@rizom/ops 0.2.0-alpha.11 → 0.2.0-alpha.13

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 (38) hide show
  1. package/README.md +3 -2
  2. package/dist/age-key-bootstrap.d.ts +17 -0
  3. package/dist/brains-ops.js +172 -149
  4. package/dist/cert-bootstrap.d.ts +2 -2
  5. package/dist/content-repo.d.ts +12 -0
  6. package/dist/default-user-runner.d.ts +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +172 -149
  9. package/dist/load-registry.d.ts +19 -3
  10. package/dist/onboard-user.d.ts +2 -2
  11. package/dist/push-secrets.d.ts +1 -1
  12. package/dist/reconcile-all.d.ts +2 -2
  13. package/dist/reconcile-cohort.d.ts +2 -2
  14. package/dist/reconcile-lib.d.ts +4 -2
  15. package/dist/run-command.d.ts +0 -1
  16. package/dist/run-subprocess.d.ts +1 -0
  17. package/dist/schema.d.ts +97 -0
  18. package/dist/secrets-encrypt.d.ts +32 -0
  19. package/dist/user-runner.d.ts +5 -0
  20. package/package.json +5 -3
  21. package/templates/rover-pilot/.env.schema +5 -0
  22. package/templates/rover-pilot/.github/workflows/deploy.yml +45 -13
  23. package/templates/rover-pilot/README.md +3 -2
  24. package/templates/rover-pilot/deploy/Caddyfile +1 -1
  25. package/templates/rover-pilot/deploy/kamal/deploy.yml +1 -1
  26. package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +83 -0
  27. package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
  28. package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +3 -1
  29. package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +12 -12
  30. package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +179 -0
  31. package/templates/rover-pilot/docs/onboarding-checklist.md +21 -12
  32. package/templates/rover-pilot/docs/operator-playbook.md +43 -5
  33. package/templates/rover-pilot/docs/user-onboarding.md +86 -58
  34. package/templates/rover-pilot/package.json +3 -0
  35. package/templates/rover-pilot/pilot.yaml +3 -0
  36. package/templates/rover-pilot/users/alice.yaml +5 -1
  37. package/dist/secrets-push.d.ts +0 -13
  38. package/dist/user-secret-names.d.ts +0 -6
@@ -13,6 +13,21 @@ export interface ResolvedCohort {
13
13
  brainVersionOverride?: string;
14
14
  presetOverride?: PilotPreset;
15
15
  aiApiKeyOverride?: string;
16
+ gitSyncTokenOverride?: string;
17
+ mcpAuthTokenOverride?: string;
18
+ }
19
+ export interface ResolvedAnchorProfileSocialLink {
20
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
21
+ url: string;
22
+ label?: string;
23
+ }
24
+ export interface ResolvedAnchorProfile {
25
+ name: string;
26
+ description?: string;
27
+ website?: string;
28
+ email?: string;
29
+ story?: string;
30
+ socialLinks?: ResolvedAnchorProfileSocialLink[];
16
31
  }
17
32
  export interface ResolvedUserIdentity {
18
33
  handle: string;
@@ -23,7 +38,11 @@ export interface ResolvedUserIdentity {
23
38
  domain: string;
24
39
  contentRepo: string;
25
40
  discordEnabled: boolean;
41
+ discordAnchorUserId?: string;
26
42
  effectiveAiApiKey: string;
43
+ effectiveGitSyncToken: string;
44
+ effectiveMcpAuthToken: string;
45
+ anchorProfile: ResolvedAnchorProfile;
27
46
  snapshotStatus: SnapshotStatus;
28
47
  }
29
48
  export interface ResolvedUser extends ResolvedUserIdentity {
@@ -40,7 +59,4 @@ export interface PilotRegistry {
40
59
  cohorts: ResolvedCohort[];
41
60
  users: ResolvedUser[];
42
61
  }
43
- declare class PilotRegistryError extends Error {
44
- }
45
62
  export declare function loadPilotRegistry(rootDir: string, options?: LoadPilotRegistryOptions): Promise<PilotRegistry>;
46
- export { PilotRegistryError };
@@ -1,2 +1,2 @@
1
- import { type UserRunner } from "./reconcile-lib";
2
- export declare function onboardUser(rootDir: string, handle: string, runner?: UserRunner): Promise<void>;
1
+ import { type UserRunner, type ContentRepoSyncOptions } from "./reconcile-lib";
2
+ export declare function onboardUser(rootDir: string, handle: string, runner?: UserRunner, contentRepoOptions?: ContentRepoSyncOptions): Promise<void>;
@@ -5,5 +5,5 @@ export interface PushSecretsOptions {
5
5
  runCommand?: RunCommand | undefined;
6
6
  logger?: ((message: string) => void) | undefined;
7
7
  }
8
- export declare function pushSecretsToBackend(target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
8
+ export declare function pushSecretsToBackend(_target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
9
9
  export { normalizePushTarget };
@@ -1,2 +1,2 @@
1
- import { type UserRunner } from "./reconcile-lib";
2
- export declare function reconcileAll(rootDir: string, runner?: UserRunner): Promise<void>;
1
+ import { type UserRunner, type ContentRepoSyncOptions } from "./reconcile-lib";
2
+ export declare function reconcileAll(rootDir: string, runner?: UserRunner, contentRepoOptions?: ContentRepoSyncOptions): Promise<void>;
@@ -1,2 +1,2 @@
1
- import { type UserRunner } from "./reconcile-lib";
2
- export declare function reconcileCohort(rootDir: string, cohortId: string, runner?: UserRunner): Promise<void>;
1
+ import { type UserRunner, type ContentRepoSyncOptions } from "./reconcile-lib";
2
+ export declare function reconcileCohort(rootDir: string, cohortId: string, runner?: UserRunner, contentRepoOptions?: ContentRepoSyncOptions): Promise<void>;
@@ -1,7 +1,9 @@
1
+ import { type ContentRepoSyncOptions } from "./content-repo";
1
2
  import { type PilotRegistry, type ResolvedUser } from "./load-registry";
2
3
  import type { UserRunner } from "./user-runner";
3
- export type { UserRunResult, UserRunner } from "./user-runner";
4
- export declare function runUsers(rootDir: string, registry: PilotRegistry, users: ResolvedUser[], runner?: UserRunner): Promise<void>;
4
+ export type { ContentRepoSyncOptions } from "./content-repo";
5
+ export type { ContentRepoFile, UserRunResult, UserRunner } from "./user-runner";
6
+ export declare function runUsers(rootDir: string, registry: PilotRegistry, users: ResolvedUser[], runner?: UserRunner, contentRepoOptions?: ContentRepoSyncOptions): Promise<void>;
5
7
  export declare function findUser(rootDir: string, handle: string): Promise<{
6
8
  registry: PilotRegistry;
7
9
  user: ResolvedUser;
@@ -15,7 +15,6 @@ export interface CommandDependencies extends LoadPilotRegistryOptions {
15
15
  logger?: ((message: string) => void) | undefined;
16
16
  fetchImpl?: FetchLike | undefined;
17
17
  lookupHost?: LookupHost | undefined;
18
- secretRunCommand?: OpsRunCommand | undefined;
19
18
  bootstrapRunCommand?: OpsRunCommand | undefined;
20
19
  sshKeygen?: SshKeygen | undefined;
21
20
  }
@@ -1,5 +1,6 @@
1
1
  export type RunCommand = (command: string, args: string[], options?: {
2
2
  stdin?: string;
3
3
  env?: NodeJS.ProcessEnv;
4
+ cwd?: string;
4
5
  }) => Promise<void>;
5
6
  export declare const runSubprocess: RunCommand;
package/dist/schema.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const presetSchema: z.ZodEnum<["core", "default", "pro"]>;
3
3
  export declare const exactVersionSchema: z.ZodString;
4
4
  export declare const handleSchema: z.ZodString;
5
5
  export declare const secretNameSchema: z.ZodString;
6
+ export declare const agePublicKeySchema: z.ZodString;
6
7
  export declare const pilotSchema: z.ZodObject<{
7
8
  schemaVersion: z.ZodLiteral<1>;
8
9
  brainVersion: z.ZodString;
@@ -12,7 +13,11 @@ export declare const pilotSchema: z.ZodObject<{
12
13
  domainSuffix: z.ZodString;
13
14
  preset: z.ZodEnum<["core", "default", "pro"]>;
14
15
  aiApiKey: z.ZodString;
16
+ gitSyncToken: z.ZodString;
17
+ mcpAuthToken: z.ZodString;
18
+ agePublicKey: z.ZodString;
15
19
  }, "strict", z.ZodTypeAny, {
20
+ agePublicKey: string;
16
21
  schemaVersion: 1;
17
22
  brainVersion: string;
18
23
  model: "rover";
@@ -21,7 +26,10 @@ export declare const pilotSchema: z.ZodObject<{
21
26
  domainSuffix: string;
22
27
  preset: "default" | "core" | "pro";
23
28
  aiApiKey: string;
29
+ gitSyncToken: string;
30
+ mcpAuthToken: string;
24
31
  }, {
32
+ agePublicKey: string;
25
33
  schemaVersion: 1;
26
34
  brainVersion: string;
27
35
  model: "rover";
@@ -30,53 +38,142 @@ export declare const pilotSchema: z.ZodObject<{
30
38
  domainSuffix: string;
31
39
  preset: "default" | "core" | "pro";
32
40
  aiApiKey: string;
41
+ gitSyncToken: string;
42
+ mcpAuthToken: string;
33
43
  }>;
34
44
  export declare const userSchema: z.ZodObject<{
35
45
  handle: z.ZodString;
36
46
  discord: z.ZodObject<{
37
47
  enabled: z.ZodBoolean;
48
+ anchorUserId: z.ZodOptional<z.ZodString>;
38
49
  }, "strict", z.ZodTypeAny, {
39
50
  enabled: boolean;
51
+ anchorUserId?: string | undefined;
40
52
  }, {
41
53
  enabled: boolean;
54
+ anchorUserId?: string | undefined;
42
55
  }>;
43
56
  aiApiKeyOverride: z.ZodOptional<z.ZodString>;
57
+ gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
58
+ mcpAuthTokenOverride: z.ZodOptional<z.ZodString>;
59
+ anchorProfile: z.ZodOptional<z.ZodObject<{
60
+ name: z.ZodOptional<z.ZodString>;
61
+ description: z.ZodOptional<z.ZodString>;
62
+ website: z.ZodOptional<z.ZodString>;
63
+ email: z.ZodOptional<z.ZodString>;
64
+ story: z.ZodOptional<z.ZodString>;
65
+ socialLinks: z.ZodOptional<z.ZodArray<z.ZodObject<{
66
+ platform: z.ZodEnum<["github", "instagram", "linkedin", "email", "website"]>;
67
+ url: z.ZodString;
68
+ label: z.ZodOptional<z.ZodString>;
69
+ }, "strict", z.ZodTypeAny, {
70
+ url: string;
71
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
72
+ label?: string | undefined;
73
+ }, {
74
+ url: string;
75
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
76
+ label?: string | undefined;
77
+ }>, "many">>;
78
+ }, "strict", z.ZodTypeAny, {
79
+ name?: string | undefined;
80
+ email?: string | undefined;
81
+ website?: string | undefined;
82
+ description?: string | undefined;
83
+ story?: string | undefined;
84
+ socialLinks?: {
85
+ url: string;
86
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
87
+ label?: string | undefined;
88
+ }[] | undefined;
89
+ }, {
90
+ name?: string | undefined;
91
+ email?: string | undefined;
92
+ website?: string | undefined;
93
+ description?: string | undefined;
94
+ story?: string | undefined;
95
+ socialLinks?: {
96
+ url: string;
97
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
98
+ label?: string | undefined;
99
+ }[] | undefined;
100
+ }>>;
44
101
  }, "strict", z.ZodTypeAny, {
45
102
  handle: string;
46
103
  discord: {
47
104
  enabled: boolean;
105
+ anchorUserId?: string | undefined;
48
106
  };
49
107
  aiApiKeyOverride?: string | undefined;
108
+ gitSyncTokenOverride?: string | undefined;
109
+ mcpAuthTokenOverride?: string | undefined;
110
+ anchorProfile?: {
111
+ name?: string | undefined;
112
+ email?: string | undefined;
113
+ website?: string | undefined;
114
+ description?: string | undefined;
115
+ story?: string | undefined;
116
+ socialLinks?: {
117
+ url: string;
118
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
119
+ label?: string | undefined;
120
+ }[] | undefined;
121
+ } | undefined;
50
122
  }, {
51
123
  handle: string;
52
124
  discord: {
53
125
  enabled: boolean;
126
+ anchorUserId?: string | undefined;
54
127
  };
55
128
  aiApiKeyOverride?: string | undefined;
129
+ gitSyncTokenOverride?: string | undefined;
130
+ mcpAuthTokenOverride?: string | undefined;
131
+ anchorProfile?: {
132
+ name?: string | undefined;
133
+ email?: string | undefined;
134
+ website?: string | undefined;
135
+ description?: string | undefined;
136
+ story?: string | undefined;
137
+ socialLinks?: {
138
+ url: string;
139
+ platform: "github" | "instagram" | "linkedin" | "email" | "website";
140
+ label?: string | undefined;
141
+ }[] | undefined;
142
+ } | undefined;
56
143
  }>;
57
144
  export declare const cohortSchema: z.ZodEffects<z.ZodObject<{
58
145
  members: z.ZodArray<z.ZodString, "many">;
59
146
  brainVersionOverride: z.ZodOptional<z.ZodString>;
60
147
  presetOverride: z.ZodOptional<z.ZodEnum<["core", "default", "pro"]>>;
61
148
  aiApiKeyOverride: z.ZodOptional<z.ZodString>;
149
+ gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
150
+ mcpAuthTokenOverride: z.ZodOptional<z.ZodString>;
62
151
  }, "strict", z.ZodTypeAny, {
63
152
  members: string[];
64
153
  aiApiKeyOverride?: string | undefined;
154
+ gitSyncTokenOverride?: string | undefined;
155
+ mcpAuthTokenOverride?: string | undefined;
65
156
  brainVersionOverride?: string | undefined;
66
157
  presetOverride?: "default" | "core" | "pro" | undefined;
67
158
  }, {
68
159
  members: string[];
69
160
  aiApiKeyOverride?: string | undefined;
161
+ gitSyncTokenOverride?: string | undefined;
162
+ mcpAuthTokenOverride?: string | undefined;
70
163
  brainVersionOverride?: string | undefined;
71
164
  presetOverride?: "default" | "core" | "pro" | undefined;
72
165
  }>, {
73
166
  members: string[];
74
167
  aiApiKeyOverride?: string | undefined;
168
+ gitSyncTokenOverride?: string | undefined;
169
+ mcpAuthTokenOverride?: string | undefined;
75
170
  brainVersionOverride?: string | undefined;
76
171
  presetOverride?: "default" | "core" | "pro" | undefined;
77
172
  }, {
78
173
  members: string[];
79
174
  aiApiKeyOverride?: string | undefined;
175
+ gitSyncTokenOverride?: string | undefined;
176
+ mcpAuthTokenOverride?: string | undefined;
80
177
  brainVersionOverride?: string | undefined;
81
178
  presetOverride?: "default" | "core" | "pro" | undefined;
82
179
  }>;
@@ -0,0 +1,32 @@
1
+ import { z } from "@brains/utils";
2
+ declare const encryptedUserSecretsSchema: z.ZodObject<{
3
+ gitSyncToken: z.ZodOptional<z.ZodString>;
4
+ mcpAuthToken: z.ZodOptional<z.ZodString>;
5
+ discordBotToken: z.ZodOptional<z.ZodString>;
6
+ aiApiKey: z.ZodOptional<z.ZodString>;
7
+ }, "strict", z.ZodTypeAny, {
8
+ aiApiKey?: string | undefined;
9
+ gitSyncToken?: string | undefined;
10
+ mcpAuthToken?: string | undefined;
11
+ discordBotToken?: string | undefined;
12
+ }, {
13
+ aiApiKey?: string | undefined;
14
+ gitSyncToken?: string | undefined;
15
+ mcpAuthToken?: string | undefined;
16
+ discordBotToken?: string | undefined;
17
+ }>;
18
+ export type EncryptedUserSecrets = z.infer<typeof encryptedUserSecretsSchema>;
19
+ export interface SecretsEncryptOptions {
20
+ env?: NodeJS.ProcessEnv | undefined;
21
+ logger?: ((message: string) => void) | undefined;
22
+ dryRun?: boolean | undefined;
23
+ }
24
+ export interface SecretsEncryptResult {
25
+ encryptedPath: string;
26
+ plaintextPath: string;
27
+ deletedPlaintext: boolean;
28
+ encryptedKeys: Array<keyof EncryptedUserSecrets>;
29
+ dryRun?: boolean | undefined;
30
+ }
31
+ export declare function encryptPilotSecrets(rootDir: string, handle: string, options?: SecretsEncryptOptions): Promise<SecretsEncryptResult>;
32
+ export {};
@@ -1,6 +1,11 @@
1
1
  import type { ResolvedUser } from "./load-registry";
2
+ export interface ContentRepoFile {
3
+ path: string;
4
+ content: string;
5
+ }
2
6
  export interface UserRunResult {
3
7
  brainYaml?: string;
4
8
  envFile?: string;
9
+ contentRepoFiles?: ContentRepoFile[];
5
10
  }
6
11
  export type UserRunner = (user: ResolvedUser) => Promise<UserRunResult | void>;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.0-alpha.11",
7
+ "version": "0.2.0-alpha.13",
8
8
  "type": "module",
9
9
  "exports": {
10
10
  ".": {
@@ -32,9 +32,11 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "lint": "eslint . --ext .ts",
34
34
  "lint:fix": "eslint . --ext .ts --fix",
35
- "test": "bun test"
35
+ "test": "bun test --timeout 20000"
36
+ },
37
+ "dependencies": {
38
+ "age-encryption": "^0.3.0"
36
39
  },
37
- "dependencies": {},
38
40
  "devDependencies": {
39
41
  "@brains/eslint-config": "workspace:*",
40
42
  "@brains/typescript-config": "workspace:*",
@@ -4,18 +4,23 @@
4
4
  # ----------
5
5
 
6
6
  # AI provider
7
+ # Shared GitHub secret by default; a per-user override may come from the decrypted
8
+ # users/<handle>.secrets.yaml.age file at deploy time.
7
9
  # @required @sensitive
8
10
  AI_API_KEY=
9
11
 
10
12
  # Git sync
13
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file.
11
14
  # @required @sensitive
12
15
  GIT_SYNC_TOKEN=
13
16
 
14
17
  # MCP interface
18
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file.
15
19
  # @required @sensitive
16
20
  MCP_AUTH_TOKEN=
17
21
 
18
22
  # Discord (optional, per-user)
23
+ # Comes from the decrypted users/<handle>.secrets.yaml.age file when enabled.
19
24
  # @sensitive
20
25
  DISCORD_BOT_TOKEN=
21
26
 
@@ -12,6 +12,8 @@ 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
17
19
 
@@ -73,8 +75,18 @@ jobs:
73
75
  - name: Install operator tooling
74
76
  run: bun install
75
77
 
78
+ - name: Decrypt user secrets
79
+ id: user_secrets
80
+ env:
81
+ AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
82
+ run: bun deploy/scripts/decrypt-user-secrets.ts "$HANDLE"
83
+
76
84
  - name: Reconcile selected user config
77
- run: bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
85
+ env:
86
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
87
+ run: |
88
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
89
+ bunx brains-ops onboard "$GITHUB_WORKSPACE" "$HANDLE"
78
90
 
79
91
  - name: Resolve generated user config
80
92
  id: user_config
@@ -82,10 +94,9 @@ jobs:
82
94
 
83
95
  - name: Validate selected secrets
84
96
  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] || '' }}
97
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
98
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
99
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
89
100
  HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
90
101
  HCLOUD_SSH_KEY_NAME: ${{ secrets.HCLOUD_SSH_KEY_NAME }}
91
102
  HCLOUD_SERVER_TYPE: ${{ secrets.HCLOUD_SERVER_TYPE }}
@@ -96,7 +107,19 @@ jobs:
96
107
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
97
108
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
98
109
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
99
- run: bun deploy/scripts/validate-secrets.ts
110
+ run: |
111
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
112
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
113
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
114
+ bun deploy/scripts/validate-secrets.ts
115
+
116
+ - name: Seed content repo
117
+ env:
118
+ CONTENT_REPO: ${{ steps.user_config.outputs.content_repo }}
119
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
120
+ run: |
121
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
122
+ bun deploy/scripts/sync-content-repo.ts
100
123
 
101
124
  - name: Log in to GHCR
102
125
  uses: docker/login-action@v3
@@ -143,14 +166,17 @@ jobs:
143
166
 
144
167
  - name: Write .kamal/secrets
145
168
  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] || '' }}
169
+ SHARED_AI_API_KEY: ${{ secrets[steps.user_secrets.outputs.shared_ai_api_key_secret_name] }}
170
+ SHARED_GIT_SYNC_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_git_sync_token_secret_name] }}
171
+ SHARED_MCP_AUTH_TOKEN: ${{ secrets[steps.user_secrets.outputs.shared_mcp_auth_token_secret_name] }}
150
172
  KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
151
173
  CERTIFICATE_PEM: ${{ secrets.CERTIFICATE_PEM }}
152
174
  PRIVATE_KEY_PEM: ${{ secrets.PRIVATE_KEY_PEM }}
153
- run: bun deploy/scripts/write-kamal-secrets.ts
175
+ run: |
176
+ export AI_API_KEY="${AI_API_KEY:-$SHARED_AI_API_KEY}"
177
+ export GIT_SYNC_TOKEN="${GIT_SYNC_TOKEN:-$SHARED_GIT_SYNC_TOKEN}"
178
+ export MCP_AUTH_TOKEN="${MCP_AUTH_TOKEN:-$SHARED_MCP_AUTH_TOKEN}"
179
+ bun deploy/scripts/write-kamal-secrets.ts
154
180
 
155
181
  - name: Provision server
156
182
  id: provision
@@ -167,10 +193,11 @@ jobs:
167
193
  CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
168
194
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
169
195
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
196
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
170
197
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
171
198
  run: |
172
199
  bun deploy/scripts/update-dns.ts
173
- BRAIN_DOMAIN="preview.$BRAIN_DOMAIN" bun deploy/scripts/update-dns.ts
200
+ BRAIN_DOMAIN="$PREVIEW_DOMAIN" bun deploy/scripts/update-dns.ts
174
201
 
175
202
  - name: Validate SSH key
176
203
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -197,6 +224,7 @@ jobs:
197
224
  IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
198
225
  REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
199
226
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
227
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
200
228
  BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
201
229
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
202
230
 
@@ -204,7 +232,10 @@ jobs:
204
232
  env:
205
233
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
206
234
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
207
- run: curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
235
+ PREVIEW_DOMAIN: ${{ steps.user_config.outputs.preview_domain }}
236
+ run: |
237
+ curl -I -k --max-time 20 --resolve "$BRAIN_DOMAIN:443:$SERVER_IP" "https://$BRAIN_DOMAIN/health"
238
+ curl -I -k --max-time 20 --resolve "$PREVIEW_DOMAIN:443:$SERVER_IP" "https://$PREVIEW_DOMAIN/"
208
239
 
209
240
  - name: Upload generated config
210
241
  uses: actions/upload-artifact@v4
@@ -213,6 +244,7 @@ jobs:
213
244
  path: |
214
245
  users/${{ matrix.handle }}/brain.yaml
215
246
  users/${{ matrix.handle }}/.env
247
+ users/${{ matrix.handle }}/content
216
248
 
217
249
  - name: Dump remote proxy diagnostics
218
250
  if: failure()
@@ -38,8 +38,9 @@ When a push changes only deploy contract files, CI prints `No affected user conf
38
38
  - `brains-ops init <repo>`
39
39
  - `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
40
40
  - `brains-ops onboard <repo> <handle>`
41
+ - `brains-ops age-key:bootstrap <repo>`
41
42
  - `brains-ops ssh-key:bootstrap <repo>`
42
- - `brains-ops cert:bootstrap <repo> <handle>`
43
- - `brains-ops secrets:push <repo> <handle>`
43
+ - `brains-ops cert:bootstrap <repo>`
44
+ - `brains-ops secrets:encrypt <repo> <handle>`
44
45
  - `brains-ops reconcile-cohort <repo> <cohort>`
45
46
  - `brains-ops reconcile-all <repo>`
@@ -1,7 +1,7 @@
1
1
  # Internal Caddy — path-based routing to brain services.
2
2
  # kamal-proxy terminates TLS externally; this runs inside the container.
3
3
  :80 {
4
- @preview host preview.*
4
+ @preview host *-preview.*
5
5
  handle @preview {
6
6
  reverse_proxy localhost:4321
7
7
 
@@ -12,7 +12,7 @@ proxy:
12
12
  private_key_pem: PRIVATE_KEY_PEM
13
13
  hosts:
14
14
  - <%= ENV['BRAIN_DOMAIN'] %>
15
- - preview.<%= ENV['BRAIN_DOMAIN'] %>
15
+ - <%= ENV['PREVIEW_DOMAIN'] %>
16
16
  app_port: 80
17
17
  healthcheck:
18
18
  path: /health
@@ -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
  }
@@ -32,7 +32,9 @@ const handles = [
32
32
  diffOutput
33
33
  .split(/\r?\n/)
34
34
  .map((path) => {
35
- const match = path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml)$/);
35
+ const match =
36
+ path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
37
+ path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
36
38
  return match?.[1] ?? null;
37
39
  })
38
40
  .filter((handle): handle is string => handle !== null)