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.
489 lines
11 KiB
Go
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
|
|
}
|