Files
silo/cmd/silo/main.go
forbes-0023 127836f7ce docs: replace kindred.internal with example.internal in all docs and config
Replace all references to internal hostnames (silo.kindred.internal,
psql.kindred.internal, minio.kindred.internal, ipa.kindred.internal,
keycloak.kindred.internal) with example.internal equivalents.

Replace gitea.kindred.internal and git.kindred.internal with the public
git.kindred-systems.com instance. Also fix stale silo-0062 repo name
in setup-host.sh and DEPLOYMENT.md.
2026-02-11 11:20:45 -06:00

489 lines
11 KiB
Go

// Command silo provides CLI access to the Silo item database.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db"
"github.com/kindredsystems/silo/internal/partnum"
"github.com/kindredsystems/silo/internal/schema"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
ctx := context.Background()
cmd := os.Args[1]
switch cmd {
case "help", "-h", "--help":
printUsage()
case "register":
cmdRegister(ctx)
case "list":
cmdList(ctx)
case "show":
cmdShow(ctx)
case "revisions":
cmdRevisions(ctx)
case "schemas":
cmdSchemas(ctx)
case "token":
cmdToken(ctx)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`Silo - Item database CLI
Usage: silo <command> [options]
Commands:
register Generate a new part number and create item
list List items
show Show item details
revisions Show item revision history
schemas List available schemas
token Manage API tokens (create, list, revoke)
Token subcommands:
silo token create --name "label" Create a new API token
silo token list List your API tokens
silo token revoke <id> Revoke a token
Environment variables for API access:
SILO_API_URL Base URL of the Silo server (e.g., https://silo.example.internal)
SILO_API_TOKEN API token for authentication
Examples:
silo register --schema kindred-rd --project PROTO --type AS
silo list --type assembly
silo show PROTO-AS-0001
silo revisions PROTO-AS-0001
silo token create --name "FreeCAD workstation"
silo token list
silo token revoke 550e8400-e29b-41d4-a716-446655440000`)
}
func loadConfig() *config.Config {
cfg, err := config.Load("config.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
return cfg
}
func connectDB(ctx context.Context, cfg *config.Config) *db.DB {
database, err := db.Connect(ctx, db.Config{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
Name: cfg.Database.Name,
User: cfg.Database.User,
Password: cfg.Database.Password,
SSLMode: cfg.Database.SSLMode,
MaxConnections: cfg.Database.MaxConnections,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
os.Exit(1)
}
return database
}
func cmdRegister(ctx context.Context) {
// Parse flags
var schemaName, project, partType, description string
args := os.Args[2:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--schema", "-s":
i++
schemaName = args[i]
case "--project", "-p":
i++
project = args[i]
case "--type", "-t":
i++
partType = args[i]
case "--description", "-d":
i++
description = args[i]
}
}
if schemaName == "" || project == "" || partType == "" {
fmt.Fprintln(os.Stderr, "Usage: silo register --schema NAME --project CODE --type TYPE [--description DESC]")
os.Exit(1)
}
cfg := loadConfig()
database := connectDB(ctx, cfg)
defer database.Close()
// Load schemas
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err)
os.Exit(1)
}
// Create generator with DB-backed sequence store
seqStore := &dbSequenceStore{db: database, schemas: schemas}
gen := partnum.NewGenerator(schemas, seqStore)
// Generate part number
pn, err := gen.Generate(ctx, partnum.Input{
SchemaName: schemaName,
Values: map[string]string{
"project": project,
"part_type": partType,
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating part number: %v\n", err)
os.Exit(1)
}
// Create item in database
repo := db.NewItemRepository(database)
schemaRecord := schemas[schemaName]
schemaID := getSchemaID(ctx, database, schemaRecord.Name)
item := &db.Item{
PartNumber: pn,
SchemaID: schemaID,
ItemType: mapPartType(partType),
Description: description,
}
if err := repo.Create(ctx, item, map[string]any{
"project": project,
"part_type": partType,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error creating item: %v\n", err)
os.Exit(1)
}
fmt.Println(pn)
}
func cmdList(ctx context.Context) {
cfg := loadConfig()
database := connectDB(ctx, cfg)
defer database.Close()
var itemType, search string
args := os.Args[2:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--type", "-t":
i++
itemType = args[i]
case "--search", "-s":
i++
search = args[i]
}
}
repo := db.NewItemRepository(database)
items, err := repo.List(ctx, db.ListOptions{
ItemType: itemType,
Search: search,
Limit: 100,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing items: %v\n", err)
os.Exit(1)
}
for _, item := range items {
fmt.Printf("%s\t%s\t%s\n", item.PartNumber, item.ItemType, item.Description)
}
}
func cmdShow(ctx context.Context) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: silo show PART-NUMBER")
os.Exit(1)
}
partNumber := os.Args[2]
cfg := loadConfig()
database := connectDB(ctx, cfg)
defer database.Close()
repo := db.NewItemRepository(database)
item, err := repo.GetByPartNumber(ctx, partNumber)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if item == nil {
fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber)
os.Exit(1)
}
out, _ := json.MarshalIndent(item, "", " ")
fmt.Println(string(out))
}
func cmdRevisions(ctx context.Context) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: silo revisions PART-NUMBER")
os.Exit(1)
}
partNumber := os.Args[2]
cfg := loadConfig()
database := connectDB(ctx, cfg)
defer database.Close()
repo := db.NewItemRepository(database)
item, err := repo.GetByPartNumber(ctx, partNumber)
if err != nil || item == nil {
fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber)
os.Exit(1)
}
revisions, err := repo.GetRevisions(ctx, item.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
for _, rev := range revisions {
comment := ""
if rev.Comment != nil {
comment = *rev.Comment
}
fmt.Printf("Rev %d\t%s\t%s\n", rev.RevisionNumber, rev.CreatedAt.Format("2006-01-02 15:04"), comment)
}
}
func cmdSchemas(ctx context.Context) {
cfg := loadConfig()
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err)
os.Exit(1)
}
for name, s := range schemas {
fmt.Printf("%s\tv%d\t%s\n", name, s.Version, s.Description)
}
}
// dbSequenceStore implements partnum.SequenceStore using the database.
type dbSequenceStore struct {
db *db.DB
schemas map[string]*schema.Schema
}
func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) {
schemaID := getSchemaID(ctx, s.db, schemaName)
if schemaID == nil {
return 0, fmt.Errorf("schema not found: %s", schemaName)
}
return s.db.NextSequenceValue(ctx, *schemaID, scope)
}
func getSchemaID(ctx context.Context, database *db.DB, name string) *string {
var id string
err := database.Pool().QueryRow(ctx,
"SELECT id FROM schemas WHERE name = $1", name,
).Scan(&id)
if err != nil {
return nil
}
return &id
}
// apiClient returns the base URL and an *http.Client with the Bearer token set.
func apiRequest(method, path string, body any) (*http.Response, error) {
baseURL := os.Getenv("SILO_API_URL")
if baseURL == "" {
fmt.Fprintln(os.Stderr, "SILO_API_URL environment variable is required for token commands")
os.Exit(1)
}
token := os.Getenv("SILO_API_TOKEN")
if token == "" {
fmt.Fprintln(os.Stderr, "SILO_API_TOKEN environment variable is required for token commands")
os.Exit(1)
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling request body: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return http.DefaultClient.Do(req)
}
func cmdToken(_ context.Context) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
os.Exit(1)
}
subcmd := os.Args[2]
switch subcmd {
case "create":
cmdTokenCreate()
case "list":
cmdTokenList()
case "revoke":
cmdTokenRevoke()
default:
fmt.Fprintf(os.Stderr, "Unknown token subcommand: %s\n", subcmd)
fmt.Fprintln(os.Stderr, "Usage: silo token <create|list|revoke> [options]")
os.Exit(1)
}
}
func cmdTokenCreate() {
var name string
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--name", "-n":
if i+1 < len(args) {
i++
name = args[i]
}
}
}
if name == "" {
fmt.Fprintln(os.Stderr, "Usage: silo token create --name \"label\"")
os.Exit(1)
}
resp, err := apiRequest("POST", "/api/auth/tokens", map[string]any{"name": name})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error creating token (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
os.Exit(1)
}
fmt.Printf("Token created: %s\n", result["name"])
fmt.Printf("API Token: %s\n", result["token"])
fmt.Println("Save this token — it will not be shown again.")
}
func cmdTokenList() {
resp, err := apiRequest("GET", "/api/auth/tokens", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error listing tokens (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
var tokens []map[string]any
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding response: %v\n", err)
os.Exit(1)
}
if len(tokens) == 0 {
fmt.Println("No API tokens.")
return
}
fmt.Printf("%-36s %-20s %-15s %s\n", "ID", "NAME", "PREFIX", "CREATED")
for _, t := range tokens {
id, _ := t["id"].(string)
name, _ := t["name"].(string)
prefix, _ := t["token_prefix"].(string)
created, _ := t["created_at"].(string)
if len(created) > 10 {
created = created[:10]
}
fmt.Printf("%-36s %-20s %-15s %s\n", id, name, prefix+"...", created)
}
}
func cmdTokenRevoke() {
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "Usage: silo token revoke <token-id>")
os.Exit(1)
}
tokenID := os.Args[3]
resp, err := apiRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Error revoking token (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
fmt.Println("Token revoked.")
}
func mapPartType(code string) string {
types := map[string]string{
"AS": "assembly",
"PT": "part",
"DW": "drawing",
"DC": "document",
"TB": "tooling",
"PC": "purchased",
"EL": "electrical",
"SW": "software",
}
if t, ok := types[code]; ok {
return t
}
return code
}