~bigbes/sr-ht-spec

2928bf9d79d9ae999a27c74ee5811693489a3a4e — bigbes 26 days ago 9647166
feat(cmd): specsrht space create/list

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.
1 files changed, 85 insertions(+), 0 deletions(-)

M cmd/specsrht/main.go
M cmd/specsrht/main.go => cmd/specsrht/main.go +85 -0
@@ 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.