@rizom/ops 0.2.0-alpha.12 → 0.2.0-alpha.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +7 -3
  2. package/dist/age-key-bootstrap.d.ts +17 -0
  3. package/dist/brains-ops.js +281 -160
  4. package/dist/cert-bootstrap.d.ts +3 -3
  5. package/dist/content-repo.d.ts +4 -0
  6. package/dist/deploy.js +3 -170
  7. package/dist/entries/deploy.d.ts +2 -2
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +280 -159
  10. package/dist/load-registry.d.ts +12 -3
  11. package/dist/observed-status.d.ts +1 -1
  12. package/dist/origin-ca.d.ts +1 -1
  13. package/dist/parse-args.d.ts +2 -0
  14. package/dist/push-secrets.d.ts +1 -1
  15. package/dist/run-command.d.ts +1 -2
  16. package/dist/schema.d.ts +64 -10
  17. package/dist/secrets-encrypt.d.ts +32 -0
  18. package/dist/secrets-push.d.ts +1 -1
  19. package/dist/ssh-key-bootstrap.d.ts +1 -1
  20. package/dist/user-add.d.ts +15 -0
  21. package/dist/verify-user.d.ts +19 -0
  22. package/package.json +7 -3
  23. package/templates/rover-pilot/.env.schema +21 -2
  24. package/templates/rover-pilot/.github/workflows/build.yml +13 -5
  25. package/templates/rover-pilot/.github/workflows/deploy.yml +64 -27
  26. package/templates/rover-pilot/.github/workflows/reconcile.yml +16 -2
  27. package/templates/rover-pilot/README.md +6 -3
  28. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +79 -0
  29. package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
  30. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -6
  31. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +12 -12
  32. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  33. package/templates/rover-pilot/docs/onboarding-checklist.md +43 -14
  34. package/templates/rover-pilot/docs/operator-playbook.md +159 -10
  35. package/templates/rover-pilot/docs/user-onboarding.md +156 -295
  36. package/templates/rover-pilot/package.json +3 -0
  37. package/templates/rover-pilot/pilot.yaml +3 -0
  38. package/templates/rover-pilot/users/alice.yaml +2 -1
  39. package/dist/user-secret-names.d.ts +0 -6
  40. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  41. package/templates/rover-pilot/deploy/Caddyfile +0 -72
  42. package/templates/rover-pilot/deploy/Dockerfile +0 -38
  43. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
@@ -13,8 +13,13 @@ on:
13
13
  - users/*/.env
14
14
  - users/*/brain.yaml
15
15
  - users/*/content/**
16
+ - users/*.secrets.yaml.age
16
17
  - deploy/**
17
18
  - .github/workflows/deploy.yml
19
+ workflow_run:
20
+ workflows: [Reconcile]
21
+ types: [completed]
22
+ branches: [main]
18
23
 
19
24
  permissions:
20
25
  contents: write
@@ -22,13 +27,16 @@ permissions:
22
27
 
23
28
  jobs:
24
29
  resolve_handles:
30
+ if: >
31
+ github.event_name != 'workflow_run' ||
32
+ github.event.workflow_run.conclusion == 'success'
25
33
  runs-on: ubuntu-latest
26
34
  outputs:
27
35
  handles_json: ${{ steps.resolve.outputs.handles_json }}
28
36
  steps:
29
37
  - uses: actions/checkout@v5
30
38
  with:
31
- ref: ${{ github.sha }}
39
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
32
40
  fetch-depth: 0
33
41
 
34
42
  - uses: oven-sh/setup-bun@v2
@@ -40,7 +48,7 @@ jobs:
40
48
  id: resolve
41
49
  env:
42
50
  HANDLE_INPUT: ${{ inputs.handle || '' }}
43
- BEFORE_SHA: ${{ github.event.before || '' }}
51
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
44
52
  run: bun deploy/scripts/resolve-deploy-handles.ts
45
53
 
46
54
  no_changes:
@@ -67,23 +75,25 @@ jobs:
67
75
  steps:
68
76
  - uses: actions/checkout@v5
69
77
  with:
70
- ref: ${{ github.sha }}
78
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
71
79
 
72
80
  - uses: oven-sh/setup-bun@v2
73
81
 
74
82
  - name: Install operator tooling
75
83
  run: bun install
76
84
 
77
- - name: Resolve selected user secret names
78
- id: user_secret_names
79
- run: |
80
- HANDLE_SUFFIX="$(printf '%s' "$HANDLE" | tr '[:lower:]-' '[:upper:]_')"
81
- echo "git_sync_token_secret_name=GIT_SYNC_TOKEN_${HANDLE_SUFFIX}" >> "$GITHUB_OUTPUT"
85
+ - name: Decrypt user secrets
86
+ id: user_secrets
87
+ env:
88
+ AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
89
+ run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
82
90
 
83
91
  - name: Reconcile selected user config
84
92
  env:
85
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_secret_names.outputs.git_sync_token_secret_name] }}
86
- run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
93
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
94
+ run: |
95
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
96
+ bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
87
97
 
88
98
  - name: Resolve generated user config
89
99
  id: user_config
@@ -91,10 +101,10 @@ jobs:
91
101
 
92
102
  - name: Validate selected secrets
93
103
  env:
94
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
95
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
96
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
97
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
104
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
105
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
106
+ SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
107
+ SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
98
108
  HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
99
109
  HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
100
110
  HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
@@ -105,16 +115,21 @@ jobs:
105
115
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
106
116
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
107
117
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
108
- run: bun deploy/scripts/validate-secrets.ts
118
+ run: |
119
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
120
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
121
+ bun deploy/scripts/validate-secrets.ts
109
122
 
110
123
  - name: Seed content repo
111
124
  env:
112
125
  CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
113
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
114
- run: bun deploy/scripts/sync-content-repo.ts
126
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
127
+ run: |
128
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
129
+ bun deploy/scripts/sync-content-repo.ts
115
130
 
116
131
  - name: Log in to GHCR
117
- uses: docker/login-action@v3
132
+ uses: docker/login-action@v4
118
133
  with:
119
134
  registry: ghcr.io
120
135
  username: ${{ github.actor }}
@@ -158,14 +173,17 @@ jobs:
158
173
 
159
174
  - name: Write .kamal/secrets
160
175
  env:
161
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
162
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
163
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
164
- DISCORD_BOT_TOKEN: ${{ steps.user_config.outputs.discord_bot_token_secret_name != '' && secrets[steps.user_config.outputs.discord_bot_token_secret_name] || '' }}
176
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
177
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
178
+ SETUP_EMAIL_API_KEY: ${{ secrets.SETUP_EMAIL_API_KEY }}
179
+ SETUP_EMAIL_FROM: ${{ secrets.SETUP_EMAIL_FROM }}
165
180
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
166
181
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
167
182
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
168
- run: bun deploy/scripts/write-kamal-secrets.ts
183
+ run: |
184
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
185
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
186
+ bun deploy/scripts/write-kamal-secrets.ts
169
187
 
170
188
  - name: Provision server
171
189
  id: provision
@@ -182,10 +200,11 @@ jobs:
182
200
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
183
201
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
184
202
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
203
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
185
204
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
186
205
  run: |
187
206
  bun deploy/scripts/update-dns.ts
188
- BRAIN_DOMAIN="preview.$BRAIN_DOMAIN" bun deploy/scripts/update-dns.ts
207
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
189
208
 
190
209
  - name: Validate SSH key
191
210
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -212,6 +231,7 @@ jobs:
212
231
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
213
232
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
214
233
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
234
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
215
235
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
216
236
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
217
237
 
@@ -219,7 +239,10 @@ jobs:
219
239
  env:
220
240
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
221
241
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
222
- run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
242
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
243
+ run: |
244
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
245
+ curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
223
246
 
224
247
  - name: Upload generated config
225
248
  uses: actions/upload-artifact@v4
@@ -255,7 +278,7 @@ jobs:
255
278
  steps:
256
279
  - uses: actions/checkout@v5
257
280
  with:
258
- ref: ${{ github.sha }}
281
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
259
282
 
260
283
  - uses: oven-sh/setup-bun@v2
261
284
 
@@ -277,8 +300,22 @@ jobs:
277
300
  if git diff --quiet -- users views; then
278
301
  exit 0
279
302
  fi
303
+
304
+ git fetch origin "${{ github.ref_name }}"
305
+ if git diff --quiet "origin/${{ github.ref_name }}" -- users views; then
306
+ exit 0
307
+ fi
308
+
309
+ patch_file="$(mktemp)"
310
+ git diff --binary -- users views > "$patch_file"
311
+ git reset --hard "origin/${{ github.ref_name }}"
312
+ git apply --3way --index "$patch_file"
313
+
314
+ if git diff --cached --quiet -- users views; then
315
+ exit 0
316
+ fi
317
+
280
318
  git config user.name "github-actions[bot]"
281
319
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
282
- git add users views
283
320
  git commit -m "chore(ops): reconcile generated config"
284
321
  git push origin HEAD:${{ github.ref_name }}
@@ -38,8 +38,22 @@ jobs:
38
38
  if git diff --quiet -- views users; then
39
39
  exit 0
40
40
  fi
41
+
42
+ git fetch origin "${{ github.ref_name }}"
43
+ if git diff --quiet "origin/${{ github.ref_name }}" -- views users; then
44
+ exit 0
45
+ fi
46
+
47
+ patch_file="$(mktemp)"
48
+ git diff --binary -- views users > "$patch_file"
49
+ git reset --hard "origin/${{ github.ref_name }}"
50
+ git apply --3way --index "$patch_file"
51
+
52
+ if git diff --cached --quiet -- views users; then
53
+ exit 0
54
+ fi
55
+
41
56
  git config user.name "github-actions[bot]"
42
57
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
43
- git add views users
44
58
  git commit -m "chore(ops): reconcile pilot outputs"
45
- git push
59
+ git push origin HEAD:${{ github.ref_name }}
@@ -29,6 +29,7 @@ The repo also checks in its deploy contract:
29
29
  - `.github/workflows/*`
30
30
 
31
31
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
32
+ Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
32
33
  The shared pilot image tag is `brain-${brainVersion}` end to end.
33
34
  When `pilot.yaml.brainVersion` changes and you push, CI rebuilds the shared tag, refreshes generated user env files, and redeploys affected users.
34
35
  When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
@@ -37,9 +38,11 @@ When a push changes only deploy contract files, CI prints `No affected user conf
37
38
 
38
39
  - `brains-ops init <repo>`
39
40
  - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
40
- - `brains-ops onboard <repo> <handle>`
41
+ - `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
42
+ - `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
43
+ - `brains-ops age-key:bootstrap <repo>`
41
44
  - `brains-ops ssh-key:bootstrap <repo>`
42
- - `brains-ops cert:bootstrap <repo> <handle>`
43
- - `brains-ops secrets:push <repo> <handle>`
45
+ - `brains-ops cert:bootstrap <repo>`
46
+ - `brains-ops secrets:encrypt <repo> <handle>`
44
47
  - `brains-ops reconcile-cohort <repo> <cohort>`
45
48
  - `brains-ops reconcile-all <repo>`
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { Decrypter, armor } from "age-encryption";
4
+
5
+ import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
6
+
7
+ const handle = process.argv[2] ?? requireEnv("HANDLE");
8
+ const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
9
+ const encryptedPath = `users/${handle}.secrets.yaml.age`;
10
+
11
+ const armored = readFileSync(encryptedPath, "utf8");
12
+ const decoded = armor.decode(armored);
13
+
14
+ const decrypter = new Decrypter();
15
+ decrypter.addIdentity(ageSecretKey);
16
+
17
+ const plaintext = await decrypter.decrypt(decoded, "text");
18
+ const secrets = parseFlatYaml(plaintext);
19
+ const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
20
+
21
+ writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
22
+ writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
23
+ writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
24
+ writeGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"] ?? "");
25
+
26
+ writeGitHubOutput(
27
+ "shared_ai_api_key_secret_name",
28
+ requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
29
+ );
30
+ writeGitHubOutput(
31
+ "shared_git_sync_token_secret_name",
32
+ requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
33
+ );
34
+
35
+ function extractAgeIdentity(contents: string): string {
36
+ const line = contents
37
+ .split(/\r?\n/)
38
+ .map((entry) => entry.trim())
39
+ .find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
40
+
41
+ if (!line) {
42
+ throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
43
+ }
44
+
45
+ return line;
46
+ }
47
+
48
+ function parseFlatYaml(contents: string): Record<string, string> {
49
+ const result: Record<string, string> = {};
50
+
51
+ for (const rawLine of contents.split(/\r?\n/)) {
52
+ const line = rawLine.trim();
53
+ if (!line || line.startsWith("#")) {
54
+ continue;
55
+ }
56
+
57
+ const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
58
+ if (!match) {
59
+ continue;
60
+ }
61
+
62
+ const [, key, rawValue] = match;
63
+ result[key] = rawValue.replace(/^['"]|['"]$/g, "");
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ function requireFlatValue(
70
+ values: Record<string, string>,
71
+ key: string,
72
+ label: string,
73
+ ): string {
74
+ const value = values[key];
75
+ if (!value) {
76
+ throw new Error(`Missing ${key} in ${label}`);
77
+ }
78
+ return value;
79
+ }
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
101
101
  server = await getServer(server.id);
102
102
  }
103
103
 
104
- const serverIp = server.public_net?.ipv4?.ip;
104
+ const serverIp = server.public_net.ipv4.ip;
105
105
  if (!serverIp) {
106
106
  throw new Error(`Server ${server.id} running but has no IPv4 address`);
107
107
  }
@@ -9,14 +9,23 @@ if (eventName === "workflow_dispatch") {
9
9
  process.exit(0);
10
10
  }
11
11
 
12
- if (eventName !== "push") {
12
+ if (eventName !== "push" && eventName !== "workflow_run") {
13
13
  throw new Error(`Unsupported GITHUB_EVENT_NAME: ${eventName}`);
14
14
  }
15
15
 
16
16
  const beforeSha = requireEnv("BEFORE_SHA");
17
- const currentSha = requireEnv("GITHUB_SHA");
17
+ const currentSha =
18
+ eventName === "workflow_run"
19
+ ? execFileSync("git", ["rev-parse", "HEAD"], {
20
+ encoding: "utf8",
21
+ }).trim()
22
+ : requireEnv("GITHUB_SHA");
18
23
 
19
- if (!isUsableGitRevision(beforeSha) || !isUsableGitRevision(currentSha)) {
24
+ if (
25
+ !isUsableGitRevision(beforeSha) ||
26
+ !isUsableGitRevision(currentSha) ||
27
+ beforeSha === currentSha
28
+ ) {
20
29
  writeGitHubOutput("handles_json", JSON.stringify([]));
21
30
  process.exit(0);
22
31
  }
@@ -32,9 +41,9 @@ const handles = [
32
41
  diffOutput
33
42
  .split(/\r?\n/)
34
43
  .map((path) => {
35
- const match = path.match(
36
- /^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/,
37
- );
44
+ const match =
45
+ path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
46
+ path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
38
47
  return match?.[1] ?? null;
39
48
  })
40
49
  .filter((handle): handle is string => handle !== null)
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+
2
3
  import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
3
4
 
4
5
  const handle = requireEnv("HANDLE");
@@ -12,32 +13,31 @@ const repositoryOwner = repository.split("/")[0] ?? "";
12
13
  const brainYaml = readFileSync(brainYamlPath, "utf8");
13
14
  const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
14
15
  const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
15
-
16
16
  if (!brainDomain) {
17
17
  throw new Error(`Missing domain in ${brainYamlPath}`);
18
18
  }
19
19
 
20
+ const zone =
21
+ brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
22
+ ? brainDomain.slice(handle.length + 1)
23
+ : "";
24
+ if (!zone) {
25
+ throw new Error(`Could not derive preview domain from ${brainDomain}`);
26
+ }
27
+ const previewDomain = `${handle}-preview.${zone}`;
28
+
20
29
  const outputs: Record<string, string> = {
21
30
  brain_version: envEntries["BRAIN_VERSION"] ?? "",
22
- ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
23
- git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
24
- mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
25
- discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
26
31
  content_repo: envEntries["CONTENT_REPO"] ?? "",
27
32
  brain_domain: brainDomain,
33
+ preview_domain: previewDomain,
28
34
  brain_yaml_path: brainYamlPath,
29
35
  instance_name: `rover-${handle}`,
30
36
  image_repository: `ghcr.io/${repository}`,
31
37
  registry_username: repositoryOwner,
32
38
  };
33
39
 
34
- const required = [
35
- "brain_version",
36
- "ai_api_key_secret_name",
37
- "git_sync_token_secret_name",
38
- "mcp_auth_token_secret_name",
39
- "registry_username",
40
- ];
40
+ const required = ["brain_version", "registry_username"];
41
41
  for (const key of required) {
42
42
  if (!outputs[key]) {
43
43
  throw new Error(`Missing ${key} (derived from ${envPath})`);
@@ -16,8 +16,11 @@ interface CloudflareResult {
16
16
  result?: Array<{ id: string }>;
17
17
  }
18
18
 
19
- async function upsertRecord(name: string): Promise<void> {
20
- const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=A&name=${encodeURIComponent(name)}`;
19
+ async function findRecordId(
20
+ name: string,
21
+ type: "A" | "CNAME",
22
+ ): Promise<string | undefined> {
23
+ const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
21
24
  const lookup = await fetch(lookupUrl, { headers });
22
25
  const payload = (await readJsonResponse(
23
26
  lookup,
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
27
30
  throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
28
31
  }
29
32
 
30
- const existing = payload.result?.[0];
33
+ return payload.result?.[0]?.id;
34
+ }
35
+
36
+ async function upsertRecord(name: string): Promise<void> {
37
+ // Prefer an existing A record. If the hostname currently has a CNAME,
38
+ // replace that CNAME in-place so deploys can claim legacy www aliases.
39
+ const existing =
40
+ (await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
31
41
  const url = existing
32
- ? `${baseUrl}/zones/${zoneId}/dns_records/${existing.id}`
42
+ ? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
33
43
  : `${baseUrl}/zones/${zoneId}/dns_records`;
34
44
 
35
45
  const response = await fetch(url, {
@@ -1,17 +1,46 @@
1
1
  # Onboarding Checklist
2
2
 
3
3
  1. Run `bun install` so the repo uses its pinned `@rizom/ops` version.
4
- 2. Fill in `pilot.yaml`.
5
- 3. Add or edit `users/<handle>.yaml`.
6
- 4. Add the user to a cohort in `cohorts/*.yaml`.
7
- 5. Run `bunx brains-ops render <repo>`.
8
- 6. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
9
- 7. Run `bunx brains-ops cert:bootstrap <repo> <handle> --push-to gh`.
10
- 8. Run `bunx brains-ops secrets:push <repo> <handle>`.
11
- 9. Run `bunx brains-ops onboard <repo> <handle>`.
12
- 10. Verify the deployed rover core contract:
13
- - `https://<handle>.rizom.ai/health` returns `200`
14
- - unauthenticated `POST https://<handle>.rizom.ai/mcp` returns `401`
15
- 11. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
16
- 12. Hand the MCP connection details to the user.
17
- 13. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
4
+ 2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
5
+ 3. Fill in `pilot.yaml`.
6
+ - keep your pinned `brainVersion`
7
+ - confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `contentRepoAdminToken`
8
+ - use different tokens for `contentRepoAdminToken` and `gitSyncToken`: admin creates/checks content repos; sync is used by runtime directory-sync
9
+ - confirm `agePublicKey`
10
+ 4. Run `bunx brains-ops user:add <repo> <handle> --cohort <cohort>`.
11
+ - Web chat is the primary interface; it needs no per-user setup beyond the passkey.
12
+ - `user:add` currently writes `discord: enabled: true`; set it to `false` unless the user's cohort actually uses Discord.
13
+ - if the user should be an anchor on Discord, add `--anchor-id <discord-user-id>`.
14
+ - the command creates `users/<handle>.yaml`, `users/<handle>.secrets.yaml`, and the cohort membership without duplicating existing entries.
15
+ 5. Edit the generated user file if the anchor profile needs richer metadata.
16
+ - Set `setup.delivery: email` and `setup.email` so the user gets the passkey setup email — this is the default onboarding path.
17
+ - For ATProto publishing, add `atproto.identifier` to the user file; put only `atprotoAppPassword` in the per-user secrets file.
18
+ - Ensure `SETUP_EMAIL_API_KEY` and `SETUP_EMAIL_FROM` exist as GitHub Secrets before deploying any email-setup user.
19
+ 6. Run `bunx brains-ops render <repo>`.
20
+ 7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
21
+ 8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
22
+ 9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs), including `CONTENT_REPO_ADMIN_TOKEN` for operator onboarding.
23
+ 10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
24
+ 11. Commit and push `users/<handle>.secrets.yaml.age`.
25
+ 12. Run `bunx brains-ops onboard <repo> <handle>`.
26
+ 13. Verify the deployed Rover contract:
27
+ - all presets:
28
+ - `https://<handle>.rizom.ai/health` returns `200`
29
+ - `https://<handle>.rizom.ai/chat` loads the web chat and accepts passkey sign-in
30
+ - `https://<handle>.rizom.ai/` loads the dashboard (or site surface on `default` preset)
31
+ - `https://<handle>.rizom.ai/cms` loads the CMS/login surface
32
+ - unauthenticated `POST https://<handle>.rizom.ai/mcp` returns the expected auth failure
33
+ - content repo exists and runtime sync is healthy
34
+ - background jobs are not repeatedly failing, except for expected missing optional integrations
35
+ - for `presetOverride: default` users:
36
+ - initial site build completes
37
+ 14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
38
+ 15. Confirm the user received the setup email, registered their passkey, and can sign in to web chat at `https://<handle>.rizom.ai/chat`. That completes the default onboarding; everything below is per-cohort extras.
39
+ 16. Hand over the browser surfaces:
40
+ - Chat (primary): `https://<handle>.rizom.ai/chat`
41
+ - Dashboard: `https://<handle>.rizom.ai/`
42
+ - CMS: `https://<handle>.rizom.ai/cms`, plus GitHub token guidance if CMS editing is part of their cohort
43
+ 17. For Discord-enabled cohorts, hand the Discord setup details to the user as a secondary chat surface.
44
+ 18. If they need direct client access (MCP), use OAuth/passkey-capable clients where possible.
45
+ 19. If you are also giving them a content repo workflow, describe it as optional and frame git/Obsidian as an advanced file-based path, not the default.
46
+ 20. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.