gitmote

gitmote  ·  history  ·  branches

feat: bootstrap — first admin, token, repo from an empty instance

1a15fb27acd145c7a69e94710ed4bcd8cb64edff
Atanas Minev <atmin@protonmail.com> · 2026-07-02 19:02

diff --git a/README.md b/README.md
index bf0b950..4e50e5a 100644
--- a/README.md
+++ b/README.md
@@ -11,5 +11,24 @@ objects/packs to S3, mutable refs and forge metadata to s3lite (plain SQLite,
backed to S3) — so the only genuinely hard problem, atomic ref updates, becomes a
single SQL transaction.
-**Status:** design stage, no code yet. The design lives in
-[docs/architecture/](docs/architecture/); tasks to follow.
+**Status:** early implementation — git read/write over smart-HTTP works
+(clone/fetch/push with token auth and per-repo ACLs); the self-hosting milestone
+is in progress. The design lives in [docs/architecture/](docs/architecture/).
+
+## Bootstrap
+
+An empty instance has no users, so token auth is a chicken-and-egg. Run the
+one-time `bootstrap` subcommand **inside the single writer** to create the first
+admin, mint a token (printed once), and create the initial repo:
+
+```sh
+GITMOTE_DB=/data/meta.sqlite3 gitmote bootstrap -handle atmin -repo atmin/gitmote
+```
+
+It prints an access token exactly once — save it. Re-running is safe: it refuses
+to clobber an existing admin. Then start the server (`GITMOTE_S3_BUCKET` et al.,
+sharing the same `GITMOTE_DB`) and clone/push with the token:
+
+```sh
+git clone http://atmin:<token>@<host>/atmin/gitmote
+```
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 22bc9b5..dd36ee0 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -6,6 +6,7 @@ import (
"errors"
"flag"
"fmt"
+ "io"
"log/slog"
"net/http"
"os"
@@ -15,6 +16,7 @@ import (
"time"
"github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/bootstrap"
"github.com/atmin/gitmote/internal/githttp"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
@@ -27,6 +29,16 @@ var version = "dev"
const shutdownTimeout = 10 * time.Second
func main() {
+ // Subcommands run without the server (single-writer admin, per
+ // docs/notes/bootstrap.md); the default (no subcommand) is the server.
+ if args := os.Args[1:]; len(args) > 0 && args[0] == "bootstrap" {
+ if err := runBootstrap(context.Background(), args[1:], os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, "bootstrap:", err)
+ os.Exit(1)
+ }
+ return
+ }
+
addr := flag.String("addr", envOr("GITMOTE_ADDR", ":8080"), "listen address")
flag.Parse()
@@ -38,6 +50,46 @@ func main() {
}
}
+// runBootstrap creates the first admin, token, and repo from an empty instance.
+// It opens the metadata DB at GITMOTE_DB (local-only; litestream is wired in the
+// deploy task) and prints the one-time token to out on success.
+func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
+ fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
+ handle := fs.String("handle", os.Getenv("GITMOTE_ADMIN_HANDLE"), "admin user handle (or GITMOTE_ADMIN_HANDLE)")
+ repoName := fs.String("repo", "", "initial repository, e.g. atmin/gitmote")
+ branch := fs.String("default-branch", "main", "default branch for the initial repo")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ md, err := meta.Open(ctx, meta.Config{LocalPath: envOr("GITMOTE_DB", "gitmote.sqlite3")})
+ if err != nil {
+ return fmt.Errorf("open metadata: %w", err)
+ }
+ defer func() { _ = md.Close() }()
+
+ res, err := bootstrap.Run(ctx, md, bootstrap.Options{
+ AdminHandle: *handle,
+ RepoName: *repoName,
+ DefaultBranch: *branch,
+ })
+ if err != nil {
+ return err
+ }
+
+ if res.AlreadyBootstrapped {
+ _, err := io.WriteString(out, "already bootstrapped: an admin exists; nothing to do\n")
+ return err
+ }
+ _, err = fmt.Fprintf(out,
+ "admin user: %s\n"+
+ "initial repo: %s\n\n"+
+ "access token (shown once — save it now):\n\n %s\n\n"+
+ "clone/push with: git clone http://%s:<token>@<host>/%s\n",
+ res.Admin.Handle, res.Repo.Name, res.RawToken, res.Admin.Handle, res.Repo.Name)
+ return err
+}
+
// run starts the HTTP server and blocks until ctx is cancelled or a
// SIGINT/SIGTERM arrives, then shuts the server down gracefully.
func run(ctx context.Context, logger *slog.Logger, addr string) error {
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index d3a144d..a23eec9 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -1,10 +1,12 @@
package main
import (
+ "bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -106,6 +108,29 @@ func TestRunShutsDownOnCancel(t *testing.T) {
}
}
+func TestRunBootstrap(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "meta.sqlite3")
+ t.Setenv("GITMOTE_DB", dbPath)
+
+ var out bytes.Buffer
+ args := []string{"-handle", "atmin", "-repo", "atmin/gitmote"}
+ if err := runBootstrap(context.Background(), args, &out); err != nil {
+ t.Fatalf("runBootstrap: %v", err)
+ }
+ if !strings.Contains(out.String(), "atmin/gitmote") || !strings.Contains(out.String(), "access token") {
+ t.Errorf("bootstrap output missing repo/token:\n%s", out.String())
+ }
+
+ // Re-running against the same DB refuses to clobber the admin.
+ var out2 bytes.Buffer
+ if err := runBootstrap(context.Background(), args, &out2); err != nil {
+ t.Fatalf("second runBootstrap: %v", err)
+ }
+ if !strings.Contains(out2.String(), "already bootstrapped") {
+ t.Errorf("second run did not report already-bootstrapped:\n%s", out2.String())
+ }
+}
+
func TestRunFailsOnBadAddr(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md
index a346102..94d2689 100644
--- a/docs/architecture/open-questions.md
+++ b/docs/architecture/open-questions.md
@@ -9,8 +9,9 @@ Items with worked-out context live as one-concern notes under
the closure must be present before it runs ("on demand" doesn't apply at the
git layer); full-hydrate vs partial-clone/promisor —
[../notes/object-hydration.md](../notes/object-hydration.md).
-- **Bootstrap.** Creating the first admin, token, and repo from an empty bucket —
- [../notes/bootstrap.md](../notes/bootstrap.md).
+- **Repo-creation rules.** The `owner/name` convention, who may create repos,
+ and reserved names — for the management UI (task 09). (First-admin bootstrap
+ itself is done: `gitmote bootstrap`.)
- **gc / compaction.** Loose objects proliferate; a `cleanup` subcommand
(triggered by loose-object count) repacks and prunes orphans. Pruning must not
race in-flight reads — a fetch pins a ref snapshot from s3lite and pushes only
diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md
index 4c9a2be..602ec75 100644
--- a/docs/architecture/storage.md
+++ b/docs/architecture/storage.md
@@ -77,6 +77,7 @@ CREATE TABLE refs (
CREATE TABLE users (
id INTEGER PRIMARY KEY,
handle TEXT NOT NULL UNIQUE,
+ is_admin INTEGER NOT NULL DEFAULT 0, -- global admin: may manage users/repos/ACLs
created_at TEXT NOT NULL
);
@@ -109,6 +110,11 @@ CREATE TABLE acls (
`HEAD` is not a row — it derives from `repos.default_branch`. Every `refs` row
holds a concrete object id; symbolic refs beyond `HEAD` are not stored.
+`users.is_admin` is the **global** administrator flag — the role that may create
+users and repos and manage ACLs (the entry point is `gitmote bootstrap`, which
+mints the first admin). It is distinct from `acls.perm='admin'`, which is
+per-repo; day-to-day repo access is always governed by `acls`.
+
A PAT is a `selector.secret` pair. The **selector** is a non-secret, unique,
indexed lookup key; only the **verifier** — `SHA-256` of the **secret** half —
is stored. Verification looks the row up by selector, then compares the verifier
diff --git a/docs/notes/README.md b/docs/notes/README.md
index c1fd8a1..572b0fc 100644
--- a/docs/notes/README.md
+++ b/docs/notes/README.md
@@ -8,5 +8,3 @@ here.
- [object-hydration.md](object-hydration.md) — getting the object closure onto
local disk before git runs; full-hydrate vs partial-clone.
-- [bootstrap.md](bootstrap.md) — first admin, token, repo, and schema migration
- from an empty bucket.
diff --git a/docs/notes/bootstrap.md b/docs/notes/bootstrap.md
deleted file mode 100644
index a5338f2..0000000
--- a/docs/notes/bootstrap.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Bootstrap
-
-Getting from an empty bucket + empty s3lite to a usable instance.
-
-**Context.** Nothing works until a user, a token, and a repo exist — and the
-s3lite schema itself must be created. None of this has an entry path yet.
-
-## What must exist on day one
-
-- The s3lite schema (tables from
- [architecture → storage layout](../architecture/storage.md)). Run migrations
- automatically on s3lite `Open`.
-- A first **admin** user + a token to authenticate as them.
-- At least one repo to push to.
-
-## Options
-
-1. **Env-seeded admin.** On first boot, if `users` is empty, seed an admin from
- `GITMOTE_ADMIN_HANDLE` + a token hash in env. Zero-interaction — good for a
- scale-to-zero container.
-2. **CLI subcommand.** `gitmote admin add-user`, `gitmote repo create`, … —
- arg-based, no server, run inside the one writer container (mirrors s3lite's
- single-writer rule). Good for ongoing admin.
-3. **Web setup wizard** on first run. More UX, more code.
-
-## Leaning
-
-(1) for the very first admin (idempotent seed), (2) for everything after. Schema
-migrations run on `Open`.
-
-## Open sub-points
-
-- **Schema gap:** `acls.perm='admin'` is _per-repo_. A **global** admin (who may
- create users/repos) isn't modeled — likely needs a `users.is_admin` flag or a
- global-role table. Resolve before building bootstrap.
-- Token format + hashing (argon2id vs sha256; the raw token is shown once).
-- Repo-creation rules — the `owner/name` convention (`repos.name`), who may
- create, reserved names.
diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
new file mode 100644
index 0000000..4408505
--- /dev/null
+++ b/internal/bootstrap/bootstrap.go
@@ -0,0 +1,85 @@
+// Package bootstrap takes an empty instance to a usable one: the first admin
+// user, a token to authenticate as them, and an initial repo — per
+// docs/notes/bootstrap.md. The schema itself is created by meta.Open; this fills
+// in the day-one rows that token auth otherwise makes a chicken-and-egg.
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+// Options parameterize a bootstrap.
+type Options struct {
+ // AdminHandle is the first global admin's handle. Required.
+ AdminHandle string
+ // RepoName is the initial repo, e.g. "atmin/gitmote". Required.
+ RepoName string
+ // DefaultBranch for the initial repo; empty means "main".
+ DefaultBranch string
+ // TokenLabel labels the minted admin token; empty means "bootstrap".
+ TokenLabel string
+}
+
+// Result reports what a bootstrap did. When AlreadyBootstrapped is true, the
+// instance already had an admin and nothing was changed; RawToken is empty.
+type Result struct {
+ AlreadyBootstrapped bool
+ Admin *meta.User
+ Repo *meta.Repo
+ // RawToken is the admin's personal access token, shown exactly once. It is
+ // never recoverable afterward.
+ RawToken string
+}
+
+// Run bootstraps md. It refuses to clobber an existing admin: if any global
+// admin already exists it returns a Result with AlreadyBootstrapped set and
+// makes no changes, so re-running is safe. Otherwise it creates the admin,
+// mints a token, creates the initial repo, and grants the admin repo-admin
+// access.
+func Run(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error) {
+ if opts.AdminHandle == "" {
+ return nil, fmt.Errorf("bootstrap: admin handle is required")
+ }
+ if opts.RepoName == "" {
+ return nil, fmt.Errorf("bootstrap: repo name is required")
+ }
+
+ exists, err := md.AdminExists(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ return &Result{AlreadyBootstrapped: true}, nil
+ }
+
+ admin, err := md.CreateAdmin(ctx, opts.AdminHandle)
+ if err != nil {
+ return nil, fmt.Errorf("bootstrap: create admin: %w", err)
+ }
+
+ raw, selector, verifier, err := auth.Mint()
+ if err != nil {
+ return nil, fmt.Errorf("bootstrap: mint token: %w", err)
+ }
+ label := opts.TokenLabel
+ if label == "" {
+ label = "bootstrap"
+ }
+ if _, err := md.CreateToken(ctx, admin.ID, selector, verifier, label); err != nil {
+ return nil, fmt.Errorf("bootstrap: store token: %w", err)
+ }
+
+ repo, err := md.CreateRepo(ctx, opts.RepoName, opts.DefaultBranch)
+ if err != nil {
+ return nil, fmt.Errorf("bootstrap: create repo: %w", err)
+ }
+ if err := md.SetACL(ctx, repo.ID, admin.ID, meta.PermAdmin); err != nil {
+ return nil, fmt.Errorf("bootstrap: grant admin acl: %w", err)
+ }
+
+ return &Result{Admin: admin, Repo: repo, RawToken: raw}, nil
+}
diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go
new file mode 100644
index 0000000..301df7c
--- /dev/null
+++ b/internal/bootstrap/bootstrap_test.go
@@ -0,0 +1,105 @@
+package bootstrap
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/auth"
+ "github.com/atmin/gitmote/internal/meta"
+)
+
+func openMeta(t *testing.T) *meta.Metadata {
+ t.Helper()
+ m, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+ return m
+}
+
+func TestRunCreatesUsableInstance(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+
+ res, err := Run(ctx, m, Options{AdminHandle: "atmin", RepoName: "atmin/gitmote"})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if res.AlreadyBootstrapped {
+ t.Fatal("fresh instance reported AlreadyBootstrapped")
+ }
+ if res.Admin == nil || !res.Admin.IsAdmin {
+ t.Fatalf("admin = %+v, want a global admin", res.Admin)
+ }
+ if res.Repo == nil || res.Repo.Name != "atmin/gitmote" {
+ t.Fatalf("repo = %+v, want atmin/gitmote", res.Repo)
+ }
+ if res.RawToken == "" {
+ t.Fatal("no token returned")
+ }
+
+ // The printed token authenticates as the admin and is authorized to write
+ // the initial repo (the admin holds a repo-admin ACL).
+ guard := auth.NewGuard(m)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Authorization", "Bearer "+res.RawToken)
+ user, err := guard.Authorize(req, "atmin/gitmote", meta.PermWrite)
+ if err != nil {
+ t.Fatalf("Authorize with bootstrap token: %v", err)
+ }
+ if user.ID != res.Admin.ID {
+ t.Errorf("authorized user = %d, want admin %d", user.ID, res.Admin.ID)
+ }
+}
+
+func TestRunIsIdempotentAndRefusesToClobber(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+
+ first, err := Run(ctx, m, Options{AdminHandle: "atmin", RepoName: "atmin/gitmote"})
+ if err != nil {
+ t.Fatalf("first Run: %v", err)
+ }
+
+ // A second run must not create a new admin or mint a new token.
+ second, err := Run(ctx, m, Options{AdminHandle: "someone-else", RepoName: "other/repo"})
+ if err != nil {
+ t.Fatalf("second Run: %v", err)
+ }
+ if !second.AlreadyBootstrapped {
+ t.Error("second Run did not report AlreadyBootstrapped")
+ }
+ if second.RawToken != "" {
+ t.Error("second Run minted a token")
+ }
+
+ // The original admin still authenticates; the would-be second admin was
+ // never created.
+ guard := auth.NewGuard(m)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("Authorization", "Bearer "+first.RawToken)
+ if _, err := guard.Authorize(req, "atmin/gitmote", meta.PermAdmin); err != nil {
+ t.Errorf("original admin no longer authorized: %v", err)
+ }
+ if _, err := m.GetUser(ctx, "someone-else"); err == nil {
+ t.Error("second Run created a user despite an existing admin")
+ }
+}
+
+func TestRunValidatesOptions(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+
+ if _, err := Run(ctx, m, Options{RepoName: "a/b"}); err == nil {
+ t.Error("Run without a handle succeeded, want error")
+ }
+ if _, err := Run(ctx, m, Options{AdminHandle: "atmin"}); err == nil {
+ t.Error("Run without a repo succeeded, want error")
+ }
+}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
index b32d147..9b06134 100644
--- a/internal/meta/meta.go
+++ b/internal/meta/meta.go
@@ -75,3 +75,11 @@ func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// isNoRows reports whether err is the "no rows" sentinel.
func isNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) }
+
+// boolToInt maps a Go bool to the 0/1 SQLite stores for it.
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 3a8a6d9..5b1bba1 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS refs (
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
handle TEXT NOT NULL UNIQUE,
+ is_admin INTEGER NOT NULL DEFAULT 0, -- global admin: may manage users/repos/ACLs
created_at TEXT NOT NULL
);
diff --git a/internal/meta/users.go b/internal/meta/users.go
index 8878e8d..76b9e16 100644
--- a/internal/meta/users.go
+++ b/internal/meta/users.go
@@ -6,10 +6,12 @@ import (
"time"
)
-// User is a forge account.
+// User is a forge account. IsAdmin marks a global administrator (see
+// storage.md) — distinct from a per-repo admin ACL.
type User struct {
ID int64
Handle string
+ IsAdmin bool
CreatedAt time.Time
}
@@ -31,11 +33,21 @@ type TokenAuth struct {
User User
}
-// CreateUser inserts a user.
+// CreateUser inserts a regular (non-admin) user.
func (m *Metadata) CreateUser(ctx context.Context, handle string) (*User, error) {
+ return m.createUser(ctx, handle, false)
+}
+
+// CreateAdmin inserts a global administrator (users.is_admin = 1).
+func (m *Metadata) CreateAdmin(ctx context.Context, handle string) (*User, error) {
+ return m.createUser(ctx, handle, true)
+}
+
+func (m *Metadata) createUser(ctx context.Context, handle string, isAdmin bool) (*User, error) {
created := now()
res, err := m.db.ExecContext(ctx,
- `INSERT INTO users (handle, created_at) VALUES (?, ?)`, handle, created)
+ `INSERT INTO users (handle, is_admin, created_at) VALUES (?, ?, ?)`,
+ handle, boolToInt(isAdmin), created)
if err != nil {
return nil, err
}
@@ -43,28 +55,42 @@ func (m *Metadata) CreateUser(ctx context.Context, handle string) (*User, error)
if err != nil {
return nil, err
}
- return &User{ID: id, Handle: handle, CreatedAt: parseTime(created)}, nil
+ return &User{ID: id, Handle: handle, IsAdmin: isAdmin, CreatedAt: parseTime(created)}, nil
}
// GetUser returns the user with the given handle, or ErrNotFound.
func (m *Metadata) GetUser(ctx context.Context, handle string) (*User, error) {
var (
- u User
- ts string
+ u User
+ isAdmin int64
+ ts string
)
err := m.db.QueryRowContext(ctx,
- `SELECT id, handle, created_at FROM users WHERE handle = ?`, handle).
- Scan(&u.ID, &u.Handle, &ts)
+ `SELECT id, handle, is_admin, created_at FROM users WHERE handle = ?`, handle).
+ Scan(&u.ID, &u.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
+ u.IsAdmin = isAdmin != 0
u.CreatedAt = parseTime(ts)
return &u, nil
}
+// AdminExists reports whether any global administrator exists — the signal
+// bootstrap uses to refuse clobbering an already-initialized instance.
+func (m *Metadata) AdminExists(ctx context.Context) (bool, error) {
+ var exists int64
+ err := m.db.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM users WHERE is_admin = 1)`).Scan(&exists)
+ if err != nil {
+ return false, err
+ }
+ return exists != 0, nil
+}
+
// CreateToken stores a personal access token for a user. selector is the token's
// public lookup key; verifier is the hash of its secret half (see internal/auth
// for the token format). Neither the raw token nor the secret is persisted.
@@ -88,20 +114,22 @@ func (m *Metadata) CreateToken(ctx context.Context, userID int64, selector, veri
// time; this method deliberately does not touch last_used (see TouchToken).
func (m *Metadata) TokenBySelector(ctx context.Context, selector string) (*TokenAuth, error) {
var (
- ta TokenAuth
- ts string
+ ta TokenAuth
+ isAdmin int64
+ ts string
)
err := m.db.QueryRowContext(ctx,
- `SELECT t.id, t.verifier, u.id, u.handle, u.created_at
+ `SELECT t.id, t.verifier, u.id, u.handle, u.is_admin, u.created_at
FROM tokens t JOIN users u ON u.id = t.user_id
WHERE t.selector = ?`, selector).
- Scan(&ta.TokenID, &ta.Verifier, &ta.User.ID, &ta.User.Handle, &ts)
+ Scan(&ta.TokenID, &ta.Verifier, &ta.User.ID, &ta.User.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
+ ta.User.IsAdmin = isAdmin != 0
ta.User.CreatedAt = parseTime(ts)
return &ta, nil
}
diff --git a/tasks/08-bootstrap.md b/tasks/08-bootstrap.md
deleted file mode 100644
index b4da31e..0000000
--- a/tasks/08-bootstrap.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# 08 — Bootstrap
-
-Depends on: 07.
-
-## Spec
-
-Get from an empty bucket + empty s3lite to a usable instance: schema, a first
-**admin** user + token, and a first repo — per
-[notes/bootstrap.md](../docs/notes/bootstrap.md).
-
-## Current
-
-Schema is created on Open (task 03), but there is no entry path to create the
-first admin/token/repo — a chicken-and-egg with token auth.
-
-## Change
-
-- `gitmote bootstrap` subcommand (arg-dispatched in `main`, no server): ensure
- schema, create an admin user, mint + print a token **once**, create an initial
- repo. Idempotent where safe; refuse to clobber an existing admin.
-- Document the one-time flow in README / ops.
-
-## Verify
-
-- From a fresh MinIO bucket: run `bootstrap`, then use the printed token to
- `git push` and `git clone` the initial repo end-to-end.
-- Re-running bootstrap neither duplicates nor overwrites the admin.
-- Non-breaking: new subcommand; the server path is unchanged.
diff --git a/tasks/README.md b/tasks/README.md
index b15df42..7d77383 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [08-bootstrap.md](08-bootstrap.md) — First admin, token, repo from an empty bucket.
- [09-management-web-ui.md](09-management-web-ui.md) — Authenticated UI: repos, tokens, ACLs.
- [10-self-host-local.md](10-self-host-local.md) — MinIO + Docker; e2e clone+push of the gitmote repo.
- [11-deploy.md](11-deploy.md) — Real object storage + single-writer deploy; hosts itself for real.