From 2928bf9d79d9ae999a27c74ee5811693489a3a4e Mon Sep 17 00:00:00 2001 From: bigbes Date: Wed, 22 Jul 2026 18:34:14 +0300 Subject: [PATCH] feat(cmd): specsrht space create/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spaces had no entry point at all: the read plane only reads, and the proposal API operates on documents inside a space that already exists, so a freshly deployed instance could not hold anything. Creation installs the receive hooks itself rather than relying on the daemon's startup refresh. A space created while the daemon runs would otherwise accept unvalidated pushes until the next restart — the exact fail-open the receive path exists to prevent. --- cmd/specsrht/main.go | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/cmd/specsrht/main.go b/cmd/specsrht/main.go index 61d04182e4489c6c18d9115c237cf3d06d417b8d..7b5678bfe9a33cee052aeaf1c464cad90b8b6b7c 100644 --- a/cmd/specsrht/main.go +++ b/cmd/specsrht/main.go @@ -108,6 +108,16 @@ func main() { log := newLogger() slog.SetDefault(log) + // Admin subcommands run and exit without binding anything, so they are safe + // to invoke while the daemon holds the hook socket. + if len(os.Args) > 1 && os.Args[1] == "space" { + if err := runSpace(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "specsrht space: %v\n", err) + os.Exit(1) + } + return + } + if err := run(log); err != nil { // Plain text, not a log record. A startup failure is read by a human // on a terminal, and the configuration report is deliberately several @@ -117,6 +127,81 @@ func main() { } } +// runSpace is the space administration command: +// +// specsrht space create ~owner/name +// specsrht space list +// +// Spaces have no other entry point. The read plane only reads, and the write +// plane is the proposal API, which operates on documents inside a space that +// already exists — so without this, a freshly deployed instance has no way to +// hold anything at all. +// +// It installs the receive hooks itself rather than leaving them to the daemon's +// startup refresh: a space created while the daemon is running would otherwise +// accept unvalidated pushes until the next restart, which is exactly the +// fail-open the receive path exists to prevent. +func runSpace(args []string) error { + if len(args) == 0 { + return errors.New("usage: specsrht space create ~owner/name | specsrht space list") + } + + conf := config.LoadConfig() + cfg, err := validateConfig(conf) + if err != nil { + return err + } + pool, err := openDatabase(cfg.ConnectionString) + if err != nil { + return err + } + defer pool.Close() + + svc, err := service.New(cfg, pool) + if err != nil { + return err + } + ctx := context.Background() + + switch args[0] { + case "create": + if len(args) != 2 { + return errors.New("usage: specsrht space create ~owner/name") + } + ref, err := core.ParseSpaceRef(args[1]) + if err != nil { + return fmt.Errorf("parse %q: %w", args[1], err) + } + if _, err := svc.CreateSpace(ctx, ref); err != nil { + return err + } + binary, err := os.Executable() + if err != nil { + return fmt.Errorf("locate this binary, which every hook symlinks to: %w", err) + } + if err := hooks.InstallSpace(cfg.Repos, ref, hooks.InstallOptions{Binary: binary}); err != nil { + return fmt.Errorf("install receive hooks for %s: %w", ref, err) + } + fmt.Printf("created %s\n repo: %s\n clone: git clone %s\n", + ref, filepath.Join(cfg.Repos, "~"+ref.Owner, ref.Name), + filepath.Join(cfg.Repos, "~"+ref.Owner, ref.Name)) + return nil + + case "list": + spaces, err := svc.ListSpaces(ctx) + if err != nil { + return err + } + for _, sp := range spaces { + fmt.Println(sp.Ref) + } + return nil + + default: + return fmt.Errorf("unknown subcommand %q: want create or list", args[0]) + } +} + // newLogger builds the process logger. LOG_LEVEL raises or lowers verbosity; // everything goes to stderr, because a hook's stdout is forwarded to the // pushing client and this binary is both programs.