@@ 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.