@rizom/ops 0.2.0-alpha.0 → 0.2.0-alpha.10

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 (32) hide show
  1. package/README.md +4 -1
  2. package/dist/brains-ops.js +114 -108
  3. package/dist/cert-bootstrap.d.ts +22 -0
  4. package/dist/deploy.d.ts +28 -0
  5. package/dist/deploy.js +48 -48
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.js +114 -108
  8. package/dist/observed-status.d.ts +12 -0
  9. package/dist/origin-ca.d.ts +1 -0
  10. package/dist/parse-args.d.ts +2 -0
  11. package/dist/push-secrets.d.ts +9 -0
  12. package/dist/push-target.d.ts +2 -0
  13. package/dist/run-command.d.ts +11 -0
  14. package/dist/run-subprocess.d.ts +5 -0
  15. package/dist/schema.d.ts +6 -6
  16. package/dist/secrets-push.d.ts +13 -0
  17. package/dist/ssh-key-bootstrap.d.ts +26 -0
  18. package/package.json +2 -2
  19. package/templates/rover-pilot/.github/workflows/build.yml +1 -0
  20. package/templates/rover-pilot/.github/workflows/deploy.yml +9 -2
  21. package/templates/rover-pilot/README.md +4 -1
  22. package/templates/rover-pilot/deploy/Caddyfile +67 -0
  23. package/templates/rover-pilot/deploy/Dockerfile +30 -7
  24. package/templates/rover-pilot/deploy/kamal/deploy.yml +3 -2
  25. package/templates/rover-pilot/deploy/scripts/provision-server.ts +109 -0
  26. package/templates/rover-pilot/deploy/scripts/update-dns.ts +55 -0
  27. package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +19 -0
  28. package/templates/rover-pilot/deploy/scripts/write-kamal-secrets.ts +18 -0
  29. package/templates/rover-pilot/deploy/scripts/write-ssh-key.ts +17 -0
  30. package/templates/rover-pilot/docs/onboarding-checklist.md +10 -3
  31. package/templates/rover-pilot/docs/operator-playbook.md +21 -0
  32. package/templates/rover-pilot/docs/user-onboarding.md +356 -0
@@ -0,0 +1,12 @@
1
+ import type { FetchLike } from "@brains/utils/origin-ca";
2
+ import type { ObservedUserStatus, ResolvedUserIdentity } from "./load-registry";
3
+ export interface LookupResult {
4
+ address: string;
5
+ family: number;
6
+ }
7
+ export type LookupHost = (hostname: string) => Promise<LookupResult>;
8
+ export interface CreateObservedStatusResolverOptions {
9
+ fetchImpl?: FetchLike;
10
+ lookupHost?: LookupHost;
11
+ }
12
+ export declare function createObservedStatusResolver(options?: CreateObservedStatusResolverOptions): (user: ResolvedUserIdentity) => Promise<ObservedUserStatus>;
@@ -0,0 +1 @@
1
+ export { createOriginCertificateRequest, generateOriginKeyPair, issueCloudflareOriginCertificate, setCloudflareZoneSslStrict, type CloudflareOriginCaResult, type FetchLike, type OriginCertificateRequest, type OriginKeyPair, } from "@brains/utils/origin-ca";
@@ -4,6 +4,8 @@ export interface ParsedArgs {
4
4
  flags: {
5
5
  help?: boolean | undefined;
6
6
  version?: boolean | undefined;
7
+ dryRun?: boolean | undefined;
8
+ pushTo?: string | undefined;
7
9
  };
8
10
  }
9
11
  export declare function parseArgs(argv: string[]): ParsedArgs;
@@ -0,0 +1,9 @@
1
+ import { normalizePushTarget, type PushTarget } from "./push-target";
2
+ import { type RunCommand } from "./run-subprocess";
3
+ export type SecretPair = readonly [name: string, value: string];
4
+ export interface PushSecretsOptions {
5
+ runCommand?: RunCommand | undefined;
6
+ logger?: ((message: string) => void) | undefined;
7
+ }
8
+ export declare function pushSecretsToBackend(target: PushTarget, secrets: readonly SecretPair[], options?: PushSecretsOptions): Promise<void>;
9
+ export { normalizePushTarget };
@@ -0,0 +1,2 @@
1
+ export type PushTarget = "gh";
2
+ export declare function normalizePushTarget(value?: string): PushTarget | undefined;
@@ -1,5 +1,9 @@
1
+ import type { FetchLike } from "@brains/utils/origin-ca";
1
2
  import type { LoadPilotRegistryOptions } from "./load-registry";
3
+ import { type LookupHost } from "./observed-status";
2
4
  import type { ParsedArgs } from "./parse-args";
5
+ import { type RunCommand as OpsRunCommand } from "./run-subprocess";
6
+ import { type SshKeygen } from "./ssh-key-bootstrap";
3
7
  import type { UserRunner } from "./user-runner";
4
8
  export interface CommandResult {
5
9
  success: boolean;
@@ -7,5 +11,12 @@ export interface CommandResult {
7
11
  }
8
12
  export interface CommandDependencies extends LoadPilotRegistryOptions {
9
13
  runner?: UserRunner;
14
+ env?: NodeJS.ProcessEnv | undefined;
15
+ logger?: ((message: string) => void) | undefined;
16
+ fetchImpl?: FetchLike | undefined;
17
+ lookupHost?: LookupHost | undefined;
18
+ secretRunCommand?: OpsRunCommand | undefined;
19
+ bootstrapRunCommand?: OpsRunCommand | undefined;
20
+ sshKeygen?: SshKeygen | undefined;
10
21
  }
11
22
  export declare function runCommand(parsed: ParsedArgs, dependencies?: CommandDependencies): Promise<CommandResult>;
@@ -0,0 +1,5 @@
1
+ export type RunCommand = (command: string, args: string[], options?: {
2
+ stdin?: string;
3
+ env?: NodeJS.ProcessEnv;
4
+ }) => Promise<void>;
5
+ export declare const runSubprocess: RunCommand;
package/dist/schema.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare const pilotSchema: z.ZodObject<{
19
19
  githubOrg: string;
20
20
  contentRepoPrefix: string;
21
21
  domainSuffix: string;
22
- preset: "core" | "default" | "pro";
22
+ preset: "default" | "core" | "pro";
23
23
  aiApiKey: string;
24
24
  }, {
25
25
  schemaVersion: 1;
@@ -28,7 +28,7 @@ export declare const pilotSchema: z.ZodObject<{
28
28
  githubOrg: string;
29
29
  contentRepoPrefix: string;
30
30
  domainSuffix: string;
31
- preset: "core" | "default" | "pro";
31
+ preset: "default" | "core" | "pro";
32
32
  aiApiKey: string;
33
33
  }>;
34
34
  export declare const userSchema: z.ZodObject<{
@@ -63,22 +63,22 @@ export declare const cohortSchema: z.ZodEffects<z.ZodObject<{
63
63
  members: string[];
64
64
  aiApiKeyOverride?: string | undefined;
65
65
  brainVersionOverride?: string | undefined;
66
- presetOverride?: "core" | "default" | "pro" | undefined;
66
+ presetOverride?: "default" | "core" | "pro" | undefined;
67
67
  }, {
68
68
  members: string[];
69
69
  aiApiKeyOverride?: string | undefined;
70
70
  brainVersionOverride?: string | undefined;
71
- presetOverride?: "core" | "default" | "pro" | undefined;
71
+ presetOverride?: "default" | "core" | "pro" | undefined;
72
72
  }>, {
73
73
  members: string[];
74
74
  aiApiKeyOverride?: string | undefined;
75
75
  brainVersionOverride?: string | undefined;
76
- presetOverride?: "core" | "default" | "pro" | undefined;
76
+ presetOverride?: "default" | "core" | "pro" | undefined;
77
77
  }, {
78
78
  members: string[];
79
79
  aiApiKeyOverride?: string | undefined;
80
80
  brainVersionOverride?: string | undefined;
81
- presetOverride?: "core" | "default" | "pro" | undefined;
81
+ presetOverride?: "default" | "core" | "pro" | undefined;
82
82
  }>;
83
83
  export type PilotConfig = z.infer<typeof pilotSchema>;
84
84
  export type UserConfig = z.infer<typeof userSchema>;
@@ -0,0 +1,13 @@
1
+ import { type RunCommand } from "./run-subprocess";
2
+ export interface SecretsPushOptions {
3
+ env?: NodeJS.ProcessEnv | undefined;
4
+ logger?: ((message: string) => void) | undefined;
5
+ dryRun?: boolean | undefined;
6
+ runCommand?: RunCommand | undefined;
7
+ }
8
+ export interface SecretsPushResult {
9
+ pushedKeys: string[];
10
+ skippedKeys: string[];
11
+ dryRun?: boolean | undefined;
12
+ }
13
+ export declare function pushPilotSecrets(rootDir: string, handle: string, options?: SecretsPushOptions): Promise<SecretsPushResult>;
@@ -0,0 +1,26 @@
1
+ import { type FetchLike } from "@brains/utils/origin-ca";
2
+ import { type RunCommand } from "./run-subprocess";
3
+ export interface SshKeyBootstrapOptions {
4
+ env?: NodeJS.ProcessEnv | undefined;
5
+ fetchImpl?: FetchLike | undefined;
6
+ logger?: (message: string) => void;
7
+ pushTo?: string | undefined;
8
+ runCommand?: RunCommand | undefined;
9
+ sshKeygen?: SshKeygen | undefined;
10
+ }
11
+ export interface SshKeyBootstrapResult {
12
+ createdHetznerKey: boolean;
13
+ createdLocalKey: boolean;
14
+ privateKeyPath: string;
15
+ publicKeyPath: string;
16
+ sshKeyName: string;
17
+ }
18
+ export interface SshKeygen {
19
+ createEd25519KeyPair: (privateKeyPath: string, comment: string) => void;
20
+ derivePublicKey: (privateKeyPath: string) => string;
21
+ }
22
+ export declare function runPilotSshKeyBootstrap(rootDir: string, options?: SshKeyBootstrapOptions): Promise<{
23
+ success: boolean;
24
+ message?: string;
25
+ }>;
26
+ export declare function bootstrapPilotSshKey(rootDir: string, options?: SshKeyBootstrapOptions): Promise<SshKeyBootstrapResult>;
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.0-alpha.0",
7
+ "version": "0.2.0-alpha.10",
8
8
  "type": "module",
9
9
  "exports": {
10
10
  ".": {
@@ -12,7 +12,7 @@
12
12
  "import": "./dist/index.js"
13
13
  },
14
14
  "./deploy": {
15
- "bun": "./src/entries/deploy.ts",
15
+ "bun": "./dist/deploy.js",
16
16
  "types": "./dist/deploy.d.ts",
17
17
  "import": "./dist/deploy.js"
18
18
  }
@@ -54,6 +54,7 @@ jobs:
54
54
  with:
55
55
  context: .
56
56
  file: deploy/Dockerfile
57
+ target: fleet
57
58
  push: true
58
59
  build-args: |
59
60
  BRAIN_VERSION=${{ env.BRAIN_VERSION }}
@@ -28,6 +28,7 @@ jobs:
28
28
  - uses: actions/checkout@v5
29
29
  with:
30
30
  ref: ${{ github.sha }}
31
+ fetch-depth: 0
31
32
 
32
33
  - uses: oven-sh/setup-bun@v2
33
34
 
@@ -167,7 +168,9 @@ jobs:
167
168
  CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
168
169
  BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
169
170
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
170
- run: bun deploy/scripts/update-dns.ts
171
+ run: |
172
+ bun deploy/scripts/update-dns.ts
173
+ BRAIN_DOMAIN="preview.$BRAIN_DOMAIN" bun deploy/scripts/update-dns.ts
171
174
 
172
175
  - name: Validate SSH key
173
176
  run: ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
@@ -191,6 +194,10 @@ jobs:
191
194
  env:
192
195
  SERVER_IP: ${{ steps.provision.outputs.server_ip }}
193
196
  VERSION: brain-${{ steps.user_config.outputs.brain_version }}
197
+ IMAGE_REPOSITORY: ${{ steps.user_config.outputs.image_repository }}
198
+ REGISTRY_USERNAME: ${{ steps.user_config.outputs.registry_username }}
199
+ BRAIN_DOMAIN: ${{ steps.user_config.outputs.brain_domain }}
200
+ BRAIN_YAML_PATH: ${{ steps.user_config.outputs.brain_yaml_path }}
194
201
  run: kamal setup --skip-push -c deploy/kamal/deploy.yml
195
202
 
196
203
  - name: Verify origin TLS
@@ -258,4 +265,4 @@ jobs:
258
265
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
259
266
  git add users views
260
267
  git commit -m "chore(ops): reconcile generated config"
261
- git push
268
+ git push origin HEAD:${{ github.ref_name }}
@@ -36,7 +36,10 @@ When a push changes only deploy contract files, CI prints `No affected user conf
36
36
  ## Commands
37
37
 
38
38
  - `brains-ops init <repo>`
39
- - `brains-ops render <repo>`
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 ssh-key:bootstrap <repo>`
42
+ - `brains-ops cert:bootstrap <repo> <handle>`
43
+ - `brains-ops secrets:push <repo> <handle>`
41
44
  - `brains-ops reconcile-cohort <repo> <cohort>`
42
45
  - `brains-ops reconcile-all <repo>`
@@ -0,0 +1,67 @@
1
+ # Internal Caddy — path-based routing to brain services.
2
+ # kamal-proxy terminates TLS externally; this runs inside the container.
3
+ :80 {
4
+ @preview host preview.*
5
+ handle @preview {
6
+ reverse_proxy localhost:4321
7
+
8
+ header {
9
+ X-Frame-Options "SAMEORIGIN"
10
+ X-Content-Type-Options "nosniff"
11
+ Referrer-Policy "strict-origin-when-cross-origin"
12
+ }
13
+ }
14
+
15
+ # Health endpoint
16
+ handle /health {
17
+ reverse_proxy localhost:3333
18
+ }
19
+
20
+ # MCP endpoint
21
+ handle /mcp* {
22
+ reverse_proxy localhost:3333
23
+
24
+ header {
25
+ X-Content-Type-Options "nosniff"
26
+ Access-Control-Allow-Origin "*"
27
+ Access-Control-Allow-Methods "GET, POST, DELETE, OPTIONS"
28
+ Access-Control-Allow-Headers "Content-Type, Authorization, MCP-Session-Id"
29
+ }
30
+ }
31
+
32
+ # A2A endpoints
33
+ handle /.well-known/agent-card.json {
34
+ reverse_proxy localhost:3334
35
+ }
36
+
37
+ handle /a2a {
38
+ reverse_proxy localhost:3334
39
+
40
+ header {
41
+ X-Content-Type-Options "nosniff"
42
+ Access-Control-Allow-Origin "*"
43
+ Access-Control-Allow-Methods "GET, POST, OPTIONS"
44
+ Access-Control-Allow-Headers "Content-Type, Authorization"
45
+ }
46
+ }
47
+
48
+ # Plugin API routes
49
+ handle /api/* {
50
+ reverse_proxy localhost:3335
51
+ }
52
+
53
+ # Production site: prefer the webserver when present; otherwise fall back
54
+ # to the A2A interface so core-only deployments never return a bare 502.
55
+ handle {
56
+ reverse_proxy localhost:8080 localhost:3334 {
57
+ lb_policy first
58
+ lb_retries 1
59
+ }
60
+
61
+ header {
62
+ X-Frame-Options "SAMEORIGIN"
63
+ X-Content-Type-Options "nosniff"
64
+ Referrer-Policy "strict-origin-when-cross-origin"
65
+ }
66
+ }
67
+ }
@@ -1,15 +1,38 @@
1
1
  ARG BUN_VERSION=1.3.10
2
- FROM oven/bun:${BUN_VERSION}-slim
2
+ FROM oven/bun:${BUN_VERSION}-slim AS runtime
3
3
 
4
- ARG BRAIN_VERSION
5
4
  WORKDIR /app
6
5
 
7
- RUN test -n "$BRAIN_VERSION" \
8
- && printf '{"name":"rover-pilot-runtime","private":true}\n' > package.json \
9
- && bun add @rizom/brain@$BRAIN_VERSION
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ curl ca-certificates git gnupg debian-keyring debian-archive-keyring apt-transport-https \
8
+ && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg \
9
+ && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list \
10
+ && apt-get update && apt-get install -y --no-install-recommends caddy libcap2-bin \
11
+ && setcap cap_net_bind_service=+ep $(which caddy) \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ COPY deploy/Caddyfile /etc/caddy/Caddyfile
15
+
16
+ RUN mkdir -p /srv/fallback && \
17
+ printf '<!doctype html><html><head><meta charset="utf-8"><title>brain</title></head><body></body></html>\n' \
18
+ > /srv/fallback/index.html
10
19
 
11
20
  ENV XDG_DATA_HOME=/data
12
21
  ENV XDG_CONFIG_HOME=/config
13
- RUN mkdir -p /app/brain-data /data /config && chmod -R 777 /app/brain-data /data /config
22
+ RUN mkdir -p /app/data /app/cache /app/brain-data && \
23
+ chmod -R 777 /app/data /app/cache /app/brain-data
14
24
 
15
- CMD ["sh", "-c", "exec ./node_modules/.bin/brain start"]
25
+ CMD ["sh", "-c", "caddy start --config /etc/caddy/Caddyfile && exec ./node_modules/.bin/brain start"]
26
+
27
+ # --- standalone: bake full project into image (brain-cli deploy) ---
28
+ FROM runtime AS standalone
29
+ COPY package.json ./package.json
30
+ RUN bun install --production --ignore-scripts
31
+ COPY . .
32
+
33
+ # --- fleet: install published brain at pinned version (ops deploy) ---
34
+ FROM runtime AS fleet
35
+ ARG BRAIN_VERSION
36
+ RUN test -n "$BRAIN_VERSION" \
37
+ && printf '{"name":"rover-pilot-runtime","private":true}\n' > package.json \
38
+ && bun add @rizom/brain@$BRAIN_VERSION
@@ -2,7 +2,7 @@ service: rover
2
2
  image: <%= ENV['IMAGE_REPOSITORY'] %>
3
3
 
4
4
  servers:
5
- mcp:
5
+ web:
6
6
  hosts:
7
7
  - <%= ENV['SERVER_IP'] %>
8
8
 
@@ -12,7 +12,8 @@ proxy:
12
12
  private_key_pem: PRIVATE_KEY_PEM
13
13
  hosts:
14
14
  - <%= ENV['BRAIN_DOMAIN'] %>
15
- app_port: 3333
15
+ - preview.<%= ENV['BRAIN_DOMAIN'] %>
16
+ app_port: 80
16
17
  healthcheck:
17
18
  path: /health
18
19
 
@@ -0,0 +1,109 @@
1
+ import {
2
+ readJsonResponse,
3
+ requireEnv,
4
+ writeGitHubOutput,
5
+ writeGitHubEnv,
6
+ } from "./helpers";
7
+
8
+ const token = requireEnv("HCLOUD_TOKEN");
9
+ const instanceName = requireEnv("INSTANCE_NAME");
10
+ const sshKeyName = requireEnv("HCLOUD_SSH_KEY_NAME");
11
+ const serverType = requireEnv("HCLOUD_SERVER_TYPE");
12
+ const location = requireEnv("HCLOUD_LOCATION");
13
+
14
+ const headers: Record<string, string> = {
15
+ Authorization: `Bearer ${token}`,
16
+ "Content-Type": "application/json",
17
+ };
18
+ const baseUrl = "https://api.hetzner.cloud/v1";
19
+ const labelSelector = `brain=${instanceName}`;
20
+ const MAX_POLLS = 30;
21
+ const POLL_INTERVAL_MS = 10_000;
22
+
23
+ interface HetznerServer {
24
+ id: number;
25
+ status: string;
26
+ public_net?: { ipv4?: { ip?: string } };
27
+ }
28
+
29
+ function sleep(ms: number): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+
33
+ async function listServers(): Promise<HetznerServer[]> {
34
+ const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
35
+ const response = await fetch(url, { headers });
36
+ const payload = (await readJsonResponse(
37
+ response,
38
+ "Hetzner server lookup",
39
+ )) as {
40
+ servers?: HetznerServer[];
41
+ };
42
+ if (!response.ok || !payload.servers) {
43
+ throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
44
+ }
45
+ return payload.servers;
46
+ }
47
+
48
+ async function createServer(): Promise<HetznerServer> {
49
+ const response = await fetch(`${baseUrl}/servers`, {
50
+ method: "POST",
51
+ headers,
52
+ body: JSON.stringify({
53
+ name: instanceName,
54
+ server_type: serverType,
55
+ image: "ubuntu-22.04",
56
+ location,
57
+ ssh_keys: [sshKeyName],
58
+ labels: { brain: instanceName },
59
+ }),
60
+ });
61
+ const payload = (await readJsonResponse(
62
+ response,
63
+ "Hetzner server create",
64
+ )) as {
65
+ server?: HetznerServer;
66
+ };
67
+ if (!response.ok || !payload.server) {
68
+ throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
69
+ }
70
+ return payload.server;
71
+ }
72
+
73
+ async function getServer(id: number): Promise<HetznerServer> {
74
+ const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
75
+ const payload = (await readJsonResponse(response, "Hetzner server poll")) as {
76
+ server?: HetznerServer;
77
+ };
78
+ if (!response.ok || !payload.server) {
79
+ throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
80
+ }
81
+ return payload.server;
82
+ }
83
+
84
+ let server: HetznerServer | undefined = (await listServers())[0];
85
+ server ??= await createServer();
86
+
87
+ let polls = 0;
88
+ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
89
+ if (++polls > MAX_POLLS) {
90
+ throw new Error(
91
+ `Server ${server.id} did not become ready after ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s (status: ${server.status})`,
92
+ );
93
+ }
94
+ if (server.status === "error") {
95
+ throw new Error(`Server ${server.id} entered error state`);
96
+ }
97
+ console.log(
98
+ `Waiting for server ${server.id} (status: ${server.status}, poll ${polls}/${MAX_POLLS})...`,
99
+ );
100
+ await sleep(POLL_INTERVAL_MS);
101
+ server = await getServer(server.id);
102
+ }
103
+
104
+ const serverIp = server.public_net?.ipv4?.ip;
105
+ if (!serverIp) {
106
+ throw new Error(`Server ${server.id} running but has no IPv4 address`);
107
+ }
108
+ writeGitHubOutput("server_ip", serverIp);
109
+ writeGitHubEnv("SERVER_IP", serverIp);
@@ -0,0 +1,55 @@
1
+ import { readJsonResponse, requireEnv } from "./helpers";
2
+
3
+ const token = requireEnv("CF_API_TOKEN");
4
+ const zoneId = requireEnv("CF_ZONE_ID");
5
+ const domain = requireEnv("BRAIN_DOMAIN");
6
+ const serverIp = requireEnv("SERVER_IP");
7
+
8
+ const headers: Record<string, string> = {
9
+ Authorization: `Bearer ${token}`,
10
+ "Content-Type": "application/json",
11
+ };
12
+ const baseUrl = "https://api.cloudflare.com/client/v4";
13
+
14
+ interface CloudflareResult {
15
+ success: boolean;
16
+ result?: Array<{ id: string }>;
17
+ }
18
+
19
+ async function upsertRecord(name: string): Promise<void> {
20
+ const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=A&name=${encodeURIComponent(name)}`;
21
+ const lookup = await fetch(lookupUrl, { headers });
22
+ const payload = (await readJsonResponse(
23
+ lookup,
24
+ "Cloudflare DNS lookup",
25
+ )) as CloudflareResult;
26
+ if (!lookup.ok || !payload.success) {
27
+ throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
28
+ }
29
+
30
+ const existing = payload.result?.[0];
31
+ const url = existing
32
+ ? `${baseUrl}/zones/${zoneId}/dns_records/${existing.id}`
33
+ : `${baseUrl}/zones/${zoneId}/dns_records`;
34
+
35
+ const response = await fetch(url, {
36
+ method: existing ? "PUT" : "POST",
37
+ headers,
38
+ body: JSON.stringify({
39
+ type: "A",
40
+ name,
41
+ content: serverIp,
42
+ ttl: 1,
43
+ proxied: true,
44
+ }),
45
+ });
46
+ const result = (await readJsonResponse(
47
+ response,
48
+ "Cloudflare DNS upsert",
49
+ )) as CloudflareResult;
50
+ if (!response.ok || !result.success) {
51
+ throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
52
+ }
53
+ }
54
+
55
+ await upsertRecord(domain);
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseEnvSchema } from "./helpers";
3
+
4
+ const envSchemaPath = ".env.schema";
5
+ const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
6
+ const requiredKeys = schema
7
+ .filter((entry) => entry.required)
8
+ .map((entry) => entry.key);
9
+
10
+ const missing: string[] = [];
11
+ for (const key of requiredKeys) {
12
+ if (!process.env[key]) {
13
+ missing.push(key);
14
+ }
15
+ }
16
+
17
+ if (missing.length > 0) {
18
+ throw new Error(`Missing required secrets: ${missing.join(", ")}`);
19
+ }
@@ -0,0 +1,18 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { parseEnvSchema } from "./helpers";
3
+
4
+ const envSchemaPath = ".env.schema";
5
+ const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
6
+ const sensitiveKeys = schema
7
+ .filter((entry) => entry.sensitive)
8
+ .map((entry) => entry.key);
9
+
10
+ const lines: string[] = [];
11
+ for (const name of sensitiveKeys) {
12
+ const value = process.env[name] ?? "";
13
+ const escaped = String(value).replace(/'/g, "'\\''");
14
+ lines.push(`${name}='${escaped}'`);
15
+ }
16
+
17
+ mkdirSync(".kamal", { recursive: true });
18
+ writeFileSync(".kamal/secrets", lines.join("\n") + "\n");
@@ -0,0 +1,17 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { requireEnv } from "./helpers";
4
+
5
+ const privateKey = requireEnv("KAMAL_SSH_PRIVATE_KEY");
6
+
7
+ let normalized = privateKey.replace(/\r\n/g, "\n").replace(/\\n/g, "\n");
8
+ if (!normalized.endsWith("\n")) {
9
+ normalized += "\n";
10
+ }
11
+
12
+ const sshDir = join(process.env["HOME"] ?? "/root", ".ssh");
13
+ mkdirSync(sshDir, { recursive: true });
14
+ writeFileSync(join(sshDir, "id_ed25519"), normalized, {
15
+ encoding: "utf8",
16
+ mode: 0o600,
17
+ });
@@ -5,6 +5,13 @@
5
5
  3. Add or edit `users/<handle>.yaml`.
6
6
  4. Add the user to a cohort in `cohorts/*.yaml`.
7
7
  5. Run `bunx brains-ops render <repo>`.
8
- 6. Run `bunx brains-ops onboard <repo> <handle>`.
9
- 7. 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.
10
- 8. Hand the MCP connection details to the user.
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.
@@ -33,6 +33,17 @@ When a push changes only deploy contract files and no generated `users/<handle>/
33
33
 
34
34
  They are scaffolded from `@rizom/ops`, then versioned in this repo like any other deploy contract.
35
35
 
36
+ ## Bootstrap flow
37
+
38
+ For a new pilot user, the operator bootstrap order is:
39
+
40
+ 1. `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`
41
+ 2. `bunx brains-ops cert:bootstrap <repo> <handle> --push-to gh`
42
+ 3. `bunx brains-ops secrets:push <repo> <handle>`
43
+ 4. `bunx brains-ops onboard <repo> <handle>`
44
+
45
+ `brains-ops cert:bootstrap` writes local cert artifacts under `.brains-ops/`, which stays repo-local and ignored by git.
46
+
36
47
  ## Upgrading operator behavior
37
48
 
38
49
  When `@rizom/ops` changes the scaffolded deploy contract:
@@ -42,6 +53,16 @@ When `@rizom/ops` changes the scaffolded deploy contract:
42
53
  3. review the resulting changes to `.env.schema`, `deploy/scripts/`, and workflows in git
43
54
  4. commit the updated deploy artifacts together
44
55
 
56
+ ## Rover-core verification notes
57
+
58
+ Rover core is MCP-only. Do not expect the bare domain to serve a website.
59
+
60
+ Use these checks after deploy:
61
+
62
+ - `https://<handle>.rizom.ai/health` should return `200`
63
+ - unauthenticated `POST https://<handle>.rizom.ai/mcp` should return `401 Unauthorized: Bearer token required`
64
+ - a bare `GET /` may also return `401`; that is expected for rover core and does not indicate a bad deploy
65
+
45
66
  ## Recovery notes
46
67
 
47
68
  Document known failure modes, recovery steps, and operator notes here.