@rizom/ops 0.2.0-alpha.7 → 0.2.0-alpha.70

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 (50) hide show
  1. package/README.md +6 -3
  2. package/dist/age-key-bootstrap.d.ts +17 -0
  3. package/dist/brains-ops.js +262 -156
  4. package/dist/cert-bootstrap.d.ts +3 -3
  5. package/dist/content-repo.d.ts +13 -0
  6. package/dist/default-user-runner.d.ts +1 -1
  7. package/dist/deploy.js +3 -170
  8. package/dist/entries/deploy.d.ts +2 -2
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +262 -156
  11. package/dist/load-registry.d.ts +19 -3
  12. package/dist/observed-status.d.ts +1 -1
  13. package/dist/onboard-user.d.ts +2 -2
  14. package/dist/origin-ca.d.ts +1 -1
  15. package/dist/parse-args.d.ts +2 -0
  16. package/dist/push-secrets.d.ts +1 -1
  17. package/dist/reconcile-all.d.ts +2 -2
  18. package/dist/reconcile-cohort.d.ts +2 -2
  19. package/dist/reconcile-lib.d.ts +4 -2
  20. package/dist/run-command.d.ts +1 -2
  21. package/dist/run-subprocess.d.ts +1 -0
  22. package/dist/schema.d.ts +100 -0
  23. package/dist/secrets-encrypt.d.ts +32 -0
  24. package/dist/secrets-push.d.ts +1 -1
  25. package/dist/ssh-key-bootstrap.d.ts +1 -1
  26. package/dist/user-add.d.ts +15 -0
  27. package/dist/user-runner.d.ts +5 -0
  28. package/package.json +7 -3
  29. package/templates/rover-pilot/.env.schema +11 -0
  30. package/templates/rover-pilot/.github/workflows/build.yml +4 -4
  31. package/templates/rover-pilot/.github/workflows/deploy.yml +75 -20
  32. package/templates/rover-pilot/.github/workflows/reconcile.yml +16 -2
  33. package/templates/rover-pilot/README.md +6 -3
  34. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +83 -0
  35. package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
  36. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +15 -4
  37. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +12 -12
  38. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +179 -0
  39. package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
  40. package/templates/rover-pilot/docs/onboarding-checklist.md +28 -12
  41. package/templates/rover-pilot/docs/operator-playbook.md +43 -5
  42. package/templates/rover-pilot/docs/user-onboarding.md +292 -99
  43. package/templates/rover-pilot/package.json +3 -0
  44. package/templates/rover-pilot/pilot.yaml +4 -0
  45. package/templates/rover-pilot/users/alice.yaml +5 -1
  46. package/dist/user-secret-names.d.ts +0 -6
  47. package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
  48. package/templates/rover-pilot/deploy/Caddyfile +0 -66
  49. package/templates/rover-pilot/deploy/Dockerfile +0 -38
  50. package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -39
@@ -12,8 +12,14 @@ on:
12
12
  paths:
13
13
  - users/*/.env
14
14
  - users/*/brain.yaml
15
+ - users/*/content/**
16
+ - users/*.secrets.yaml.age
15
17
  - deploy/**
16
18
  - .github/workflows/deploy.yml
19
+ workflow_run:
20
+ workflows: [Reconcile]
21
+ types: [completed]
22
+ branches: [main]
17
23
 
18
24
  permissions:
19
25
  contents: write
@@ -21,13 +27,16 @@ permissions:
21
27
 
22
28
  jobs:
23
29
  resolve_handles:
30
+ if: >
31
+ github.event_name != 'workflow_run' ||
32
+ github.event.workflow_run.conclusion == 'success'
24
33
  runs-on: ubuntu-latest
25
34
  outputs:
26
35
  handles_json: ${{ steps.resolve.outputs.handles_json }}
27
36
  steps:
28
37
  - uses: actions/checkout@v5
29
38
  with:
30
- ref: ${{ github.sha }}
39
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
31
40
  fetch-depth: 0
32
41
 
33
42
  - uses: oven-sh/setup-bun@v2
@@ -39,7 +48,7 @@ jobs:
39
48
  id: resolve
40
49
  env:
41
50
  HANDLE_INPUT: ${{ inputs.handle || '' }}
42
- BEFORE_SHA: ${{ github.event.before || '' }}
51
+ BEFORE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.before || '' }}
43
52
  run: bun deploy/scripts/resolve-deploy-handles.ts
44
53
 
45
54
  no_changes:
@@ -66,15 +75,25 @@ jobs:
66
75
  steps:
67
76
  - uses: actions/checkout@v5
68
77
  with:
69
- ref: ${{ github.sha }}
78
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
70
79
 
71
80
  - uses: oven-sh/setup-bun@v2
72
81
 
73
82
  - name: Install operator tooling
74
83
  run: bun install
75
84
 
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"
90
+
76
91
  - name: Reconcile selected user config
77
- run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
92
+ env:
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"
78
97
 
79
98
  - name: Resolve generated user config
80
99
  id: user_config
@@ -82,10 +101,9 @@ jobs:
82
101
 
83
102
  - name: Validate selected secrets
84
103
  env:
85
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
86
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
87
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
88
- 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
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
89
107
  HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
90
108
  HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
91
109
  HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
@@ -96,10 +114,22 @@ jobs:
96
114
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
97
115
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
98
116
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
99
- run: bun deploy/scripts/validate-secrets.ts
117
+ run: |
118
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
119
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
120
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
121
+ bun deploy/scripts/validate-secrets.ts
122
+
123
+ - name: Seed content repo
124
+ env:
125
+ CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
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
100
130
 
101
131
  - name: Log in to GHCR
102
- uses: docker/login-action@v3
132
+ uses: docker/login-action@v4
103
133
  with:
104
134
  registry: ghcr.io
105
135
  username: ${{ github.actor }}
@@ -143,14 +173,17 @@ jobs:
143
173
 
144
174
  - name: Write .kamal/secrets
145
175
  env:
146
- AI_API_KEY: ${{ secrets[steps.user_config.outputs.ai_api_key_secret_name] }}
147
- GIT_SYNC_TOKEN: ${{ secrets[steps.user_config.outputs.git_sync_token_secret_name] }}
148
- MCP_AUTH_TOKEN: ${{ secrets[steps.user_config.outputs.mcp_auth_token_secret_name] }}
149
- 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
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
150
179
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
151
180
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
152
181
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
153
- run: bun deploy/scripts/write-kamal-secrets.ts
182
+ run: |
183
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
184
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
185
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
186
+ bun deploy/scripts/write-kamal-secrets.ts
154
187
 
155
188
  - name: Provision server
156
189
  id: provision
@@ -167,8 +200,11 @@ jobs:
167
200
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
168
201
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
202
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
203
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
170
204
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
171
- run: bun deploy/scripts/update-dns.ts
205
+ run: |
206
+ bun deploy/scripts/update-dns.ts
207
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
172
208
 
173
209
  - name: Validate SSH key
174
210
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -195,6 +231,7 @@ jobs:
195
231
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
196
232
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
197
233
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
234
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
198
235
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
199
236
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
200
237
 
@@ -202,7 +239,10 @@ jobs:
202
239
  env:
203
240
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
204
241
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
205
- 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/"
206
246
 
207
247
  - name: Upload generated config
208
248
  uses: actions/upload-artifact@v4
@@ -211,6 +251,7 @@ jobs:
211
251
  path: |
212
252
  users/${{ matrix.handle }}/brain.yaml
213
253
  users/${{ matrix.handle }}/.env
254
+ users/${{ matrix.handle }}/content
214
255
 
215
256
  - name: Dump remote proxy diagnostics
216
257
  if: failure()
@@ -237,7 +278,7 @@ jobs:
237
278
  steps:
238
279
  - uses: actions/checkout@v5
239
280
  with:
240
- ref: ${{ github.sha }}
281
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.sha }}
241
282
 
242
283
  - uses: oven-sh/setup-bun@v2
243
284
 
@@ -259,8 +300,22 @@ jobs:
259
300
  if git diff --quiet -- users views; then
260
301
  exit 0
261
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
+
262
318
  git config user.name "github-actions[bot]"
263
319
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
264
- git add users views
265
320
  git commit -m "chore(ops): reconcile generated config"
266
- git push
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,83 @@
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("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
24
+ writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
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
+ writeGitHubOutput(
35
+ "shared_mcp_auth_token_secret_name",
36
+ requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
37
+ );
38
+
39
+ function extractAgeIdentity(contents: string): string {
40
+ const line = contents
41
+ .split(/\r?\n/)
42
+ .map((entry) => entry.trim())
43
+ .find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
44
+
45
+ if (!line) {
46
+ throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
47
+ }
48
+
49
+ return line;
50
+ }
51
+
52
+ function parseFlatYaml(contents: string): Record<string, string> {
53
+ const result: Record<string, string> = {};
54
+
55
+ for (const rawLine of contents.split(/\r?\n/)) {
56
+ const line = rawLine.trim();
57
+ if (!line || line.startsWith("#")) {
58
+ continue;
59
+ }
60
+
61
+ const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
62
+ if (!match) {
63
+ continue;
64
+ }
65
+
66
+ const [, key, rawValue] = match;
67
+ result[key] = rawValue.replace(/^['"]|['"]$/g, "");
68
+ }
69
+
70
+ return result;
71
+ }
72
+
73
+ function requireFlatValue(
74
+ values: Record<string, string>,
75
+ key: string,
76
+ label: string,
77
+ ): string {
78
+ const value = values[key];
79
+ if (!value) {
80
+ throw new Error(`Missing ${key} in ${label}`);
81
+ }
82
+ return value;
83
+ }
@@ -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,7 +41,9 @@ const handles = [
32
41
  diffOutput
33
42
  .split(/\r?\n/)
34
43
  .map((path) => {
35
- const match = path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml)$/);
44
+ const match =
45
+ path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
46
+ path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
36
47
  return match?.[1] ?? null;
37
48
  })
38
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})`);
@@ -0,0 +1,179 @@
1
+ import { cp, mkdtemp, mkdir, readFile, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { readJsonResponse, requireEnv } from "./helpers";
7
+
8
+ const handle = requireEnv("HANDLE");
9
+ const contentRepo = requireEnv("CONTENT_REPO");
10
+ const token = requireEnv("GIT_SYNC_TOKEN");
11
+ const sourceDir = join("users", handle, "content");
12
+
13
+ if (!existsSync(sourceDir)) {
14
+ process.exit(0);
15
+ }
16
+
17
+ const { owner, repo } = parseRepoSlug(contentRepo);
18
+ await ensureGitHubRepo({ owner, repo, token });
19
+
20
+ const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
21
+ const checkoutDir = join(tempRoot, "repo");
22
+ const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
23
+
24
+ runGit(["clone", remoteUrl, checkoutDir]);
25
+ runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
26
+
27
+ const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
28
+ if (copiedFiles === 0) {
29
+ process.exit(0);
30
+ }
31
+
32
+ runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
33
+ runGit([
34
+ "-C",
35
+ checkoutDir,
36
+ "config",
37
+ "user.email",
38
+ "41898282+github-actions[bot]@users.noreply.github.com",
39
+ ]);
40
+ runGit(["-C", checkoutDir, "add", "."]);
41
+
42
+ if (hasNoStagedChanges(checkoutDir)) {
43
+ process.exit(0);
44
+ }
45
+
46
+ runGit([
47
+ "-C",
48
+ checkoutDir,
49
+ "commit",
50
+ "-m",
51
+ `chore(content): seed ${handle} anchor profile`,
52
+ ]);
53
+ runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
54
+
55
+ const STALE_ANCHOR_PROFILE_MARKERS = [
56
+ "name: Your Name Here",
57
+ "Delete this and write your own",
58
+ ];
59
+
60
+ interface EnsureGitHubRepoOptions {
61
+ owner: string;
62
+ repo: string;
63
+ token: string;
64
+ }
65
+
66
+ interface GitHubRepoResponse {
67
+ clone_url?: string;
68
+ private?: boolean;
69
+ }
70
+
71
+ async function ensureGitHubRepo(
72
+ options: EnsureGitHubRepoOptions,
73
+ ): Promise<void> {
74
+ const headers = {
75
+ Authorization: `Bearer ${options.token}`,
76
+ Accept: "application/vnd.github+json",
77
+ "Content-Type": "application/json",
78
+ };
79
+ const repoUrl = `https://api.github.com/repos/${options.owner}/${options.repo}`;
80
+ const repoResponse = await fetch(repoUrl, { headers });
81
+
82
+ if (repoResponse.ok) {
83
+ await readJsonResponse(repoResponse, "GitHub repo lookup");
84
+ return;
85
+ }
86
+
87
+ if (repoResponse.status !== 404) {
88
+ const payload = await readJsonResponse(repoResponse, "GitHub repo lookup");
89
+ throw new Error(`GitHub repo lookup failed: ${JSON.stringify(payload)}`);
90
+ }
91
+
92
+ const createResponse = await fetch(
93
+ `https://api.github.com/orgs/${options.owner}/repos`,
94
+ {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify({
98
+ name: options.repo,
99
+ private: true,
100
+ auto_init: false,
101
+ }),
102
+ },
103
+ );
104
+ const payload = (await readJsonResponse(
105
+ createResponse,
106
+ "GitHub repo create",
107
+ )) as GitHubRepoResponse;
108
+
109
+ if (!createResponse.ok) {
110
+ throw new Error(`GitHub repo create failed: ${JSON.stringify(payload)}`);
111
+ }
112
+ }
113
+
114
+ function parseRepoSlug(contentRepo: string): { owner: string; repo: string } {
115
+ const [owner, repo] = contentRepo.split("/");
116
+ if (!owner || !repo) {
117
+ throw new Error(`Invalid CONTENT_REPO: ${contentRepo}`);
118
+ }
119
+ return { owner, repo };
120
+ }
121
+
122
+ function buildAuthenticatedRemoteUrl(
123
+ owner: string,
124
+ repo: string,
125
+ token: string,
126
+ ): string {
127
+ return `https://x-access-token:${encodeURIComponent(token)}@github.com/${owner}/${repo}.git`;
128
+ }
129
+
130
+ function runGit(args: string[]): void {
131
+ execFileSync("git", args, { stdio: "inherit" });
132
+ }
133
+
134
+ function hasNoStagedChanges(checkoutDir: string): boolean {
135
+ try {
136
+ execFileSync("git", ["-C", checkoutDir, "diff", "--cached", "--quiet"], {
137
+ stdio: "ignore",
138
+ });
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ async function copyMissingFiles(
146
+ sourceDir: string,
147
+ targetDir: string,
148
+ ): Promise<number> {
149
+ const entries = await readdir(sourceDir, { withFileTypes: true });
150
+ let copiedFiles = 0;
151
+
152
+ for (const entry of entries) {
153
+ const sourcePath = join(sourceDir, entry.name);
154
+ const targetPath = join(targetDir, entry.name);
155
+
156
+ if (entry.isDirectory()) {
157
+ await mkdir(targetPath, { recursive: true });
158
+ copiedFiles += await copyMissingFiles(sourcePath, targetPath);
159
+ continue;
160
+ }
161
+
162
+ const existing = await readFile(targetPath, "utf8").catch(() => undefined);
163
+ if (existing !== undefined && !isStaleAnchorProfile(existing)) {
164
+ continue;
165
+ }
166
+
167
+ await mkdir(dirname(targetPath), { recursive: true });
168
+ await cp(sourcePath, targetPath, { force: true });
169
+ copiedFiles += existing === (await readFile(sourcePath, "utf8")) ? 0 : 1;
170
+ }
171
+
172
+ return copiedFiles;
173
+ }
174
+
175
+ function isStaleAnchorProfile(content: string): boolean {
176
+ return STALE_ANCHOR_PROFILE_MARKERS.some((marker) =>
177
+ content.includes(marker),
178
+ );
179
+ }
@@ -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, {