baa-conductor

git clone 

commit
e4f3d9c
parent
458d7cf
author
im_wower
date
2026-03-22 01:26:45 +0800 CST
Merge branch 'integration/fourth-wave-20260322'
47 files changed,  +3786, -284
M apps/conductor-daemon/src/index.test.js
+131, -2
  1@@ -5,6 +5,7 @@ import {
  2   ConductorDaemon,
  3   ConductorRuntime,
  4   createFetchControlApiClient,
  5+  handleConductorHttpRequest,
  6   parseConductorCliRequest
  7 } from "./index.ts";
  8 
  9@@ -278,10 +279,137 @@ test("parseConductorCliRequest merges launchd env defaults with CLI overrides",
 10   assert.equal(request.config.role, "standby");
 11   assert.equal(request.config.nodeId, "mini-main");
 12   assert.equal(request.config.controlApiBase, "https://control.example.test");
 13-  assert.equal(request.config.localApiBase, "http://127.0.0.1:4317/");
 14+  assert.equal(request.config.localApiBase, "http://127.0.0.1:4317");
 15   assert.equal(request.config.paths.runsDir, "/tmp/runs");
 16 });
 17 
 18+test("handleConductorHttpRequest keeps degraded runtimes observable but not ready", () => {
 19+  const snapshot = {
 20+    daemon: {
 21+      nodeId: "mini-main",
 22+      host: "mini",
 23+      role: "primary",
 24+      leaseState: "degraded",
 25+      schedulerEnabled: false,
 26+      currentLeaderId: null,
 27+      currentTerm: null,
 28+      leaseExpiresAt: null,
 29+      lastHeartbeatAt: 100,
 30+      lastLeaseOperation: "acquire",
 31+      nextLeaseOperation: "acquire",
 32+      consecutiveRenewFailures: 2,
 33+      lastError: "lease endpoint timeout"
 34+    },
 35+    identity: "mini-main@mini(primary)",
 36+    loops: {
 37+      heartbeat: false,
 38+      lease: false
 39+    },
 40+    paths: {
 41+      logsDir: null,
 42+      runsDir: null,
 43+      stateDir: null,
 44+      tmpDir: null,
 45+      worktreesDir: null
 46+    },
 47+    controlApi: {
 48+      baseUrl: "https://control.example.test",
 49+      localApiBase: "http://127.0.0.1:4317",
 50+      hasSharedToken: true,
 51+      usesPlaceholderToken: false
 52+    },
 53+    runtime: {
 54+      pid: 123,
 55+      started: true,
 56+      startedAt: 100
 57+    },
 58+    startupChecklist: [],
 59+    warnings: []
 60+  };
 61+
 62+  const readyResponse = handleConductorHttpRequest(
 63+    {
 64+      method: "GET",
 65+      path: "/readyz"
 66+    },
 67+    () => snapshot
 68+  );
 69+  assert.equal(readyResponse.status, 503);
 70+  assert.equal(readyResponse.body, "not_ready\n");
 71+
 72+  const roleResponse = handleConductorHttpRequest(
 73+    {
 74+      method: "GET",
 75+      path: "/rolez"
 76+    },
 77+    () => snapshot
 78+  );
 79+  assert.equal(roleResponse.status, 200);
 80+  assert.equal(roleResponse.body, "standby\n");
 81+});
 82+
 83+test("ConductorRuntime serves health and runtime probes over the local HTTP endpoint", async () => {
 84+  const runtime = new ConductorRuntime(
 85+    {
 86+      nodeId: "mini-main",
 87+      host: "mini",
 88+      role: "primary",
 89+      controlApiBase: "https://control.example.test",
 90+      localApiBase: "http://127.0.0.1:0",
 91+      sharedToken: "replace-me",
 92+      paths: {
 93+        runsDir: "/tmp/runs"
 94+      }
 95+    },
 96+    {
 97+      autoStartLoops: false,
 98+      client: {
 99+        async acquireLeaderLease() {
100+          return createLeaseResult({
101+            holderId: "mini-main",
102+            term: 2,
103+            leaseExpiresAt: 130,
104+            renewedAt: 100,
105+            isLeader: true,
106+            operation: "acquire"
107+          });
108+        },
109+        async sendControllerHeartbeat() {}
110+      },
111+      now: () => 100
112+    }
113+  );
114+
115+  const snapshot = await runtime.start();
116+  assert.equal(snapshot.daemon.schedulerEnabled, true);
117+  assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
118+
119+  const baseUrl = snapshot.controlApi.localApiBase;
120+
121+  const healthResponse = await fetch(`${baseUrl}/healthz`);
122+  assert.equal(healthResponse.status, 200);
123+  assert.equal(await healthResponse.text(), "ok\n");
124+
125+  const readyResponse = await fetch(`${baseUrl}/readyz`);
126+  assert.equal(readyResponse.status, 200);
127+  assert.equal(await readyResponse.text(), "ready\n");
128+
129+  const roleResponse = await fetch(`${baseUrl}/rolez`);
130+  assert.equal(roleResponse.status, 200);
131+  assert.equal(await roleResponse.text(), "leader\n");
132+
133+  const runtimeResponse = await fetch(`${baseUrl}/v1/runtime`);
134+  assert.equal(runtimeResponse.status, 200);
135+  const payload = await runtimeResponse.json();
136+  assert.equal(payload.ok, true);
137+  assert.equal(payload.data.identity, "mini-main@mini(primary)");
138+  assert.equal(payload.data.controlApi.localApiBase, baseUrl);
139+  assert.equal(payload.data.runtime.started, true);
140+
141+  const stoppedSnapshot = await runtime.stop();
142+  assert.equal(stoppedSnapshot.runtime.started, false);
143+});
144+
145 test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status surfaces", async () => {
146   const runtime = new ConductorRuntime(
147     {
148@@ -319,11 +447,12 @@ test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status sur
149   const startedSnapshot = await runtime.start();
150   assert.equal(startedSnapshot.runtime.started, true);
151   assert.equal(startedSnapshot.daemon.leaseState, "leader");
152+  assert.equal(startedSnapshot.daemon.schedulerEnabled, true);
153   assert.equal(startedSnapshot.loops.heartbeat, false);
154   assert.equal(startedSnapshot.loops.lease, false);
155   assert.equal(startedSnapshot.controlApi.usesPlaceholderToken, true);
156   assert.match(startedSnapshot.warnings.join("\n"), /replace-me/);
157 
158-  const stoppedSnapshot = runtime.stop();
159+  const stoppedSnapshot = await runtime.stop();
160   assert.equal(stoppedSnapshot.runtime.started, false);
161 });
M apps/conductor-daemon/src/index.ts
+328, -10
  1@@ -1,4 +1,13 @@
  2+import {
  3+  createServer,
  4+  type IncomingMessage,
  5+  type Server,
  6+  type ServerResponse
  7+} from "node:http";
  8+import type { AddressInfo } from "node:net";
  9+
 10 export type ConductorRole = "primary" | "standby";
 11+export type ConductorLeadershipRole = "leader" | "standby";
 12 export type LeaseState = "leader" | "standby" | "degraded";
 13 export type SchedulerDecision = "scheduled" | "skipped_not_leader";
 14 export type TimerHandle = ReturnType<typeof globalThis.setInterval>;
 15@@ -9,6 +18,14 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
 16 const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
 17 const DEFAULT_LEASE_TTL_SEC = 30;
 18 const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
 19+const JSON_RESPONSE_HEADERS = {
 20+  "cache-control": "no-store",
 21+  "content-type": "application/json; charset=utf-8"
 22+} as const;
 23+const TEXT_RESPONSE_HEADERS = {
 24+  "cache-control": "no-store",
 25+  "content-type": "text/plain; charset=utf-8"
 26+} as const;
 27 
 28 const STARTUP_CHECKLIST: StartupChecklistItem[] = [
 29   { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
 30@@ -147,6 +164,17 @@ export interface ConductorRuntimeSnapshot {
 31   warnings: string[];
 32 }
 33 
 34+export interface ConductorHttpRequest {
 35+  method: string;
 36+  path: string;
 37+}
 38+
 39+export interface ConductorHttpResponse {
 40+  status: number;
 41+  headers: Record<string, string>;
 42+  body: string;
 43+}
 44+
 45 export interface SchedulerContext {
 46   controllerId: string;
 47   host: string;
 48@@ -258,6 +286,11 @@ interface JsonRequestOptions {
 49   headers?: Record<string, string>;
 50 }
 51 
 52+interface LocalApiListenConfig {
 53+  host: string;
 54+  port: number;
 55+}
 56+
 57 interface CliValueOverrides {
 58   controlApiBase?: string;
 59   heartbeatIntervalMs?: string;
 60@@ -301,6 +334,273 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
 61   return normalized === "" ? null : normalized;
 62 }
 63 
 64+function normalizePath(value: string): string {
 65+  const baseUrl = "http://conductor.local";
 66+  const url = new URL(value || "/", baseUrl);
 67+  const normalized = url.pathname.replace(/\/+$/u, "");
 68+
 69+  return normalized === "" ? "/" : normalized;
 70+}
 71+
 72+function normalizeLoopbackHost(hostname: string): string {
 73+  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
 74+}
 75+
 76+function isLoopbackHost(hostname: string): boolean {
 77+  const normalized = normalizeLoopbackHost(hostname);
 78+  return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
 79+}
 80+
 81+function readHttpPort(url: URL): number {
 82+  if (url.port === "") {
 83+    return 80;
 84+  }
 85+
 86+  const port = Number(url.port);
 87+
 88+  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
 89+    throw new Error("Conductor localApiBase must use a valid TCP port.");
 90+  }
 91+
 92+  return port;
 93+}
 94+
 95+function formatLocalApiBaseUrl(hostname: string, port: number): string {
 96+  const normalizedHost = normalizeLoopbackHost(hostname);
 97+  const formattedHost = normalizedHost.includes(":") ? `[${normalizedHost}]` : normalizedHost;
 98+
 99+  return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
100+}
101+
102+function resolveLocalApiBase(value: string | null | undefined): string | null {
103+  const normalized = normalizeOptionalString(value);
104+
105+  if (normalized == null) {
106+    return null;
107+  }
108+
109+  let url: URL;
110+
111+  try {
112+    url = new URL(normalized);
113+  } catch {
114+    throw new Error("Conductor localApiBase must be a valid absolute http:// URL.");
115+  }
116+
117+  if (url.protocol !== "http:") {
118+    throw new Error("Conductor localApiBase must use the http:// scheme.");
119+  }
120+
121+  if (!isLoopbackHost(url.hostname)) {
122+    throw new Error("Conductor localApiBase must use a loopback host.");
123+  }
124+
125+  if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
126+    throw new Error("Conductor localApiBase must not include a path, query, or hash.");
127+  }
128+
129+  if (url.username !== "" || url.password !== "") {
130+    throw new Error("Conductor localApiBase must not include credentials.");
131+  }
132+
133+  return formatLocalApiBaseUrl(url.hostname, readHttpPort(url));
134+}
135+
136+function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig {
137+  const url = new URL(localApiBase);
138+
139+  return {
140+    host: normalizeLoopbackHost(url.hostname),
141+    port: readHttpPort(url)
142+  };
143+}
144+
145+function jsonResponse(
146+  status: number,
147+  payload: unknown,
148+  extraHeaders: Record<string, string> = {}
149+): ConductorHttpResponse {
150+  return {
151+    status,
152+    headers: {
153+      ...JSON_RESPONSE_HEADERS,
154+      ...extraHeaders
155+    },
156+    body: `${JSON.stringify(payload, null, 2)}\n`
157+  };
158+}
159+
160+function textResponse(
161+  status: number,
162+  body: string,
163+  extraHeaders: Record<string, string> = {}
164+): ConductorHttpResponse {
165+  return {
166+    status,
167+    headers: {
168+      ...TEXT_RESPONSE_HEADERS,
169+      ...extraHeaders
170+    },
171+    body: `${body}\n`
172+  };
173+}
174+
175+function resolveLeadershipRole(snapshot: ConductorRuntimeSnapshot): ConductorLeadershipRole {
176+  return snapshot.daemon.leaseState === "leader" ? "leader" : "standby";
177+}
178+
179+function isRuntimeReady(snapshot: ConductorRuntimeSnapshot): boolean {
180+  return snapshot.runtime.started && snapshot.daemon.leaseState !== "degraded";
181+}
182+
183+export function handleConductorHttpRequest(
184+  request: ConductorHttpRequest,
185+  snapshotLoader: () => ConductorRuntimeSnapshot
186+): ConductorHttpResponse {
187+  const method = request.method.toUpperCase();
188+
189+  if (method !== "GET") {
190+    return jsonResponse(
191+      405,
192+      {
193+        ok: false,
194+        error: "method_not_allowed",
195+        message: "Conductor local API is read-only and only accepts GET requests."
196+      },
197+      { Allow: "GET" }
198+    );
199+  }
200+
201+  const path = normalizePath(request.path);
202+
203+  switch (path) {
204+    case "/healthz":
205+      return textResponse(200, "ok");
206+
207+    case "/readyz": {
208+      const snapshot = snapshotLoader();
209+      const ready = isRuntimeReady(snapshot);
210+      return textResponse(ready ? 200 : 503, ready ? "ready" : "not_ready");
211+    }
212+
213+    case "/rolez":
214+      return textResponse(200, resolveLeadershipRole(snapshotLoader()));
215+
216+    case "/v1/runtime":
217+      return jsonResponse(200, {
218+        ok: true,
219+        data: snapshotLoader()
220+      });
221+
222+    default:
223+      return jsonResponse(404, {
224+        ok: false,
225+        error: "not_found",
226+        message: `No conductor route matches "${path}".`
227+      });
228+  }
229+}
230+
231+function writeHttpResponse(
232+  response: ServerResponse<IncomingMessage>,
233+  payload: ConductorHttpResponse
234+): void {
235+  response.statusCode = payload.status;
236+
237+  for (const [name, value] of Object.entries(payload.headers)) {
238+    response.setHeader(name, value);
239+  }
240+
241+  response.end(payload.body);
242+}
243+
244+class ConductorLocalHttpServer {
245+  private readonly localApiBase: string;
246+  private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
247+  private resolvedBaseUrl: string;
248+  private server: Server | null = null;
249+
250+  constructor(localApiBase: string, snapshotLoader: () => ConductorRuntimeSnapshot) {
251+    this.localApiBase = localApiBase;
252+    this.snapshotLoader = snapshotLoader;
253+    this.resolvedBaseUrl = localApiBase;
254+  }
255+
256+  getBaseUrl(): string {
257+    return this.resolvedBaseUrl;
258+  }
259+
260+  async start(): Promise<string> {
261+    if (this.server != null) {
262+      return this.resolvedBaseUrl;
263+    }
264+
265+    const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
266+    const server = createServer((request, response) => {
267+      writeHttpResponse(
268+        response,
269+        handleConductorHttpRequest(
270+          {
271+            method: request.method ?? "GET",
272+            path: request.url ?? "/"
273+          },
274+          this.snapshotLoader
275+        )
276+      );
277+    });
278+
279+    await new Promise<void>((resolve, reject) => {
280+      const onError = (error: Error) => {
281+        server.off("listening", onListening);
282+        reject(error);
283+      };
284+      const onListening = () => {
285+        server.off("error", onError);
286+        resolve();
287+      };
288+
289+      server.once("error", onError);
290+      server.once("listening", onListening);
291+      server.listen({
292+        host: listenConfig.host,
293+        port: listenConfig.port
294+      });
295+    });
296+
297+    const address = server.address();
298+
299+    if (address == null || typeof address === "string") {
300+      server.close();
301+      throw new Error("Conductor local API started without a TCP listen address.");
302+    }
303+
304+    this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
305+    this.server = server;
306+    return this.resolvedBaseUrl;
307+  }
308+
309+  async stop(): Promise<void> {
310+    if (this.server == null) {
311+      return;
312+    }
313+
314+    const server = this.server;
315+    this.server = null;
316+
317+    await new Promise<void>((resolve, reject) => {
318+      server.close((error) => {
319+        if (error) {
320+          reject(error);
321+          return;
322+        }
323+
324+        resolve();
325+      });
326+      server.closeAllConnections?.();
327+    });
328+  }
329+}
330+
331 function toErrorMessage(error: unknown): string {
332   if (error instanceof Error) {
333     return error.message;
334@@ -987,7 +1287,7 @@ export function resolveConductorRuntimeConfig(
335     heartbeatIntervalMs,
336     leaseRenewIntervalMs,
337     leaseTtlSec,
338-    localApiBase: normalizeOptionalString(config.localApiBase),
339+    localApiBase: resolveLocalApiBase(config.localApiBase),
340     paths: resolvePathConfig(config.paths),
341     preferred: config.preferred ?? config.role === "primary",
342     priority,
343@@ -1348,11 +1648,13 @@ function getUsageText(): string {
344 export class ConductorRuntime {
345   private readonly config: ResolvedConductorRuntimeConfig;
346   private readonly daemon: ConductorDaemon;
347+  private readonly localApiServer: ConductorLocalHttpServer | null;
348+  private readonly now: () => number;
349   private started = false;
350 
351   constructor(config: ConductorRuntimeConfig, options: ConductorRuntimeOptions = {}) {
352-    const now = options.now ?? defaultNowUnixSeconds;
353-    const startedAt = config.startedAt ?? now();
354+    this.now = options.now ?? defaultNowUnixSeconds;
355+    const startedAt = config.startedAt ?? this.now();
356 
357     this.config = resolveConductorRuntimeConfig({
358       ...config,
359@@ -1361,26 +1663,42 @@ export class ConductorRuntime {
360     this.daemon = new ConductorDaemon(this.config, {
361       ...options,
362       controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
363-      now
364+      now: this.now
365     });
366+    this.localApiServer =
367+      this.config.localApiBase == null
368+        ? null
369+        : new ConductorLocalHttpServer(this.config.localApiBase, () => this.getRuntimeSnapshot());
370   }
371 
372   async start(): Promise<ConductorRuntimeSnapshot> {
373     if (!this.started) {
374       await this.daemon.start();
375       this.started = true;
376+
377+      try {
378+        await this.localApiServer?.start();
379+      } catch (error) {
380+        this.started = false;
381+        this.daemon.stop();
382+        throw error;
383+      }
384     }
385 
386     return this.getRuntimeSnapshot();
387   }
388 
389-  stop(): ConductorRuntimeSnapshot {
390-    this.daemon.stop();
391+  async stop(): Promise<ConductorRuntimeSnapshot> {
392     this.started = false;
393+    this.daemon.stop();
394+    await this.localApiServer?.stop();
395+
396     return this.getRuntimeSnapshot();
397   }
398 
399-  getRuntimeSnapshot(now: number = defaultNowUnixSeconds()): ConductorRuntimeSnapshot {
400+  getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
401+    const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
402+
403     return {
404       daemon: this.daemon.getStatusSnapshot(now),
405       identity: this.daemon.describeIdentity(),
406@@ -1388,7 +1706,7 @@ export class ConductorRuntime {
407       paths: { ...this.config.paths },
408       controlApi: {
409         baseUrl: this.config.controlApiBase,
410-        localApiBase: this.config.localApiBase,
411+        localApiBase,
412         hasSharedToken: this.config.sharedToken != null,
413         usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
414       },
415@@ -1494,12 +1812,12 @@ export async function runConductorCli(options: RunConductorCliOptions = {}): Pro
416   }
417 
418   if (request.runOnce) {
419-    runtime.stop();
420+    await runtime.stop();
421     return 0;
422   }
423 
424   const signal = await waitForShutdownSignal(processLike);
425-  runtime.stop();
426+  await runtime.stop();
427 
428   if (!request.printJson) {
429     writeLine(stdout, `conductor stopped${signal ? ` after ${signal}` : ""}`);
A apps/conductor-daemon/src/node-shims.d.ts
+38, -0
 1@@ -0,0 +1,38 @@
 2+declare module "node:net" {
 3+  export interface AddressInfo {
 4+    address: string;
 5+    family: string;
 6+    port: number;
 7+  }
 8+}
 9+
10+declare module "node:http" {
11+  import type { AddressInfo } from "node:net";
12+
13+  export interface IncomingMessage {
14+    method?: string;
15+    url?: string;
16+  }
17+
18+  export interface ServerResponse<Request extends IncomingMessage = IncomingMessage> {
19+    end(chunk?: string): void;
20+    setHeader(name: string, value: string): this;
21+    statusCode: number;
22+  }
23+
24+  export interface Server {
25+    address(): AddressInfo | string | null;
26+    close(callback?: (error?: Error) => void): this;
27+    closeAllConnections?(): void;
28+    listen(options: { host: string; port: number }): this;
29+    off(event: "error", listener: (error: Error) => void): this;
30+    off(event: "listening", listener: () => void): this;
31+    off(event: string, listener: (...args: unknown[]) => void): this;
32+    once(event: "error", listener: (error: Error) => void): this;
33+    once(event: "listening", listener: () => void): this;
34+  }
35+
36+  export function createServer(
37+    handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void
38+  ): Server;
39+}
M apps/conductor-daemon/tsconfig.json
+1, -2
1@@ -4,6 +4,5 @@
2     "rootDir": "src",
3     "outDir": "dist"
4   },
5-  "include": ["src/**/*.ts"]
6+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
7 }
8-
A apps/control-api-worker/.dev.vars.example
+9, -0
 1@@ -0,0 +1,9 @@
 2+# Copy to .dev.vars or .dev.vars.production for local wrangler dev.
 3+# Keep the deployed Worker secrets in Cloudflare; do not commit real values.
 4+
 5+BAA_SHARED_TOKEN=replace-me
 6+CONTROL_API_BROWSER_ADMIN_TOKEN=replace-me
 7+CONTROL_API_CONTROLLER_TOKEN=replace-me
 8+CONTROL_API_OPS_ADMIN_TOKEN=replace-me
 9+CONTROL_API_READONLY_TOKEN=replace-me
10+CONTROL_API_WORKER_TOKEN=replace-me
A apps/control-api-worker/.gitignore
+3, -0
1@@ -0,0 +1,3 @@
2+.dev.vars*
3+!.dev.vars.example
4+.wrangler/
M apps/control-api-worker/package.json
+4, -1
 1@@ -5,6 +5,9 @@
 2   "main": "dist/index.js",
 3   "scripts": {
 4     "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/control-api-worker/dist BAA_DIST_ENTRY=apps/control-api-worker/src/index.js BAA_IMPORT_ALIASES='@baa-conductor/auth=../../../packages/auth/src/index.js;@baa-conductor/db=../../../packages/db/src/index.js' BAA_FIX_RELATIVE_EXTENSIONS=true BAA_EXPORT_DEFAULT=true pnpm -C ../.. run build:runtime-postprocess",
 5-    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
 6+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
 7+    "deploy:cloudflare": "bash ../../ops/cloudflare/deploy-control-api-worker.sh",
 8+    "migrate:d1:remote": "bash ../../ops/cloudflare/apply-control-api-d1-migrations.sh",
 9+    "tail:cloudflare": "npx --yes wrangler@4 tail --config wrangler.jsonc"
10   }
11 }
M apps/control-api-worker/src/contracts.ts
+33, -9
 1@@ -6,6 +6,30 @@ import type {
 2 } from "@baa-conductor/auth";
 3 import type { ControlPlaneRepository, D1DatabaseLike, JsonValue } from "@baa-conductor/db";
 4 
 5+export const CONTROL_API_WORKER_NAME = "baa-conductor-control-api" as const;
 6+export const CONTROL_API_WORKER_ENTRY = "dist/index.js" as const;
 7+export const CONTROL_API_CUSTOM_DOMAIN = "control-api.makefile.so" as const;
 8+export const CONTROL_API_D1_BINDING_NAME = "CONTROL_DB" as const;
 9+export const CONTROL_API_D1_DATABASE_NAME = "baa-conductor-control-prod" as const;
10+export const CONTROL_API_VERSION_ENV_NAME = "CONTROL_API_VERSION" as const;
11+export const CONTROL_API_AUTH_REQUIRED_ENV_NAME = "CONTROL_API_AUTH_REQUIRED" as const;
12+export const CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME =
13+  "CONTROL_API_BROWSER_ADMIN_TOKEN" as const;
14+export const CONTROL_API_CONTROLLER_TOKEN_ENV_NAME = "CONTROL_API_CONTROLLER_TOKEN" as const;
15+export const CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME = "CONTROL_API_OPS_ADMIN_TOKEN" as const;
16+export const CONTROL_API_READONLY_TOKEN_ENV_NAME = "CONTROL_API_READONLY_TOKEN" as const;
17+export const CONTROL_API_WORKER_TOKEN_ENV_NAME = "CONTROL_API_WORKER_TOKEN" as const;
18+export const BAA_SHARED_TOKEN_ENV_NAME = "BAA_SHARED_TOKEN" as const;
19+
20+export const CONTROL_API_SECRET_ENV_NAMES = [
21+  BAA_SHARED_TOKEN_ENV_NAME,
22+  CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME,
23+  CONTROL_API_CONTROLLER_TOKEN_ENV_NAME,
24+  CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME,
25+  CONTROL_API_READONLY_TOKEN_ENV_NAME,
26+  CONTROL_API_WORKER_TOKEN_ENV_NAME
27+] as const;
28+
29 export type ControlApiRouteMethod = "GET" | "POST";
30 
31 export type ControlApiRouteId =
32@@ -33,15 +57,15 @@ export interface ControlApiRouteSchemaDescriptor {
33 }
34 
35 export interface ControlApiEnv {
36-  CONTROL_DB?: D1DatabaseLike;
37-  CONTROL_API_VERSION?: string;
38-  CONTROL_API_AUTH_REQUIRED?: string;
39-  CONTROL_API_BROWSER_ADMIN_TOKEN?: string;
40-  CONTROL_API_CONTROLLER_TOKEN?: string;
41-  CONTROL_API_OPS_ADMIN_TOKEN?: string;
42-  CONTROL_API_READONLY_TOKEN?: string;
43-  CONTROL_API_WORKER_TOKEN?: string;
44-  BAA_SHARED_TOKEN?: string;
45+  [CONTROL_API_D1_BINDING_NAME]?: D1DatabaseLike;
46+  [CONTROL_API_VERSION_ENV_NAME]?: string;
47+  [CONTROL_API_AUTH_REQUIRED_ENV_NAME]?: string;
48+  [CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME]?: string;
49+  [CONTROL_API_CONTROLLER_TOKEN_ENV_NAME]?: string;
50+  [CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME]?: string;
51+  [CONTROL_API_READONLY_TOKEN_ENV_NAME]?: string;
52+  [CONTROL_API_WORKER_TOKEN_ENV_NAME]?: string;
53+  [BAA_SHARED_TOKEN_ENV_NAME]?: string;
54 }
55 
56 export interface ControlApiExecutionContext {
M apps/control-api-worker/src/handlers.ts
+3, -4
 1@@ -15,9 +15,8 @@ import type {
 2   ControlApiRouteHandler,
 3   ControlApiRouteMethod
 4 } from "./contracts.js";
 5-import {
 6-  CONTROL_API_ROUTE_SCHEMAS
 7-} from "./schemas.js";
 8+import { CONTROL_API_D1_BINDING_NAME } from "./contracts.js";
 9+import { CONTROL_API_ROUTE_SCHEMAS } from "./schemas.js";
10 
11 const DEFAULT_TASK_PRIORITY = 50;
12 
13@@ -253,7 +252,7 @@ function buildRepositoryNotConfiguredFailure(context: ControlApiRouteContext): C
14     ok: false,
15     status: 503,
16     error: "repository_not_configured",
17-    message: `Route ${context.route.id} requires CONTROL_DB or an injected repository.`,
18+    message: `Route ${context.route.id} requires ${CONTROL_API_D1_BINDING_NAME} or an injected repository.`,
19     details: {
20       route_id: context.route.id
21     }
M apps/control-api-worker/src/runtime.ts
+32, -22
  1@@ -8,13 +8,21 @@ import {
  2   DEFAULT_AUTH_AUDIENCE
  3 } from "@baa-conductor/auth";
  4 import { createD1ControlPlaneRepository } from "@baa-conductor/db";
  5-import type {
  6-  ControlApiEnv,
  7-  ControlApiHandlerFailure,
  8-  ControlApiRequestAuthHook,
  9-  ControlApiRouteAuthorization,
 10-  ControlApiServices,
 11-  ControlApiWorkerOptions
 12+import {
 13+  BAA_SHARED_TOKEN_ENV_NAME,
 14+  CONTROL_API_AUTH_REQUIRED_ENV_NAME,
 15+  CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME,
 16+  CONTROL_API_CONTROLLER_TOKEN_ENV_NAME,
 17+  CONTROL_API_D1_BINDING_NAME,
 18+  CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME,
 19+  CONTROL_API_READONLY_TOKEN_ENV_NAME,
 20+  CONTROL_API_WORKER_TOKEN_ENV_NAME,
 21+  type ControlApiEnv,
 22+  type ControlApiHandlerFailure,
 23+  type ControlApiRequestAuthHook,
 24+  type ControlApiRouteAuthorization,
 25+  type ControlApiServices,
 26+  type ControlApiWorkerOptions
 27 } from "./contracts.js";
 28 
 29 const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
 30@@ -38,12 +46,14 @@ function resolveRepository(
 31     return options.repository;
 32   }
 33 
 34-  if (!env.CONTROL_DB) {
 35+  const database = env[CONTROL_API_D1_BINDING_NAME];
 36+
 37+  if (!database) {
 38     return null;
 39   }
 40 
 41   const repositoryFactory = options.repositoryFactory ?? createD1ControlPlaneRepository;
 42-  return repositoryFactory(env.CONTROL_DB);
 43+  return repositoryFactory(database);
 44 }
 45 
 46 export function createControlApiAuthHook(
 47@@ -51,7 +61,7 @@ export function createControlApiAuthHook(
 48   tokenVerifier?: AuthTokenVerifier
 49 ): ControlApiRequestAuthHook | null {
 50   const hasEnvTokens = hasConfiguredEnvTokens(env);
 51-  const authRequired = parseBooleanEnv(env.CONTROL_API_AUTH_REQUIRED) ?? false;
 52+  const authRequired = parseBooleanEnv(env[CONTROL_API_AUTH_REQUIRED_ENV_NAME]) ?? false;
 53 
 54   if (!tokenVerifier && !hasEnvTokens && !authRequired) {
 55     return null;
 56@@ -144,42 +154,42 @@ function verifyEnvToken(
 57   resource: AuthResourceOwnership | undefined,
 58   env: ControlApiEnv
 59 ): AuthVerificationResult {
 60-  if (token === env.CONTROL_API_BROWSER_ADMIN_TOKEN) {
 61+  if (token === env[CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME]) {
 62     return {
 63       ok: true,
 64       principal: buildStaticPrincipal("browser_admin")
 65     };
 66   }
 67 
 68-  if (token === env.CONTROL_API_READONLY_TOKEN) {
 69+  if (token === env[CONTROL_API_READONLY_TOKEN_ENV_NAME]) {
 70     return {
 71       ok: true,
 72       principal: buildStaticPrincipal("readonly")
 73     };
 74   }
 75 
 76-  if (token === env.CONTROL_API_OPS_ADMIN_TOKEN) {
 77+  if (token === env[CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME]) {
 78     return {
 79       ok: true,
 80       principal: buildStaticPrincipal("ops_admin")
 81     };
 82   }
 83 
 84-  if (token === env.CONTROL_API_CONTROLLER_TOKEN) {
 85+  if (token === env[CONTROL_API_CONTROLLER_TOKEN_ENV_NAME]) {
 86     return {
 87       ok: true,
 88       principal: buildServicePrincipal("controller", resource)
 89     };
 90   }
 91 
 92-  if (token === env.CONTROL_API_WORKER_TOKEN) {
 93+  if (token === env[CONTROL_API_WORKER_TOKEN_ENV_NAME]) {
 94     return {
 95       ok: true,
 96       principal: buildServicePrincipal("worker", resource)
 97     };
 98   }
 99 
100-  if (token === env.BAA_SHARED_TOKEN) {
101+  if (token === env[BAA_SHARED_TOKEN_ENV_NAME]) {
102     const role = resolveServiceRole(action);
103 
104     if (role) {
105@@ -199,12 +209,12 @@ function verifyEnvToken(
106 
107 function hasConfiguredEnvTokens(env: ControlApiEnv): boolean {
108   return [
109-    env.BAA_SHARED_TOKEN,
110-    env.CONTROL_API_BROWSER_ADMIN_TOKEN,
111-    env.CONTROL_API_CONTROLLER_TOKEN,
112-    env.CONTROL_API_OPS_ADMIN_TOKEN,
113-    env.CONTROL_API_READONLY_TOKEN,
114-    env.CONTROL_API_WORKER_TOKEN
115+    env[BAA_SHARED_TOKEN_ENV_NAME],
116+    env[CONTROL_API_BROWSER_ADMIN_TOKEN_ENV_NAME],
117+    env[CONTROL_API_CONTROLLER_TOKEN_ENV_NAME],
118+    env[CONTROL_API_OPS_ADMIN_TOKEN_ENV_NAME],
119+    env[CONTROL_API_READONLY_TOKEN_ENV_NAME],
120+    env[CONTROL_API_WORKER_TOKEN_ENV_NAME]
121   ].some((value) => typeof value === "string" && value.trim().length > 0);
122 }
123 
A apps/control-api-worker/wrangler.jsonc
+29, -0
 1@@ -0,0 +1,29 @@
 2+{
 3+  "name": "baa-conductor-control-api",
 4+  "main": "dist/index.js",
 5+  "compatibility_date": "2026-03-22",
 6+  "workers_dev": false,
 7+  "routes": [
 8+    {
 9+      "pattern": "control-api.makefile.so",
10+      "custom_domain": true
11+    }
12+  ],
13+  "vars": {
14+    "CONTROL_API_VERSION": "2026-03-22",
15+    "CONTROL_API_AUTH_REQUIRED": "true"
16+  },
17+  "d1_databases": [
18+    {
19+      "binding": "CONTROL_DB",
20+      "database_name": "baa-conductor-control-prod",
21+      "database_id": "00000000-0000-0000-0000-000000000000",
22+      "preview_database_id": "00000000-0000-0000-0000-000000000000",
23+      "migrations_table": "d1_migrations",
24+      "migrations_dir": "../../ops/sql/migrations"
25+    }
26+  ],
27+  "observability": {
28+    "enabled": true
29+  }
30+}
M apps/status-api/package.json
+4, -1
 1@@ -9,6 +9,9 @@
 2   },
 3   "scripts": {
 4     "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/status-api/dist BAA_DIST_ENTRY=apps/status-api/src/index.js BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
 5-    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
 6+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
 7+    "serve": "pnpm run build && node dist/index.js",
 8+    "start": "node dist/index.js",
 9+    "smoke": "pnpm run build && node dist/index.js smoke"
10   }
11 }
A apps/status-api/src/cli.ts
+180, -0
  1@@ -0,0 +1,180 @@
  2+import {
  3+  getDefaultStatusApiHost,
  4+  getDefaultStatusApiPort,
  5+  runStatusApiSmokeCheck,
  6+  startStatusApiServer,
  7+  type StatusApiEnvironment
  8+} from "./host.js";
  9+
 10+type StatusApiCliAction = "help" | "serve" | "smoke";
 11+
 12+export interface StatusApiTextWriter {
 13+  write(chunk: string): unknown;
 14+}
 15+
 16+export interface RunStatusApiCliOptions {
 17+  argv?: readonly string[];
 18+  env?: StatusApiEnvironment;
 19+  stderr?: StatusApiTextWriter;
 20+  stdout?: StatusApiTextWriter;
 21+}
 22+
 23+interface StatusApiCliCommand {
 24+  action: StatusApiCliAction;
 25+  host?: string;
 26+  port?: number;
 27+}
 28+
 29+export async function runStatusApiCli(options: RunStatusApiCliOptions = {}): Promise<number> {
 30+  const stdout = toTextWriter(options.stdout, console.log);
 31+  const stderr = toTextWriter(options.stderr, console.error);
 32+  const command = parseStatusApiCliCommand(options.argv ?? process?.argv ?? [], options.env ?? process?.env ?? {});
 33+
 34+  switch (command.action) {
 35+    case "help":
 36+      stdout.write(renderStatusApiCliHelp());
 37+      return 0;
 38+
 39+    case "serve": {
 40+      const server = await startStatusApiServer({
 41+        host: command.host,
 42+        port: command.port
 43+      });
 44+      const baseUrl =
 45+        server.getBaseUrl() ??
 46+        `http://${command.host ?? getDefaultStatusApiHost()}:${command.port ?? getDefaultStatusApiPort()}`;
 47+
 48+      stdout.write(`Status API listening on ${baseUrl}\n`);
 49+
 50+      for (const route of server.describeSurface()) {
 51+        stdout.write(`- ${route}\n`);
 52+      }
 53+
 54+      return 0;
 55+    }
 56+
 57+    case "smoke": {
 58+      const result = await runStatusApiSmokeCheck({
 59+        host: command.host ?? getDefaultStatusApiHost(),
 60+        port: command.port ?? 0
 61+      });
 62+
 63+      stdout.write(`Smoke OK on ${result.baseUrl}\n`);
 64+
 65+      for (const check of result.checks) {
 66+        stdout.write(`- ${check.status} ${check.path} ${check.detail}\n`);
 67+      }
 68+
 69+      return 0;
 70+    }
 71+  }
 72+}
 73+
 74+function parseStatusApiCliCommand(argv: readonly string[], env: StatusApiEnvironment): StatusApiCliCommand {
 75+  const tokens = argv.slice(2);
 76+  let action: StatusApiCliAction = "serve";
 77+  let actionSet = false;
 78+  let host: string | undefined;
 79+  let port: number | undefined;
 80+
 81+  for (let index = 0; index < tokens.length; index += 1) {
 82+    const token = tokens[index];
 83+
 84+    if (token == null) {
 85+      continue;
 86+    }
 87+
 88+    if (token === "--help" || token === "-h" || token === "help") {
 89+      return { action: "help" };
 90+    }
 91+
 92+    if (token === "--host") {
 93+      host = readCliValue(tokens, index, "--host");
 94+      index += 1;
 95+      continue;
 96+    }
 97+
 98+    if (token === "--port") {
 99+      port = parseCliPort(readCliValue(tokens, index, "--port"));
100+      index += 1;
101+      continue;
102+    }
103+
104+    if (token.startsWith("--")) {
105+      throw new Error(`Unknown status-api flag "${token}".`);
106+    }
107+
108+    if (actionSet) {
109+      throw new Error(`Unexpected extra status-api argument "${token}".`);
110+    }
111+
112+    if (!isStatusApiCliAction(token)) {
113+      throw new Error(`Unknown status-api action "${token}".`);
114+    }
115+
116+    action = token;
117+    actionSet = true;
118+  }
119+
120+  return {
121+    action,
122+    host: host ?? env.BAA_STATUS_API_HOST ?? env.HOST,
123+    port: port
124+  };
125+}
126+
127+function isStatusApiCliAction(value: string): value is Exclude<StatusApiCliAction, "help"> {
128+  return value === "serve" || value === "smoke";
129+}
130+
131+function readCliValue(tokens: readonly string[], index: number, flag: string): string {
132+  const value = tokens[index + 1];
133+
134+  if (value == null || value.startsWith("--")) {
135+    throw new Error(`Missing value for ${flag}.`);
136+  }
137+
138+  return value;
139+}
140+
141+function parseCliPort(value: string): number {
142+  const port = Number(value);
143+
144+  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
145+    throw new Error(`Invalid --port value "${value}".`);
146+  }
147+
148+  return port;
149+}
150+
151+function renderStatusApiCliHelp(): string {
152+  return [
153+    "Usage: node apps/status-api/dist/index.js [serve|smoke|help] [--host <host>] [--port <port>]",
154+    "",
155+    `Default listen address: http://${getDefaultStatusApiHost()}:${getDefaultStatusApiPort()}`,
156+    "Routes:",
157+    "- GET /healthz",
158+    "- GET /v1/status",
159+    "- GET /v1/status/ui",
160+    "",
161+    "Examples:",
162+    "- pnpm --filter @baa-conductor/status-api serve",
163+    "- pnpm --filter @baa-conductor/status-api smoke",
164+    "- node apps/status-api/dist/index.js --host 127.0.0.1 --port 4318"
165+  ].join("\n") + "\n";
166+}
167+
168+function toTextWriter(
169+  writer: StatusApiTextWriter | undefined,
170+  fallback: (message?: unknown, ...optionalParams: unknown[]) => void
171+): StatusApiTextWriter {
172+  if (writer != null) {
173+    return writer;
174+  }
175+
176+  return {
177+    write(chunk: string) {
178+      fallback(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
179+    }
180+  };
181+}
A apps/status-api/src/host.ts
+369, -0
  1@@ -0,0 +1,369 @@
  2+import { createStatusApiRuntime, describeStatusApiRuntimeSurface, type StatusApiRuntime, type StatusApiRuntimeOptions } from "./runtime.js";
  3+import type { StatusApiResponse } from "./contracts.js";
  4+
  5+const DEFAULT_STATUS_API_HOST = "127.0.0.1";
  6+const DEFAULT_STATUS_API_PORT = 4318;
  7+const NODE_HTTP_MODULE_SPECIFIER = "node:http";
  8+
  9+const ERROR_HEADERS = {
 10+  "content-type": "application/json; charset=utf-8",
 11+  "cache-control": "no-store"
 12+} as const;
 13+
 14+export interface StatusApiEnvironment {
 15+  [key: string]: string | undefined;
 16+}
 17+
 18+export interface StatusApiListenOptions {
 19+  host: string;
 20+  port: number;
 21+}
 22+
 23+export interface StatusApiServerOptions extends StatusApiRuntimeOptions {
 24+  host?: string;
 25+  port?: number;
 26+}
 27+
 28+export interface StatusApiServerAddress {
 29+  address: string;
 30+  family: string;
 31+  port: number;
 32+}
 33+
 34+export interface StatusApiServer {
 35+  readonly runtime: StatusApiRuntime;
 36+  close(): Promise<void>;
 37+  describeSurface(): string[];
 38+  getAddress(): StatusApiServerAddress | string | null;
 39+  getBaseUrl(): string | null;
 40+}
 41+
 42+export interface StatusApiSmokeCheck {
 43+  path: string;
 44+  status: number;
 45+  detail: string;
 46+}
 47+
 48+export interface StatusApiSmokeCheckResult {
 49+  baseUrl: string;
 50+  checks: StatusApiSmokeCheck[];
 51+}
 52+
 53+interface NodeHttpAddressInfo {
 54+  address: string;
 55+  family: string;
 56+  port: number;
 57+}
 58+
 59+interface NodeIncomingMessage {
 60+  method?: string;
 61+  url?: string;
 62+}
 63+
 64+interface NodeServerResponse {
 65+  statusCode: number;
 66+  setHeader(name: string, value: string | readonly string[]): void;
 67+  end(chunk?: string | Uint8Array): void;
 68+}
 69+
 70+interface NodeHttpServer {
 71+  address(): NodeHttpAddressInfo | string | null;
 72+  close(callback?: (error?: Error) => void): void;
 73+  listen(port: number, host: string, listeningListener?: () => void): void;
 74+  once(event: string, listener: (...args: unknown[]) => void): void;
 75+}
 76+
 77+interface NodeHttpModule {
 78+  createServer(
 79+    requestListener: (request: NodeIncomingMessage, response: NodeServerResponse) => void | Promise<void>
 80+  ): NodeHttpServer;
 81+}
 82+
 83+export function resolveStatusApiListenOptions(
 84+  options: StatusApiServerOptions = {},
 85+  env: StatusApiEnvironment = process?.env ?? {}
 86+): StatusApiListenOptions {
 87+  return {
 88+    host: resolveStatusApiHost(options.host, env),
 89+    port: resolveStatusApiPort(options.port, env)
 90+  };
 91+}
 92+
 93+export function createStatusApiNodeRequestListener(runtime: StatusApiRuntime) {
 94+  return (request: NodeIncomingMessage, response: NodeServerResponse): void => {
 95+    void handleStatusApiNodeRequest(runtime, request, response);
 96+  };
 97+}
 98+
 99+export async function startStatusApiServer(options: StatusApiServerOptions = {}): Promise<StatusApiServer> {
100+  const { createServer } = await importNodeHttp();
101+  const listenOptions = resolveStatusApiListenOptions(options);
102+  const runtime = createStatusApiRuntime({
103+    snapshotLoader: options.snapshotLoader
104+  });
105+  const server = createServer(createStatusApiNodeRequestListener(runtime));
106+
107+  await listenOnServer(server, listenOptions);
108+
109+  return {
110+    runtime,
111+    close: () => closeServer(server),
112+    describeSurface: () => describeStatusApiRuntimeSurface(runtime),
113+    getAddress: () => toServerAddress(server.address()),
114+    getBaseUrl: () => getStatusApiBaseUrl(server.address(), listenOptions.host)
115+  };
116+}
117+
118+export async function runStatusApiSmokeCheck(
119+  options: StatusApiServerOptions = {}
120+): Promise<StatusApiSmokeCheckResult> {
121+  const server = await startStatusApiServer({
122+    ...options,
123+    host: options.host ?? DEFAULT_STATUS_API_HOST,
124+    port: options.port ?? 0
125+  });
126+
127+  try {
128+    const baseUrl = server.getBaseUrl();
129+
130+    if (baseUrl == null) {
131+      throw new Error("Status API smoke check could not resolve a listening address.");
132+    }
133+
134+    const checks = [
135+      await verifyHealthz(baseUrl),
136+      await verifyStatus(baseUrl),
137+      await verifyUi(baseUrl)
138+    ];
139+
140+    return {
141+      baseUrl,
142+      checks
143+    };
144+  } finally {
145+    await server.close();
146+  }
147+}
148+
149+export function getDefaultStatusApiHost(): string {
150+  return DEFAULT_STATUS_API_HOST;
151+}
152+
153+export function getDefaultStatusApiPort(): number {
154+  return DEFAULT_STATUS_API_PORT;
155+}
156+
157+async function handleStatusApiNodeRequest(
158+  runtime: StatusApiRuntime,
159+  request: NodeIncomingMessage,
160+  response: NodeServerResponse
161+): Promise<void> {
162+  try {
163+    const runtimeResponse = await runtime.handle({
164+      method: request.method ?? "GET",
165+      path: request.url ?? "/"
166+    });
167+
168+    writeStatusApiResponse(response, runtimeResponse);
169+  } catch (error) {
170+    writeUnhandledErrorResponse(response, error);
171+  }
172+}
173+
174+function writeStatusApiResponse(response: NodeServerResponse, runtimeResponse: StatusApiResponse): void {
175+  response.statusCode = runtimeResponse.status;
176+
177+  for (const [name, value] of Object.entries(runtimeResponse.headers)) {
178+    response.setHeader(name, value);
179+  }
180+
181+  response.end(runtimeResponse.body);
182+}
183+
184+function writeUnhandledErrorResponse(response: NodeServerResponse, error: unknown): void {
185+  const message = error instanceof Error ? error.message : String(error);
186+
187+  response.statusCode = 500;
188+
189+  for (const [name, value] of Object.entries(ERROR_HEADERS)) {
190+    response.setHeader(name, value);
191+  }
192+
193+  response.end(
194+    `${JSON.stringify(
195+      {
196+        ok: false,
197+        error: "internal_error",
198+        message
199+      },
200+      null,
201+      2
202+    )}\n`
203+  );
204+}
205+
206+function resolveStatusApiHost(explicitHost: string | undefined, env: StatusApiEnvironment): string {
207+  const host = explicitHost ?? env.BAA_STATUS_API_HOST ?? env.HOST ?? DEFAULT_STATUS_API_HOST;
208+
209+  return host.trim() === "" ? DEFAULT_STATUS_API_HOST : host;
210+}
211+
212+function resolveStatusApiPort(explicitPort: number | undefined, env: StatusApiEnvironment): number {
213+  if (explicitPort != null) {
214+    return normalizePort(explicitPort);
215+  }
216+
217+  const rawPort = env.BAA_STATUS_API_PORT ?? env.PORT;
218+
219+  if (rawPort == null || rawPort.trim() === "") {
220+    return DEFAULT_STATUS_API_PORT;
221+  }
222+
223+  return normalizePort(Number(rawPort));
224+}
225+
226+function normalizePort(value: number): number {
227+  if (!Number.isInteger(value) || value < 0 || value > 65_535) {
228+    throw new TypeError(`Expected a valid TCP port, received "${String(value)}".`);
229+  }
230+
231+  return value;
232+}
233+
234+async function importNodeHttp(): Promise<NodeHttpModule> {
235+  return import(NODE_HTTP_MODULE_SPECIFIER) as Promise<NodeHttpModule>;
236+}
237+
238+function listenOnServer(server: NodeHttpServer, options: StatusApiListenOptions): Promise<void> {
239+  return new Promise((resolve, reject) => {
240+    let settled = false;
241+
242+    server.once("error", (error: unknown) => {
243+      if (settled) {
244+        return;
245+      }
246+
247+      settled = true;
248+      reject(error);
249+    });
250+
251+    server.listen(options.port, options.host, () => {
252+      if (settled) {
253+        return;
254+      }
255+
256+      settled = true;
257+      resolve();
258+    });
259+  });
260+}
261+
262+function closeServer(server: NodeHttpServer): Promise<void> {
263+  return new Promise((resolve, reject) => {
264+    server.close((error?: Error) => {
265+      if (error != null) {
266+        reject(error);
267+        return;
268+      }
269+
270+      resolve();
271+    });
272+  });
273+}
274+
275+function toServerAddress(value: ReturnType<NodeHttpServer["address"]>): StatusApiServerAddress | string | null {
276+  if (value == null || typeof value === "string") {
277+    return value;
278+  }
279+
280+  return {
281+    address: value.address,
282+    family: value.family,
283+    port: value.port
284+  };
285+}
286+
287+function getStatusApiBaseUrl(
288+  value: ReturnType<NodeHttpServer["address"]>,
289+  fallbackHost: string
290+): string | null {
291+  if (value == null || typeof value === "string") {
292+    return null;
293+  }
294+
295+  return `http://${formatHostForUrl(selectReachableHost(value.address, fallbackHost))}:${value.port}`;
296+}
297+
298+function selectReachableHost(address: string, fallbackHost: string): string {
299+  if (address === "::" || address === "0.0.0.0") {
300+    return fallbackHost === "::" || fallbackHost === "0.0.0.0" ? DEFAULT_STATUS_API_HOST : fallbackHost;
301+  }
302+
303+  return address;
304+}
305+
306+function formatHostForUrl(host: string): string {
307+  return host.includes(":") ? `[${host}]` : host;
308+}
309+
310+async function verifyHealthz(baseUrl: string): Promise<StatusApiSmokeCheck> {
311+  const response = await fetch(new URL("/healthz", baseUrl));
312+  const body = await response.text();
313+
314+  assertStatus(response, 200, "/healthz");
315+
316+  if (body.trim() !== "ok") {
317+    throw new Error(`Expected /healthz to return "ok", received ${JSON.stringify(body)}.`);
318+  }
319+
320+  return {
321+    path: "/healthz",
322+    status: response.status,
323+    detail: 'body="ok"'
324+  };
325+}
326+
327+async function verifyStatus(baseUrl: string): Promise<StatusApiSmokeCheck> {
328+  const response = await fetch(new URL("/v1/status", baseUrl));
329+  const payload = (await response.json()) as {
330+    ok?: boolean;
331+    data?: {
332+      source?: string;
333+    };
334+  };
335+
336+  assertStatus(response, 200, "/v1/status");
337+
338+  if (payload.ok !== true || payload.data?.source !== "empty") {
339+    throw new Error(`Expected /v1/status to return an empty snapshot envelope, received ${JSON.stringify(payload)}.`);
340+  }
341+
342+  return {
343+    path: "/v1/status",
344+    status: response.status,
345+    detail: `source=${payload.data.source}`
346+  };
347+}
348+
349+async function verifyUi(baseUrl: string): Promise<StatusApiSmokeCheck> {
350+  const response = await fetch(new URL("/v1/status/ui", baseUrl));
351+  const html = await response.text();
352+
353+  assertStatus(response, 200, "/v1/status/ui");
354+
355+  if (!html.includes("<title>BAA Conductor Status</title>")) {
356+    throw new Error("Expected /v1/status/ui to return the HTML status page shell.");
357+  }
358+
359+  return {
360+    path: "/v1/status/ui",
361+    status: response.status,
362+    detail: "html shell rendered"
363+  };
364+}
365+
366+function assertStatus(response: Response, expectedStatus: number, path: string): void {
367+  if (response.status !== expectedStatus) {
368+    throw new Error(`Expected ${path} to return ${expectedStatus}, received ${response.status}.`);
369+  }
370+}
M apps/status-api/src/index.ts
+57, -0
 1@@ -3,3 +3,60 @@ export * from "./data-source.js";
 2 export * from "./render.js";
 3 export * from "./runtime.js";
 4 export * from "./service.js";
 5+export * from "./host.js";
 6+export * from "./cli.js";
 7+
 8+import { runStatusApiCli } from "./cli.js";
 9+
10+if (shouldRunStatusApiCli(import.meta.url)) {
11+  try {
12+    const exitCode = await runStatusApiCli();
13+
14+    if (exitCode !== 0 && typeof process !== "undefined") {
15+      process.exitCode = exitCode;
16+    }
17+  } catch (error) {
18+    console.error(formatStatusApiCliError(error));
19+
20+    if (typeof process !== "undefined") {
21+      process.exitCode = 1;
22+    }
23+  }
24+}
25+
26+function shouldRunStatusApiCli(metaUrl: string): boolean {
27+  if (typeof process === "undefined") {
28+    return false;
29+  }
30+
31+  const executedPath = normalizeCliEntryPath(process.argv[1]);
32+
33+  if (executedPath == null) {
34+    return false;
35+  }
36+
37+  const sourceEntryPath = normalizeCliEntryPath(toFsPath(metaUrl));
38+  const distShimPath = normalizeCliEntryPath(toFsPath(new URL("../../../index.js", metaUrl).href));
39+
40+  return executedPath === sourceEntryPath || executedPath === distShimPath;
41+}
42+
43+function normalizeCliEntryPath(value: string | undefined): string | null {
44+  if (value == null || value === "") {
45+    return null;
46+  }
47+
48+  return value.endsWith("/") ? value.slice(0, -1) : value;
49+}
50+
51+function toFsPath(value: string): string {
52+  return decodeURIComponent(new URL(value).pathname);
53+}
54+
55+function formatStatusApiCliError(error: unknown): string {
56+  if (error instanceof Error) {
57+    return error.stack ?? `${error.name}: ${error.message}`;
58+  }
59+
60+  return `Status API startup failed: ${String(error)}`;
61+}
A apps/status-api/src/node-shims.d.ts
+11, -0
 1@@ -0,0 +1,11 @@
 2+declare global {
 3+  const process:
 4+    | {
 5+        argv: string[];
 6+        env: Record<string, string | undefined>;
 7+        exitCode?: number;
 8+      }
 9+    | undefined;
10+}
11+
12+export {};
M apps/status-api/tsconfig.json
+1, -1
1@@ -5,5 +5,5 @@
2     "rootDir": "../..",
3     "outDir": "dist"
4   },
5-  "include": ["src/**/*.ts", "../../packages/db/src/**/*.ts"]
6+  "include": ["src/**/*.ts", "src/**/*.d.ts", "../../packages/db/src/**/*.ts"]
7 }
M coordination/tasks/T-018-control-api-deploy.md
+28, -7
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-018
 4 title: Cloudflare Worker 与 D1 部署配置
 5-status: todo
 6+status: review
 7 branch: feat/T-018-control-api-deploy
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-014
13 write_scope:
14@@ -64,23 +64,44 @@ updated_at: 2026-03-22
15 
16 ## files_changed
17 
18-- 待填写
19+- `apps/control-api-worker/.dev.vars.example`
20+- `apps/control-api-worker/.gitignore`
21+- `apps/control-api-worker/package.json`
22+- `apps/control-api-worker/src/contracts.ts`
23+- `apps/control-api-worker/src/handlers.ts`
24+- `apps/control-api-worker/src/runtime.ts`
25+- `apps/control-api-worker/wrangler.jsonc`
26+- `ops/cloudflare/README.md`
27+- `ops/cloudflare/apply-control-api-d1-migrations.sh`
28+- `ops/cloudflare/control-api-worker.secrets.example.env`
29+- `ops/cloudflare/deploy-control-api-worker.sh`
30+- `coordination/tasks/T-018-control-api-deploy.md`
31 
32 ## commands_run
33 
34-- 待填写
35+- `git worktree add /Users/george/code/baa-conductor-T018 -b feat/T-018-control-api-deploy 458d7cf`
36+- `npx --yes pnpm install`
37+- `bash -n ops/cloudflare/deploy-control-api-worker.sh ops/cloudflare/apply-control-api-d1-migrations.sh`
38+- `npx --yes pnpm --filter @baa-conductor/control-api-worker typecheck`
39+- `npx --yes pnpm --filter @baa-conductor/control-api-worker build`
40 
41 ## result
42 
43-- 待填写
44+- 为 `control-api-worker` 增加了可提交的 `wrangler.jsonc`,固定了 Worker 名称、入口 `dist/index.js`、自定义域 `control-api.makefile.so`、D1 binding `CONTROL_DB` 和最小运行时变量。
45+- 在 `contracts.ts` / `runtime.ts` / `handlers.ts` 中把 D1 binding 与 token/env 名称抽成常量并接入运行时,减少代码和部署配置之间的漂移。
46+- 在 `ops/cloudflare/` 下补了最小运维材料:部署说明、secret 模板、远端 D1 migration 脚本、构建后部署脚本;同时在包脚本中暴露了 Cloudflare 相关入口。
47 
48 ## risks
49 
50-- 待填写
51+- `apps/control-api-worker/wrangler.jsonc` 中的 `database_id` 与 `preview_database_id` 仍是占位值;实际部署前必须替换为真实 D1 UUID。
52+- 本任务只提供 secret 模板和 `wrangler secret put` 约定,真实 secret 仍需在目标 Cloudflare 账号中手动注入。
53+- 未执行真实 Cloudflare deploy / D1 远端 migration,因此自定义域、账号权限和线上资源绑定仍需落地验证。
54 
55 ## next_handoff
56 
57-- 待填写
58+- 先填写 `wrangler.jsonc` 的真实 D1 UUID,再执行 `./ops/cloudflare/apply-control-api-d1-migrations.sh`。
59+- 按 `ops/cloudflare/control-api-worker.secrets.example.env` 的键名把生产 secret 写入 Cloudflare Worker。
60+- 完成后执行 `./ops/cloudflare/deploy-control-api-worker.sh`,并验证 `https://control-api.makefile.so` 是否能正常返回 Worker 响应。
61 
62 开始时建议直接把 `status` 改为 `in_progress`。
63 
M coordination/tasks/T-019-conductor-http.md
+20, -7
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-019
 4 title: Conductor 本地 HTTP 入口
 5-status: todo
 6+status: review
 7 branch: feat/T-019-conductor-http
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-015
13 write_scope:
14@@ -61,23 +61,36 @@ updated_at: 2026-03-22
15 
16 ## files_changed
17 
18-- 待填写
19+- `apps/conductor-daemon/src/index.ts`
20+- `apps/conductor-daemon/src/index.test.js`
21+- `apps/conductor-daemon/src/node-shims.d.ts`
22+- `apps/conductor-daemon/tsconfig.json`
23+- `coordination/tasks/T-019-conductor-http.md`
24 
25 ## commands_run
26 
27-- 待填写
28+- `git worktree add /Users/george/code/baa-conductor-T019 -b feat/T-019-conductor-http 458d7cf`
29+- `npx --yes pnpm install`
30+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon typecheck`
31+- `npx --yes pnpm --filter @baa-conductor/conductor-daemon build`
32+- `node --test --experimental-strip-types apps/conductor-daemon/src/index.test.js`
33+- `git diff --check`
34 
35 ## result
36 
37-- 待填写
38+- 已在 `apps/conductor-daemon` 内补上最小本地只读 HTTP server,基于 `BAA_CONDUCTOR_LOCAL_API` 暴露 `GET /healthz`、`GET /readyz`、`GET /rolez` 与 `GET /v1/runtime`,并限制为 loopback `http://` 地址。
39+- 已把现有 runtime snapshot 接到 HTTP 读取面,`ConductorRuntime` 现在会随启动/停止一起管理本地 server 生命周期,并在快照中回填实际监听地址。
40+- 已补测试覆盖降级态 `readyz/rolez` 语义与真实本地 HTTP 冒烟请求,`typecheck`、`build`、`node --test` 均通过。
41 
42 ## risks
43 
44-- 待填写
45+- 当前 `/readyz` 的语义是“runtime 已启动且 lease 未降级”;后续如果要把本地 runs 恢复、调度器预热或更多依赖也纳入 readiness,需要再扩展判定。
46+- 为避免越出本任务 `write_scope`,Node 内建模块类型声明目前以 `apps/conductor-daemon/src/node-shims.d.ts` 本地 shim 形式维护;如果仓库后续统一引入 `@types/node`,应顺手收敛这里的声明。
47 
48 ## next_handoff
49 
50-- 待填写
51+- launchd、Nginx 或后续 status 相关整合可以直接复用 `http://127.0.0.1:4317/healthz`、`/readyz`、`/rolez`、`/v1/runtime` 作为 conductor 的本地读取面。
52+- 如果后续需要更严格的运维判断,可在不改外部路由的前提下继续细化 `/readyz` 与 `/v1/runtime` 的字段。
53 
54 开始时建议直接把 `status` 改为 `in_progress`。
55 
M coordination/tasks/T-020-status-host.md
+26, -7
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-020
 4 title: Status API 本地宿主进程
 5-status: todo
 6+status: review
 7 branch: feat/T-020-status-host
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-017
13 write_scope:
14@@ -62,23 +62,42 @@ updated_at: 2026-03-22
15 
16 ## files_changed
17 
18-- 待填写
19+- `apps/status-api/package.json`
20+- `apps/status-api/tsconfig.json`
21+- `apps/status-api/src/index.ts`
22+- `apps/status-api/src/cli.ts`
23+- `apps/status-api/src/host.ts`
24+- `apps/status-api/src/node-shims.d.ts`
25+- `coordination/tasks/T-020-status-host.md`
26 
27 ## commands_run
28 
29-- 待填写
30+- `npx --yes pnpm install`
31+- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
32+- `npx --yes pnpm --filter @baa-conductor/status-api build`
33+- `npx --yes pnpm --filter @baa-conductor/status-api smoke`
34+- `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328`
35+- `curl --silent --show-error http://127.0.0.1:4328/healthz`
36+- `curl --silent --show-error http://127.0.0.1:4328/v1/status`
37+- `curl --silent --show-error http://127.0.0.1:4328/v1/status/ui | sed -n '1,8p'`
38 
39 ## result
40 
41-- 待填写
42+- 已在 `apps/status-api/**` 内补齐 Node 宿主层,新增本地 HTTP request listener 与 `startStatusApiServer()`,把 `createStatusApiRuntime()` 真正挂到监听端口。
43+- 已补 `runStatusApiCli()` 与主入口直启逻辑,`node apps/status-api/dist/index.js` 现在默认监听 `127.0.0.1:4318`,可直接给 launchd 复用。
44+- 已新增包内脚本:`pnpm --filter @baa-conductor/status-api serve`、`start`、`smoke`;本地可访问 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`。
45+- 已补最小真实 HTTP 冒烟验证:`smoke` 会拉起临时端口并验证三条 GET 路由,另外也手动确认了 `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328` 的实际监听响应。
46 
47 ## risks
48 
49-- 待填写
50+- 当前宿主进程默认仍使用 `StaticStatusSnapshotLoader`,返回的是本地空快照;真实 D1 / 控制平面接线仍需后续整合任务注入。
51+- `dist/index.js` 的直启能力依赖当前统一 build 产物布局;如果后续全仓库再调整 `BAA_DIST_ENTRY` 或 shim 结构,需要同步验证直启检测逻辑。
52 
53 ## next_handoff
54 
55-- 待填写
56+- 在后续整合中,把真实 snapshot loader 注入 `startStatusApiServer()` 或 CLI 启动路径,让 `/v1/status` 返回真实控制平面状态。
57+- 视 launchd/bootstrap 任务需要,在安装副本里补 `BAA_STATUS_API_HOST` / `BAA_STATUS_API_PORT` 等环境变量,或继续沿用默认 `127.0.0.1:4318`。
58+- 如果后续希望把 status-api 嵌入到更大的 Node 宿主里,可直接复用本任务新增的 request listener / server adapter,而不必再改 service/runtime 层。
59 
60 开始时建议直接把 `status` 改为 `in_progress`。
61 
M coordination/tasks/T-021-runtime-bootstrap.md
+32, -8
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-021
 4 title: launchd 安装脚本与 Runtime Bootstrap
 5-status: todo
 6+status: review
 7 branch: feat/T-021-runtime-bootstrap
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-011
13   - T-013
14@@ -14,7 +14,7 @@ write_scope:
15   - ops/launchd/**
16   - docs/runtime/**
17   - scripts/runtime/**
18-updated_at: 2026-03-22
19+updated_at: 2026-03-22T01:08:59+0800
20 ---
21 
22 # T-021 launchd 安装脚本与 Runtime Bootstrap
23@@ -69,23 +69,47 @@ updated_at: 2026-03-22
24 
25 ## files_changed
26 
27-- 待填写
28+- `coordination/tasks/T-021-runtime-bootstrap.md`
29+- `docs/runtime/README.md`
30+- `docs/runtime/environment.md`
31+- `docs/runtime/launchd.md`
32+- `docs/runtime/layout.md`
33+- `ops/launchd/so.makefile.baa-conductor.plist`
34+- `ops/launchd/so.makefile.baa-worker-runner.plist`
35+- `ops/launchd/so.makefile.baa-status-api.plist`
36+- `scripts/runtime/bootstrap.sh`
37+- `scripts/runtime/check-launchd.sh`
38+- `scripts/runtime/common.sh`
39+- `scripts/runtime/install-launchd.sh`
40+- `scripts/runtime/reload-launchd.sh`
41 
42 ## commands_run
43 
44-- 待填写
45+- `npx --yes pnpm install`
46+- `chmod +x scripts/runtime/bootstrap.sh scripts/runtime/install-launchd.sh scripts/runtime/reload-launchd.sh scripts/runtime/check-launchd.sh`
47+- `bash -n scripts/runtime/*.sh`
48+- `plutil -lint ops/launchd/so.makefile.baa-conductor.plist ops/launchd/so.makefile.baa-worker-runner.plist ops/launchd/so.makefile.baa-status-api.plist`
49+- `git diff --check`
50+- `./scripts/runtime/bootstrap.sh --repo-dir "$TMP_REPO"`
51+- `./scripts/runtime/install-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
52+- `./scripts/runtime/check-launchd.sh --repo-dir "$TMP_REPO" --home-dir "$TMP_HOME" --install-dir "$TMP_INSTALL" --node mini --all-services --shared-token test-token`
53+- `./scripts/runtime/reload-launchd.sh --install-dir "$TMP_INSTALL" --all-services --dry-run`
54 
55 ## result
56 
57-- 待填写
58+- 已新增 `scripts/runtime/bootstrap.sh`、`install-launchd.sh`、`check-launchd.sh`、`reload-launchd.sh` 与共享 helper,使 runtime 目录初始化、plist 安装副本渲染、静态校验、`launchctl` 重载都有可执行脚本。
59+- 已把 `docs/runtime/**` 改为以脚本驱动流程为准,补充 `--node`、`--repo-dir`、`--shared-token`、`--scope` 等输入约定,并明确默认只安装 `conductor`、其它模板需显式 opt-in。
60+- 已小幅修正 `ops/launchd/*.plist` 说明并补入 `BAA_STATE_DIR`,同时在临时 repo 上完成 bootstrap -> install -> check -> reload(dry-run) 的整链验证。
61 
62 ## risks
63 
64-- 待填写
65+- 本任务只验证了 plist 渲染、静态检查和 dry-run `launchctl` 命令,没有在本机真实 `bootstrap` 任何 launchd 服务;实际目标机上仍需再做一次权限和 domain 验证。
66+- `worker-runner` 与 `status-api` 模板现在可以被脚本渲染和校验,但是否作为常驻服务启用仍取决于各自宿主进程接线进度;默认流程因此只安装 `conductor`。
67 
68 ## next_handoff
69 
70-- 待填写
71+- 在真实 `mini` / `mac` 节点上按文档顺序执行 `bootstrap.sh`、`npx --yes pnpm -r build`、`install-launchd.sh`、`check-launchd.sh`,确认安装副本和 runtime 目录都落在预期路径。
72+- 真正准备加载服务时,先运行 `reload-launchd.sh --dry-run` 复核命令;确认无误后再去掉 `--dry-run`。若后续 runner/status 宿主进程落地,可再显式加 `--service worker-runner` 或 `--service status-api`。
73 
74 开始时建议直接把 `status` 改为 `in_progress`。
75 
M coordination/tasks/T-022-ops-automation.md
+34, -7
 1@@ -1,10 +1,10 @@
 2 ---
 3 task_id: T-022
 4 title: Nginx 与 Cloudflare DNS 自动化
 5-status: todo
 6+status: review
 7 branch: feat/T-022-ops-automation
 8 repo: /Users/george/code/baa-conductor
 9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12   - T-008
13 write_scope:
14@@ -64,23 +64,50 @@ updated_at: 2026-03-22
15 
16 ## files_changed
17 
18-- 待填写
19+- `coordination/tasks/T-022-ops-automation.md`
20+- `docs/ops/README.md`
21+- `ops/nginx/templates/baa-conductor.conf.template`
22+- `ops/nginx/templates/includes/direct-node-auth.conf.template`
23+- `scripts/ops/baa-conductor.env.example`
24+- `scripts/ops/cloudflare-dns-plan.mjs`
25+- `scripts/ops/cloudflare-dns-plan.sh`
26+- `scripts/ops/lib/ops-config.mjs`
27+- `scripts/ops/nginx-sync-plan.mjs`
28+- `scripts/ops/nginx-sync-plan.sh`
29 
30 ## commands_run
31 
32-- 待填写
33+- `git worktree add /Users/george/code/baa-conductor-T022 -b feat/T-022-ops-automation 458d7cf`
34+- `npx --yes pnpm install`
35+- `chmod +x scripts/ops/*.sh scripts/ops/*.mjs`
36+- `bash -n scripts/ops/*.sh`
37+- `node --check scripts/ops/cloudflare-dns-plan.mjs`
38+- `node --check scripts/ops/nginx-sync-plan.mjs`
39+- `node --check scripts/ops/lib/ops-config.mjs`
40+- `scripts/ops/cloudflare-dns-plan.sh --env scripts/ops/baa-conductor.env.example`
41+- `scripts/ops/nginx-sync-plan.sh --env scripts/ops/baa-conductor.env.example --check-repo --bundle-dir .tmp/ops/baa-conductor-nginx`
42+- `git diff --check`
43+- `git commit -m "feat(ops): add nginx and cloudflare dns automation helpers"`
44+- `git push -u origin feat/T-022-ops-automation`
45 
46 ## result
47 
48-- 待填写
49+- 新增 `scripts/ops/baa-conductor.env.example`,把公网域名、VPS 公网 IP、Tailscale `100.x` 回源和 Nginx 安装路径收口到一份可执行 inventory 模板
50+- 新增 `scripts/ops/cloudflare-dns-plan.{mjs,sh}`,可渲染目标 DNS 记录、可选用 Cloudflare GET API 对比现网,并输出预览用的 `curl` shell 脚本,但默认不写线上 DNS
51+- 新增 `scripts/ops/nginx-sync-plan.{mjs,sh}` 与 `ops/nginx/templates/**`,可按 inventory 渲染 Nginx 配置、检查仓库默认配置是否漂移,并打出包含 `deploy-on-vps.sh` 的部署 bundle
52+- `docs/ops/README.md` 已改为 inventory 驱动的运维流程说明,覆盖 DNS 计划、Nginx bundle、VPS 分发、证书与验证步骤
53 
54 ## risks
55 
56-- 待填写
57+- 本次没有连接真实 Cloudflare zone;`--fetch-current` 和预览 shell 仍需在填入真实 Zone ID / token 后做一次人工核对
58+- 本次没有在真实 VPS 上执行 `deploy-on-vps.sh`、`nginx -t` 或 `systemctl reload nginx`;证书路径、权限和 systemd 服务名仍需线上确认
59+- 真实 inventory 预计放在仓库外,若运维人员绕开 inventory 手工改 Nginx 或 DNS,模板与线上状态仍可能漂移
60 
61 ## next_handoff
62 
63-- 待填写
64+- 在仓库外复制并填写 `scripts/ops/baa-conductor.env.example`,补上真实 VPS 公网 IP、Cloudflare Zone ID 与证书路径
65+- 导出 `CLOUDFLARE_API_TOKEN` 后运行 `scripts/ops/cloudflare-dns-plan.sh --fetch-current --emit-shell ...`,审阅差异和预览脚本
66+- 运行 `scripts/ops/nginx-sync-plan.sh --env <real-env> --bundle-dir <dir>`,把 bundle 分发到 VPS,先执行 `sudo ./deploy-on-vps.sh`,确认 `nginx -t` 后再显式执行 `sudo ./deploy-on-vps.sh --reload`
67 
68 开始时建议直接把 `status` 改为 `in_progress`。
69 
M docs/ops/README.md
+134, -107
  1@@ -1,142 +1,181 @@
  2-# VPS 与 Nginx 运维说明
  3+# VPS、Nginx 与 Cloudflare DNS 运维
  4 
  5-本目录记录 `baa-conductor` 在 VPS 上的公网入口、Nginx 转发和启用步骤。
  6+本目录收口第四波公网入口的运维步骤。当前方案固定遵守这几个约束:
  7 
  8-## 域名与转发关系
  9+- 公网只暴露 VPS 的 `80/tcp` 与 `443/tcp`
 10+- `mini` / `mac` 回源固定走 Tailscale `100.x` 地址
 11+- 不依赖 `*.ts.net` 的 MagicDNS 名称
 12+- 仓库脚本默认只做 DNS 计划、Nginx 渲染和部署分发,不会直接改线上 DNS
 13+- `deploy-on-vps.sh` 只有显式传 `--reload` 时才会重载 Nginx
 14 
 15-- `conductor.makefile.so`
 16-  - DNS 指向 VPS 公网 IP
 17-  - `Nginx -> upstream conductor_primary`
 18-  - `conductor_primary` 先打到 `mini` 的 `100.71.210.78:4317`
 19-  - `mini` 不可达时回退到 `mac` 的 `100.112.239.13:4317`
 20-- `mini-conductor.makefile.so`
 21-  - DNS 指向同一台 VPS
 22-  - `Nginx -> upstream mini_conductor_direct -> 100.71.210.78:4317`
 23-  - 用于直连 `mini` 做调试、健康检查和手工运维
 24-- `mac-conductor.makefile.so`
 25-  - DNS 指向同一台 VPS
 26-  - `Nginx -> upstream mac_conductor_direct -> 100.112.239.13:4317`
 27-  - 用于直连 `mac` 做调试、健康检查和手工运维
 28-
 29-统一原则:
 30+`control-api.makefile.so` 的 Worker 自定义域仍由 Cloudflare Worker / D1 相关任务管理,不在这里的脚本覆盖范围内。
 31 
 32-- 只有 VPS 对公网暴露 `80/tcp` 与 `443/tcp`
 33-- `mini` 与 `mac` 的 `4317` 只应通过 Tailscale 或 WireGuard 被 VPS 访问
 34-- `conductor.makefile.so` 不加 Basic Auth,留给统一入口使用
 35-- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 默认启用 Basic Auth
 36+## 单一来源 inventory
 37 
 38-## 仓库文件与 VPS 目标路径
 39+本任务把公网域名、VPS 公网 IP、内网 Tailscale `100.x` 和 Nginx 安装路径收口到一份 inventory:
 40 
 41-仓库中的配置与 VPS 上建议路径一一对应:
 42+- [`scripts/ops/baa-conductor.env.example`](../../scripts/ops/baa-conductor.env.example)
 43+- [`scripts/ops/cloudflare-dns-plan.sh`](../../scripts/ops/cloudflare-dns-plan.sh)
 44+- [`scripts/ops/nginx-sync-plan.sh`](../../scripts/ops/nginx-sync-plan.sh)
 45+- [`ops/nginx/templates/baa-conductor.conf.template`](../../ops/nginx/templates/baa-conductor.conf.template)
 46+- [`ops/nginx/templates/includes/direct-node-auth.conf.template`](../../ops/nginx/templates/includes/direct-node-auth.conf.template)
 47 
 48-```text
 49-ops/nginx/baa-conductor.conf                    -> /etc/nginx/sites-available/baa-conductor.conf
 50-ops/nginx/includes/common-proxy.conf           -> /etc/nginx/includes/baa-conductor/common-proxy.conf
 51-ops/nginx/includes/direct-node-auth.conf       -> /etc/nginx/includes/baa-conductor/direct-node-auth.conf
 52-/etc/nginx/sites-enabled/baa-conductor.conf    -> symlink to /etc/nginx/sites-available/baa-conductor.conf
 53-/etc/nginx/.htpasswd-baa-conductor             -> direct-node Basic Auth 凭据
 54-```
 55+推荐做法:
 56 
 57-`ops/nginx/baa-conductor.conf` 里写的是实际 VPS 路径,部署时应直接按这个目录结构放置。
 58+1. 把 `baa-conductor.env.example` 复制到仓库外的私有路径
 59+2. 在那份私有 inventory 里填写真实 VPS 公网 IP、Cloudflare Zone ID 和证书路径
 60+3. 用同一份 inventory 先出 DNS 计划,再渲染 Nginx bundle
 61 
 62-## 前置条件
 63+## 当前域名与内网关系
 64 
 65-部署前确认:
 66+| 公网域名 | Cloudflare DNS 目标 | VPS 上的 Nginx upstream | 内网实际回源 |
 67+| --- | --- | --- | --- |
 68+| `conductor.makefile.so` | VPS 公网 IP | `conductor_primary` | `mini 100.71.210.78:4317` 主,`mac 100.112.239.13:4317` 备 |
 69+| `mini-conductor.makefile.so` | VPS 公网 IP | `mini_conductor_direct` | `100.71.210.78:4317` |
 70+| `mac-conductor.makefile.so` | VPS 公网 IP | `mac_conductor_direct` | `100.112.239.13:4317` |
 71 
 72-1. VPS 已安装 `nginx`
 73-2. VPS 能通过 Tailscale 或 WireGuard 访问:
 74-   - `100.71.210.78:4317`
 75-   - `100.112.239.13:4317`
 76-3. 三个域名都已解析到 VPS 公网 IP
 77-4. TLS 证书已经准备好,或已经决定使用哪种签发方式:
 78-   - Let’s Encrypt:默认路径 `/etc/letsencrypt/live/<hostname>/`
 79-   - Cloudflare Origin Cert:把配置中的证书路径改成实际落盘位置
 80-5. 若要启用 Basic Auth,VPS 已安装 `htpasswd` 所在包:
 81-   - Debian/Ubuntu 通常是 `apache2-utils`
 82+统一原则:
 83 
 84-## 部署与启用步骤
 85+- 三个公网 host 都解析到同一台 VPS
 86+- 只有 VPS 对公网暴露 `80/443`
 87+- `4317` 只允许 VPS 通过 Tailscale 或 WireGuard 访问
 88+- `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 默认保留 Basic Auth
 89 
 90-以下步骤假设系统使用 Debian/Ubuntu 风格的 Nginx 目录。
 91+## Cloudflare DNS 计划
 92 
 93-### 1. 安装依赖
 94+### 1. 准备 inventory
 95 
 96 ```bash
 97-sudo apt-get update
 98-sudo apt-get install -y nginx apache2-utils certbot
 99+cp scripts/ops/baa-conductor.env.example ../baa-conductor.ops.env
100+$EDITOR ../baa-conductor.ops.env
101+export CLOUDFLARE_API_TOKEN=...your token...
102 ```
103 
104-如果证书通过 Cloudflare DNS challenge 或其他方式获取,按你的现有流程安装对应插件。
105+注意:
106+
107+- 真实 inventory 建议放仓库外,避免把公网 IP、Zone ID 或 token 变量名混进提交
108+- token 不写进文件,脚本从 `BAA_CF_API_TOKEN_ENV` 指定的环境变量读取
109 
110-### 2. 验证 VPS 到 mini/mac 的内网连通性
111+### 2. 只看目标记录
112 
113 ```bash
114-curl --fail --max-time 3 http://100.71.210.78:4317/healthz
115-curl --fail --max-time 3 http://100.112.239.13:4317/healthz
116+scripts/ops/cloudflare-dns-plan.sh --env ../baa-conductor.ops.env
117 ```
118 
119-任一命令失败时,不要继续启用公网入口,先修好 VPS 到节点的链路。
120+这一步只渲染期望记录,不访问 Cloudflare API。
121 
122-### 3. 准备目录并安装配置
123+### 3. 读取现网并生成变更预览
124 
125 ```bash
126-sudo install -d -m 0755 /etc/nginx/includes/baa-conductor
127-sudo install -m 0644 ops/nginx/includes/common-proxy.conf /etc/nginx/includes/baa-conductor/common-proxy.conf
128-sudo install -m 0644 ops/nginx/includes/direct-node-auth.conf /etc/nginx/includes/baa-conductor/direct-node-auth.conf
129-sudo install -m 0644 ops/nginx/baa-conductor.conf /etc/nginx/sites-available/baa-conductor.conf
130-sudo ln -sfn /etc/nginx/sites-available/baa-conductor.conf /etc/nginx/sites-enabled/baa-conductor.conf
131+scripts/ops/cloudflare-dns-plan.sh \
132+  --env ../baa-conductor.ops.env \
133+  --fetch-current \
134+  --emit-shell .tmp/ops/cloudflare-dns-preview.sh \
135+  --output .tmp/ops/cloudflare-dns-plan.json
136 ```
137 
138-如果默认站点会抢占 `80/443`,按你的发行版习惯移除或禁用默认站点。
139+这一步会:
140+
141+- 用 Cloudflare DNS GET API 读取当前记录
142+- 输出 create / update / delete 计划
143+- 生成一个 `curl` 预览脚本,供人工审阅后再决定是否执行
144+
145+安全边界:
146+
147+- 脚本本身不会发 `POST` / `PATCH` / `DELETE`
148+- `--emit-shell` 只是把预览命令写到文件里,不会自动执行
149+
150+## Nginx 渲染与部署 bundle
151 
152-### 4. 准备直连域名的 Basic Auth
153+### 1. 渲染并打包
154 
155 ```bash
156-sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
157+scripts/ops/nginx-sync-plan.sh \
158+  --env ../baa-conductor.ops.env \
159+  --bundle-dir .tmp/ops/baa-conductor-nginx
160 ```
161 
162-建议:
163+bundle 内会包含:
164+
165+- 渲染后的 `etc/nginx/sites-available/baa-conductor.conf`
166+- 渲染后的 `etc/nginx/includes/baa-conductor/direct-node-auth.conf`
167+- 当前的 `common-proxy.conf`
168+- `inventory-summary.json`
169+- `DEPLOY_COMMANDS.txt`
170+- `deploy-on-vps.sh`
171+
172+### 2. 校验模板是否仍与仓库默认配置一致
173 
174-- 至少给 `mini-conductor.makefile.so` 和 `mac-conductor.makefile.so` 打 Basic Auth
175-- 如果运维来源 IP 固定,再把 allowlist 打开,形成“双保险”
176-- 如果域名经过 Cloudflare 代理,先配置真实 IP 恢复,再使用 `allow/deny`
177+```bash
178+scripts/ops/nginx-sync-plan.sh \
179+  --env scripts/ops/baa-conductor.env.example \
180+  --check-repo
181+```
182 
183-### 5. 准备 TLS 证书
184+这一步用于维护仓库里的默认模板和默认配置,不是线上部署必需步骤。
185 
186-仓库配置默认引用:
187+### 3. 分发到 VPS
188 
189-```text
190-/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem
191-/etc/letsencrypt/live/conductor.makefile.so/privkey.pem
192-/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem
193-/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem
194-/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem
195-/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem
196+```bash
197+rsync -av .tmp/ops/baa-conductor-nginx/ root@YOUR_VPS:/tmp/baa-conductor-nginx/
198+ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh'
199+ssh root@YOUR_VPS 'cd /tmp/baa-conductor-nginx && sudo ./deploy-on-vps.sh --reload'
200 ```
201 
202-两种常见做法:
203+行为说明:
204 
205-- 直接 Let’s Encrypt:在 DNS 已生效且 `80/tcp` 可达时,用 `certbot` 申请三张证书,或一张覆盖三个 SAN 的证书
206-- Cloudflare 代理:改用 Cloudflare Origin Cert,并把 `baa-conductor.conf` 中的路径改成你实际存放的位置
207+- 第一个 `deploy-on-vps.sh` 会安装文件并执行 `nginx -t`,但不会 reload
208+- 只有显式传 `--reload` 时才会执行 `systemctl reload nginx`
209 
210-如果证书文件还不存在,`nginx -t` 会失败;先准备好证书,再启用 `443` 配置。
211+## 前置条件
212+
213+部署前确认:
214+
215+1. VPS 已安装 `nginx`
216+2. VPS 能访问:
217+   - `100.71.210.78:4317`
218+   - `100.112.239.13:4317`
219+3. 三个公网域名都已准备好 DNS 记录
220+4. 证书路径已经与 inventory 一致
221+5. 若启用 Basic Auth,VPS 已安装 `htpasswd`
222 
223-### 6. 做语法检查并启用站点
224+常见依赖:
225 
226 ```bash
227-sudo nginx -t
228-sudo systemctl enable nginx
229-sudo systemctl reload nginx
230+sudo apt-get update
231+sudo apt-get install -y nginx apache2-utils certbot
232 ```
233 
234-首次部署且 Nginx 尚未运行时,把最后一条替换为:
235+若证书通过 Cloudflare DNS challenge 或其他流程签发,按现有方式安装对应插件。
236+
237+## Basic Auth 与证书
238+
239+直连域名默认引用:
240+
241+- `auth_basic_user_file /etc/nginx/.htpasswd-baa-conductor`
242+- `mini-conductor.makefile.so`
243+- `mac-conductor.makefile.so`
244+
245+准备 Basic Auth:
246 
247 ```bash
248-sudo systemctl start nginx
249+sudo htpasswd -c /etc/nginx/.htpasswd-baa-conductor conductor-ops
250 ```
251 
252+准备证书时,仓库默认按 Let’s Encrypt 路径渲染:
253+
254+- `/etc/letsencrypt/live/conductor.makefile.so/fullchain.pem`
255+- `/etc/letsencrypt/live/conductor.makefile.so/privkey.pem`
256+- `/etc/letsencrypt/live/mini-conductor.makefile.so/fullchain.pem`
257+- `/etc/letsencrypt/live/mini-conductor.makefile.so/privkey.pem`
258+- `/etc/letsencrypt/live/mac-conductor.makefile.so/fullchain.pem`
259+- `/etc/letsencrypt/live/mac-conductor.makefile.so/privkey.pem`
260+
261+若改用 Cloudflare Origin Cert,就把 inventory 里的证书根目录改成实际落盘位置,然后重新生成 bundle。
262+
263 ## 上线后验证
264 
265-### 入口与跳转
266+入口与跳转:
267 
268 ```bash
269 curl -I http://conductor.makefile.so
270@@ -145,10 +184,10 @@ curl -I https://conductor.makefile.so/healthz
271 
272 预期:
273 
274-- `http://conductor.makefile.so` 返回 `301` 到 `https://...`
275-- HTTPS 健康检查返回 `200`
276+- `http://conductor.makefile.so` 返回 `301`
277+- `https://conductor.makefile.so/healthz` 返回 `200`
278 
279-### 直连 mini/mac
280+直连 mini/mac:
281 
282 ```bash
283 curl -I https://mini-conductor.makefile.so/healthz
284@@ -158,24 +197,12 @@ curl -u conductor-ops:YOUR_PASSWORD https://mac-conductor.makefile.so/healthz
285 
286 预期:
287 
288-- 未带认证访问 `mini-conductor.makefile.so` 或 `mac-conductor.makefile.so` 返回 `401`
289+- 未带认证访问直连域名返回 `401`
290 - 带正确认证后返回 `200`
291 
292-### 主备切换边界
293-
294-`conductor.makefile.so` 只做入口层 failover:
295-
296-- 能处理 `mini` 网络不可达、连接超时、`502/503/504`
297-- 不能替代 D1 lease 判断
298-- 不能解决 split-brain
299-
300-也就是说,公网入口切到 `mac` 不代表 `mac` 自动获得合法 leader 身份;真正的写权限仍由 conductor 自己校验。
301-
302-## 建议的运维加固
303+## 运维加固
304 
305-- 只对公网开放 `80/tcp` 与 `443/tcp`
306-- 在主机防火墙里明确拒绝公网访问 `4317`
307+- 主机防火墙明确拒绝公网访问 `4317`
308 - `mini-conductor.makefile.so` 与 `mac-conductor.makefile.so` 至少保留 Basic Auth
309-- 有固定办公出口时,把 allowlist 叠加到 `direct-node-auth.conf`
310-- 域名走 Cloudflare 代理时,限制直连 origin 的来源
311-- 上线后定期执行 `sudo nginx -t` 和一次带认证的健康检查
312+- 有固定办公出口时,在 [`ops/nginx/includes/direct-node-auth.conf`](../../ops/nginx/includes/direct-node-auth.conf) 基础上叠加 allowlist
313+- 域名走 Cloudflare 代理时,先恢复真实客户端 IP,再启用 `allow/deny`
M docs/runtime/README.md
+16, -7
 1@@ -1,6 +1,6 @@
 2 # runtime
 3 
 4-本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量和 `launchd` 安装方式。
 5+本目录定义 `mini` 与 `mac` 上的本地 runtime 约定:目录布局、环境变量,以及 `scripts/runtime/*.sh` 驱动的 `launchd` 安装方式。
 6 
 7 当前仓库已经把 app 级 `build` 从单纯 typecheck 推进到真实 emit。执行 `npx --yes pnpm -r build` 后,`apps/*/dist/index.js` 会生成,`ops/launchd/*.plist` 里的入口路径也因此固定下来。
 8 
 9@@ -9,19 +9,28 @@
10 ## 内容
11 
12 - [`layout.md`](./layout.md): `runs/`、`worktrees/`、`logs/`、`tmp/` 与 `state/` 的初始化方式和生命周期
13-- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量
14-- [`launchd.md`](./launchd.md): `mini` 与 `mac` 的安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
15+- [`environment.md`](./environment.md): `launchd` 下必须显式写入的环境变量,以及安装脚本如何覆盖默认值
16+- [`launchd.md`](./launchd.md): `mini` 与 `mac` 的脚本化安装步骤,以及 `LaunchAgents` / `LaunchDaemons` 的差异
17 
18 ## 统一约定
19 
20 - `mini` 是首选 leader,默认跑 `so.makefile.baa-conductor` 的 `primary` 配置。
21 - `mac` 使用同一组服务,但 conductor 默认角色是 `standby`。
22 - 两台机器都推荐把仓库放在 `/Users/george/code/baa-conductor`,这样 repo 内的 plist 源模板可以直接复用设计里的绝对路径。
23-- repo 中的 plist 只作为源模板;真正加载的是复制到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
24+- repo 中的 plist 只作为源模板;真正加载的是 `scripts/runtime/install-launchd.sh` 复制并改写到 `~/Library/LaunchAgents/` 或 `/Library/LaunchDaemons/` 的副本。
25 
26 ## 最短路径
27 
28-1. 先按 [`layout.md`](./layout.md) 初始化 runtime 根目录。
29+1. 先按 [`layout.md`](./layout.md) 运行 `./scripts/runtime/bootstrap.sh` 初始化 runtime 根目录。
30 2. 再按 [`environment.md`](./environment.md) 准备共享变量和节点变量,特别是 `BAA_SHARED_TOKEN`。
31-3. 最后按 [`launchd.md`](./launchd.md) 复制、调整并加载 plist。
32-4. 每次准备加载或重载本地服务前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
33+3. 按 [`launchd.md`](./launchd.md) 运行 `install-launchd.sh` 生成安装副本,再用 `check-launchd.sh` / `reload-launchd.sh` 校验与重载。
34+4. 每次准备执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 前,先在 repo 根目录执行一次 `npx --yes pnpm -r build`,确认目标 app 的 `dist/index.js` 已更新。
35+
36+## 当前脚本集
37+
38+- `scripts/runtime/bootstrap.sh`: 预创建 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`tmp/`
39+- `scripts/runtime/install-launchd.sh`: 从 `ops/launchd/*.plist` 渲染实际安装副本
40+- `scripts/runtime/check-launchd.sh`: 校验源模板、runtime 目录、构建产物,以及已安装的 plist 副本
41+- `scripts/runtime/reload-launchd.sh`: 执行或 dry-run `launchctl bootout/bootstrap/kickstart`
42+
43+默认安装/检查/重载集合只包含 `conductor`。如果后续要把其它模板也纳入流程,显式加 `--service worker-runner`、`--service status-api`,或直接使用 `--all-services`。
M docs/runtime/environment.md
+23, -1
 1@@ -19,10 +19,11 @@
 2 | `BAA_WORKTREES_DIR` | `/Users/george/code/baa-conductor/worktrees` | `/Users/george/code/baa-conductor/worktrees` | task worktree 根目录 |
 3 | `BAA_LOGS_DIR` | `/Users/george/code/baa-conductor/logs` | `/Users/george/code/baa-conductor/logs` | 服务日志根目录 |
 4 | `BAA_TMP_DIR` | `/Users/george/code/baa-conductor/tmp` | `/Users/george/code/baa-conductor/tmp` | 临时目录根 |
 5+| `BAA_STATE_DIR` | `/Users/george/code/baa-conductor/state` | `/Users/george/code/baa-conductor/state` | 本地状态镜像目录 |
 6 | `BAA_NODE_ID` | `mini-main` | `mac-standby` | 节点实例 ID;应稳定且可用于 lease/日志归因 |
 7 | `BAA_SHARED_TOKEN` | 安装时注入 | 安装时注入 | 节点到 control API 的共享认证口令 |
 8 
 9-当前设计已经把 `state/` 纳入本地目录布局,但还没有单独定义 `BAA_STATE_DIR`。在真实 daemon 代码需要它之前,保持 `state/` 固定在 repo 根目录下即可。
10+`scripts/runtime/install-launchd.sh` 会把这些路径写进安装副本;如果 repo 根目录不在 `/Users/george/code/baa-conductor`,直接通过 `--repo-dir` 派生新的路径,不需要再手工改每个 plist。
11 
12 ## launchd 辅助变量
13 
14@@ -35,6 +36,18 @@
15 
16 这些变量属于 `launchd` 安装层面的补充,不代表当前应用代码已经消费了全部字段。模板里统一保留它们,是为了避免后续服务落地时再拆分不同的 plist 版本。
17 
18+`install-launchd.sh` 会同时根据 `--home-dir` 重写 `HOME` 和 `PATH`,避免模板里残留固定的 `/Users/george`。
19+
20+## 脚本输入约定
21+
22+`scripts/runtime/install-launchd.sh` / `check-launchd.sh` 目前主要消费这些输入:
23+
24+- `--node mini|mac`:决定 `BAA_CONDUCTOR_HOST`、`BAA_CONDUCTOR_ROLE`、`BAA_NODE_ID`
25+- `--repo-dir PATH`:决定 `WorkingDirectory`、`BAA_*_DIR` 与 `ProgramArguments` 里的 dist 入口
26+- `--home-dir PATH`:决定 `HOME`、`PATH` 与默认 `~/Library/LaunchAgents`
27+- `--shared-token`、`--shared-token-file` 或环境变量 `BAA_SHARED_TOKEN`:写入或校验 `BAA_SHARED_TOKEN`
28+- `--control-api-base`、`--local-api-base`:覆盖默认控制平面地址
29+
30 ## 节点最小差异集
31 
32 如果两台机器的 repo 路径一致,`mac` 相对 `mini` 最少只要改这三项:
33@@ -56,3 +69,12 @@ BAA_NODE_ID=mac-standby
34 ```text
35 --host mac --role standby
36 ```
37+
38+脚本化安装时,这些差异不需要手工改 plist;改成:
39+
40+```bash
41+BAA_SHARED_TOKEN='replace-with-real-token'
42+./scripts/runtime/install-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mac
43+```
44+
45+即可把安装副本改成 `mac` 的节点身份。
M docs/runtime/launchd.md
+102, -60
  1@@ -2,11 +2,19 @@
  2 
  3 ## 服务集合
  4 
  5-推荐长期守护的本地服务:
  6+repo 里保留了三个源模板:
  7 
  8 - `so.makefile.baa-conductor`
  9 - `so.makefile.baa-worker-runner`
 10-- 可选 `so.makefile.baa-status-api`
 11+- `so.makefile.baa-status-api`
 12+
 13+当前脚本化流程默认只安装和重载 `conductor`。如果要把其它模板也纳入流程,显式加:
 14+
 15+- `--service worker-runner`
 16+- `--service status-api`
 17+- 或直接用 `--all-services`
 18+
 19+这样可以先把已接成 CLI/runtime 入口的服务跑通,再按需扩展其他模板。
 20 
 21 repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 22 
 23@@ -21,6 +29,8 @@ repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 24 - `logs/launchd/` 已按 [`layout.md`](./layout.md) 创建
 25 - 如果 repo 不在 `/Users/george/code/baa-conductor`,同步改 `WorkingDirectory`、`ProgramArguments`、`HOME`、`StandardOutPath`、`StandardErrorPath`
 26 
 27+实际安装时推荐让脚本做这些改写,而不是手工编辑 plist。
 28+
 29 ## `LaunchAgents` 与 `LaunchDaemons`
 30 
 31 | 目标路径 | 适合场景 | 优点 | 额外要求 |
 32@@ -32,104 +42,126 @@ repo 内的三个 plist 源模板都默认写成 `mini` 的 canonical 配置:
 33 
 34 ## mini 安装
 35 
 36-`mini` 可以直接复用 repo 模板的大部分默认值,只需要先在复制后的副本里替换真实 token,并确认 control API 与本地端口设置正确。
 37+`mini` 可以直接复用 repo 模板的大部分默认值。推荐顺序如下。
 38 
 39-### 1. 复制模板
 40+### 1. 初始化 runtime 根目录
 41 
 42 ```bash
 43 REPO_DIR=/Users/george/code/baa-conductor
 44-AGENTS_DIR="$HOME/Library/LaunchAgents"
 45-
 46-install -d "$AGENTS_DIR"
 47-cp "$REPO_DIR/ops/launchd/so.makefile.baa-conductor.plist" "$AGENTS_DIR/"
 48-cp "$REPO_DIR/ops/launchd/so.makefile.baa-worker-runner.plist" "$AGENTS_DIR/"
 49-cp "$REPO_DIR/ops/launchd/so.makefile.baa-status-api.plist" "$AGENTS_DIR/"
 50+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
 51 ```
 52 
 53-### 2. 修改安装副本
 54+### 2. 构建 dist 入口
 55 
 56-最少检查这些字段:
 57+```bash
 58+cd /Users/george/code/baa-conductor
 59+npx --yes pnpm -r build
 60+```
 61 
 62-- 所有 plist 里的 `BAA_SHARED_TOKEN`
 63-- 所有 plist 里的 `BAA_CONTROL_API_BASE`
 64-- 所有 plist 里的 `BAA_CONDUCTOR_LOCAL_API`
 65+### 3. 渲染安装副本
 66 
 67-如果 `status-api` 暂时不部署,可以删掉或不加载 `so.makefile.baa-status-api.plist`。
 68+```bash
 69+REPO_DIR=/Users/george/code/baa-conductor
 70+export BAA_SHARED_TOKEN='replace-with-real-token'
 71 
 72-### 3. 加载服务
 73+./scripts/runtime/install-launchd.sh \
 74+  --repo-dir "$REPO_DIR" \
 75+  --node mini
 76+```
 77+
 78+如果要同时渲染 runner/status 模板:
 79 
 80 ```bash
 81-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-conductor.plist" 2>/dev/null || true
 82-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-worker-runner.plist" 2>/dev/null || true
 83-launchctl bootout "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-status-api.plist" 2>/dev/null || true
 84+./scripts/runtime/install-launchd.sh \
 85+  --repo-dir "$REPO_DIR" \
 86+  --node mini \
 87+  --all-services
 88+```
 89 
 90-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-conductor.plist"
 91-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-worker-runner.plist"
 92-launchctl bootstrap "gui/$(id -u)" "$AGENTS_DIR/so.makefile.baa-status-api.plist"
 93+### 4. 静态校验安装副本
 94 
 95-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-conductor"
 96-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-worker-runner"
 97-launchctl kickstart -k "gui/$(id -u)/so.makefile.baa-status-api"
 98-```
 99+```bash
100+REPO_DIR=/Users/george/code/baa-conductor
101+AGENTS_DIR="$HOME/Library/LaunchAgents"
102 
103-如果 `status-api` 没启用,把对应三行删掉即可。
104+./scripts/runtime/check-launchd.sh \
105+  --repo-dir "$REPO_DIR" \
106+  --node mini \
107+  --install-dir "$AGENTS_DIR"
108+```
109 
110-## mac 安装
111+### 5. 预览或执行重载
112 
113-`mac` 与 `mini` 用同一组模板,但需要先把节点身份改成 standby。
114+```bash
115+./scripts/runtime/reload-launchd.sh --dry-run
116+```
117 
118-### 1. 复制模板
119+确认输出无误后,再去掉 `--dry-run` 真正执行。
120 
121-复制命令与 `mini` 相同,只是目标机器改成 `mac` 本机的 `~/Library/LaunchAgents/`。
122+## mac 安装
123 
124-### 2. 修改节点差异项
125+`mac` 与 `mini` 用同一组模板,但节点身份切到 standby。
126 
127-在三个 plist 的 `EnvironmentVariables` 里统一改:
128+### 1. 初始化 runtime 根目录与构建
129 
130-- `BAA_CONDUCTOR_HOST=mac`
131-- `BAA_CONDUCTOR_ROLE=standby`
132-- `BAA_NODE_ID=mac-standby`
133-- `BAA_SHARED_TOKEN=<真实共享口令>`
134+```bash
135+REPO_DIR=/Users/george/code/baa-conductor
136+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
137+cd "$REPO_DIR"
138+npx --yes pnpm -r build
139+```
140 
141-只在 conductor plist 里额外改:
142+### 2. 渲染 `mac` 安装副本
143 
144-- `--host mini` -> `--host mac`
145-- `--role primary` -> `--role standby`
146+```bash
147+REPO_DIR=/Users/george/code/baa-conductor
148+export BAA_SHARED_TOKEN='replace-with-real-token'
149 
150-如果 `mac` 的 repo 路径、home 目录或本地端口与 `mini` 不同,也在复制后的副本里同步改绝对路径与 `BAA_CONDUCTOR_LOCAL_API`。
151+./scripts/runtime/install-launchd.sh \
152+  --repo-dir "$REPO_DIR" \
153+  --node mac
154+```
155 
156 ### 3. 加载服务
157 
158-`launchctl bootstrap` 与 `kickstart` 命令和 `mini` 完全相同,只是执行机器换成 `mac`。
159+静态校验和重载命令与 `mini` 相同,只是把 `--node mini` 换成 `--node mac`。
160 
161 ## 切到 `LaunchDaemons`
162 
163-如果需要登录前启动,流程改成:
164+如果需要登录前启动,脚本流程改成:
165 
166-1. 把编辑好的 plist 复制到 `/Library/LaunchDaemons/`
167-2. 在每个 plist 中增加 `UserName`,例如 `george`
168-3. 设置权限为 `root:wheel` 和 `0644`
169-4. 用 `sudo launchctl bootstrap system ...`
170+1. 用 `bootstrap.sh` 先准备 runtime 根目录
171+2. 用 `install-launchd.sh --scope daemon --username <user>` 渲染到 `/Library/LaunchDaemons/`
172+3. 如有需要,再手工 `sudo chown root:wheel /Library/LaunchDaemons/*.plist`
173+4. 用 `reload-launchd.sh --scope daemon` 或 `launchctl print system/...`
174 
175 示例:
176 
177 ```bash
178-PLIST_SRC="$HOME/Library/LaunchAgents/so.makefile.baa-conductor.plist"
179-
180-sudo cp "$PLIST_SRC" /Library/LaunchDaemons/
181-sudo chown root:wheel /Library/LaunchDaemons/so.makefile.baa-conductor.plist
182-sudo chmod 644 /Library/LaunchDaemons/so.makefile.baa-conductor.plist
183-
184-sudo launchctl bootout system /Library/LaunchDaemons/so.makefile.baa-conductor.plist 2>/dev/null || true
185-sudo launchctl bootstrap system /Library/LaunchDaemons/so.makefile.baa-conductor.plist
186-sudo launchctl kickstart -k system/so.makefile.baa-conductor
187+REPO_DIR=/Users/george/code/baa-conductor
188+export BAA_SHARED_TOKEN='replace-with-real-token'
189+
190+sudo ./scripts/runtime/install-launchd.sh \
191+  --repo-dir "$REPO_DIR" \
192+  --scope daemon \
193+  --node mini \
194+  --username george
195+
196+sudo ./scripts/runtime/check-launchd.sh \
197+  --repo-dir "$REPO_DIR" \
198+  --scope daemon \
199+  --node mini \
200+  --install-dir /Library/LaunchDaemons \
201+  --username george
202+
203+sudo ./scripts/runtime/reload-launchd.sh --scope daemon --dry-run
204 ```
205 
206 使用 `LaunchDaemons` 时尤其要注意:
207 
208 - plist 里的 `HOME`、repo 路径和日志路径不能依赖当前登录用户的 shell
209 - runtime 根目录必须对 `UserName` 指定的账号可写
210-- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先改安装副本
211+- 不要把 repo 源模板直接 `sudo cp` 过去后立刻加载,先通过安装脚本生成副本
212 
213 ## 校验与排障
214 
215@@ -141,6 +173,16 @@ plutil -lint ops/launchd/so.makefile.baa-worker-runner.plist
216 plutil -lint ops/launchd/so.makefile.baa-status-api.plist
217 ```
218 
219+脚本化校验:
220+
221+```bash
222+./scripts/runtime/check-launchd.sh --repo-dir /Users/george/code/baa-conductor --node mini
223+./scripts/runtime/check-launchd.sh \
224+  --repo-dir /Users/george/code/baa-conductor \
225+  --node mini \
226+  --install-dir "$HOME/Library/LaunchAgents"
227+```
228+
229 运行时排障常用命令:
230 
231 ```bash
232@@ -150,7 +192,7 @@ tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-conduct
233 tail -n 50 /Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-worker-runner.err.log
234 ```
235 
236-当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
237+当前仓库已经能为 app 生成基础 `dist/index.js` 产物,因此 launchd 不再依赖“未来某天才会出现的入口文件”。在执行 `check-launchd.sh` 的 dist 校验或真正 `launchctl bootstrap` 之前,先在 repo 根目录跑一次:
238 
239 ```bash
240 npx --yes pnpm -r build
241@@ -158,4 +200,4 @@ npx --yes pnpm -r build
242 
243 这样可以确保 plist 指向的 `apps/*/dist/index.js` 已刷新到最新代码。
244 
245-需要注意的是,第三波后续任务还会继续补各服务的真正运行时接线,所以“入口文件存在”不等于“业务逻辑已经全部落地”。这里解决的是部署路径与构建产物的一致性。
246+需要注意的是,当前脚本解决的是 runtime 目录、plist 渲染、静态检查与 `launchctl` 重载流程;它不替代各服务自己的运行时接线。这里解决的是部署路径与构建产物的一致性,而不是把所有业务进程都变成完整生产守护进程。
M docs/runtime/layout.md
+12, -21
 1@@ -26,40 +26,31 @@
 2 
 3 ## 一次性初始化
 4 
 5-在两台机器上都先创建 runtime 根目录:
 6+优先直接运行仓库内脚本:
 7 
 8 ```bash
 9 REPO_DIR=/Users/george/code/baa-conductor
10-
11-install -d -m 700 \
12-  "$REPO_DIR/state" \
13-  "$REPO_DIR/runs" \
14-  "$REPO_DIR/worktrees" \
15-  "$REPO_DIR/logs" \
16-  "$REPO_DIR/logs/launchd" \
17-  "$REPO_DIR/tmp"
18+./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR"
19 ```
20 
21-如果服务通过 `LaunchDaemons` 以专门账号运行,再把这些目录的 owner 改成该账号,而不是当前登录用户:
22+如果服务通过 `LaunchDaemons` 以专门账号运行,可以在初始化时直接带 owner:
23 
24 ```bash
25-sudo chown -R george:staff \
26-  "$REPO_DIR/state" \
27-  "$REPO_DIR/runs" \
28-  "$REPO_DIR/worktrees" \
29-  "$REPO_DIR/logs" \
30-  "$REPO_DIR/tmp"
31+REPO_DIR=/Users/george/code/baa-conductor
32+sudo ./scripts/runtime/bootstrap.sh --repo-dir "$REPO_DIR" --owner george:staff
33 ```
34 
35+脚本本质上仍然只是执行 `install -d -m 700`,不会加载 `launchd` 服务,也不会修改 repo 之外的配置。
36+
37 ## 目录职责
38 
39 | 路径 | 初始化方式 | 后续创建者 | 说明 |
40 | --- | --- | --- | --- |
41-| `state/` | 手工预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
42-| `runs/` | 手工预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
43-| `worktrees/` | 手工预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
44-| `logs/` | 手工预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
45-| `tmp/` | 手工预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
46+| `state/` | `bootstrap.sh` 预创建根目录 | conductor | 本地状态镜像,便于恢复与对账 |
47+| `runs/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 每个 run 在 `runs/<task-id>/<run-id>/` 下落本地元数据与日志 |
48+| `worktrees/` | `bootstrap.sh` 预创建根目录 | conductor | 每个 task 一个确定性的 worktree,worker 不负责清理 |
49+| `logs/` | `bootstrap.sh` 预创建根目录 | `launchd` 与服务进程 | `logs/launchd/` 放服务 stdout/stderr,其它运行期日志仍按 run 归档 |
50+| `tmp/` | `bootstrap.sh` 预创建根目录 | conductor/worker | 临时文件和中间产物,整机停服后才允许清空 |
51 
52 ## `runs/` 的细化约定
53 
A ops/cloudflare/README.md
+55, -0
 1@@ -0,0 +1,55 @@
 2+# Cloudflare Worker / D1 操作约定
 3+
 4+本目录只负责 `apps/control-api-worker` 的最小部署骨架,不包含真实 Cloudflare 账号信息。
 5+
 6+## 绑定与入口
 7+
 8+- Worker 配置文件:`apps/control-api-worker/wrangler.jsonc`
 9+- Worker 名称:`baa-conductor-control-api`
10+- 入口文件:`dist/index.js`
11+- 自定义域:`control-api.makefile.so`
12+- D1 binding:`CONTROL_DB`
13+- D1 数据库名模板:`baa-conductor-control-prod`
14+
15+当前 Worker 代码和部署配置统一使用以下运行时变量:
16+
17+- 普通变量:`CONTROL_API_VERSION`、`CONTROL_API_AUTH_REQUIRED`
18+- Secret:`BAA_SHARED_TOKEN`、`CONTROL_API_BROWSER_ADMIN_TOKEN`、`CONTROL_API_CONTROLLER_TOKEN`、`CONTROL_API_OPS_ADMIN_TOKEN`、`CONTROL_API_READONLY_TOKEN`、`CONTROL_API_WORKER_TOKEN`
19+
20+## 首次部署前
21+
22+1. 把 `apps/control-api-worker/wrangler.jsonc` 中的 `database_id` / `preview_database_id` 替换成真实 D1 UUID。
23+2. 以 `control-api-worker.secrets.example.env` 为模板,准备不入库的 secret 清单。
24+3. 确保本机已配置 `CLOUDFLARE_API_TOKEN`,或者已通过 `wrangler login` 完成鉴权。
25+
26+## 推荐命令
27+
28+应用远端 D1 migrations:
29+
30+```bash
31+./ops/cloudflare/apply-control-api-d1-migrations.sh
32+```
33+
34+构建并部署 Worker:
35+
36+```bash
37+./ops/cloudflare/deploy-control-api-worker.sh
38+```
39+
40+查看线上日志:
41+
42+```bash
43+cd apps/control-api-worker
44+npx --yes wrangler@4 tail --config wrangler.jsonc
45+```
46+
47+## Secret 下发
48+
49+建议按 `control-api-worker.secrets.example.env` 的键名逐个执行:
50+
51+```bash
52+cd apps/control-api-worker
53+printf '%s' 'replace-me' | npx --yes wrangler@4 secret put BAA_SHARED_TOKEN --config wrangler.jsonc
54+```
55+
56+其余 `CONTROL_API_*_TOKEN` secret 同理。`CONTROL_API_AUTH_REQUIRED` 已在 `wrangler.jsonc` 里固定为 `"true"`,因此生产环境缺失 secret 时会直接拒绝请求,而不是静默降级为匿名访问。
A ops/cloudflare/apply-control-api-d1-migrations.sh
+25, -0
 1@@ -0,0 +1,25 @@
 2+#!/usr/bin/env bash
 3+set -euo pipefail
 4+
 5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 6+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
 7+APP_DIR="${REPO_ROOT}/apps/control-api-worker"
 8+CONFIG_PATH="${APP_DIR}/wrangler.jsonc"
 9+
10+if [[ ! -f "${CONFIG_PATH}" ]]; then
11+  echo "wrangler config not found: ${CONFIG_PATH}" >&2
12+  exit 1
13+fi
14+
15+DEFAULT_DATABASE_NAME="$(sed -n 's/.*"database_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${CONFIG_PATH}" | head -n 1)"
16+DATABASE_NAME="${1:-${DEFAULT_DATABASE_NAME}}"
17+
18+if [[ -z "${DATABASE_NAME}" ]]; then
19+  echo "Unable to determine database_name from ${CONFIG_PATH}." >&2
20+  exit 1
21+fi
22+
23+shift $(( $# > 0 ? 1 : 0 ))
24+
25+cd "${APP_DIR}"
26+npx --yes wrangler@4 d1 migrations apply "${DATABASE_NAME}" --config wrangler.jsonc --remote "$@"
A ops/cloudflare/control-api-worker.secrets.example.env
+9, -0
 1@@ -0,0 +1,9 @@
 2+# Copy this file outside the repo, fill in real values, then push each key with:
 3+# printf '%s' "$VALUE" | npx --yes wrangler@4 secret put <KEY> --config apps/control-api-worker/wrangler.jsonc
 4+
 5+BAA_SHARED_TOKEN=replace-me
 6+CONTROL_API_BROWSER_ADMIN_TOKEN=replace-me
 7+CONTROL_API_CONTROLLER_TOKEN=replace-me
 8+CONTROL_API_OPS_ADMIN_TOKEN=replace-me
 9+CONTROL_API_READONLY_TOKEN=replace-me
10+CONTROL_API_WORKER_TOKEN=replace-me
A ops/cloudflare/deploy-control-api-worker.sh
+24, -0
 1@@ -0,0 +1,24 @@
 2+#!/usr/bin/env bash
 3+set -euo pipefail
 4+
 5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 6+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
 7+APP_DIR="${REPO_ROOT}/apps/control-api-worker"
 8+CONFIG_PATH="${APP_DIR}/wrangler.jsonc"
 9+PLACEHOLDER_DATABASE_ID="00000000-0000-0000-0000-000000000000"
10+
11+if [[ ! -f "${CONFIG_PATH}" ]]; then
12+  echo "wrangler config not found: ${CONFIG_PATH}" >&2
13+  exit 1
14+fi
15+
16+if rg --fixed-strings --quiet "${PLACEHOLDER_DATABASE_ID}" "${CONFIG_PATH}"; then
17+  echo "Replace database_id / preview_database_id in ${CONFIG_PATH} before deploying." >&2
18+  exit 1
19+fi
20+
21+cd "${REPO_ROOT}"
22+npx --yes pnpm --filter @baa-conductor/control-api-worker build
23+
24+cd "${APP_DIR}"
25+npx --yes wrangler@4 deploy --config wrangler.jsonc "$@"
M ops/launchd/so.makefile.baa-conductor.plist
+3, -0
 1@@ -3,6 +3,7 @@
 2 <!--
 3   Source template kept in the repo.
 4   Default values target the mini node at /Users/george/code/baa-conductor.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6   For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID,
 7   BAA_SHARED_TOKEN, and keep the CLI args aligned with those values.
 8   If this file is installed under /Library/LaunchDaemons, add UserName and keep
 9@@ -42,6 +43,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
M ops/launchd/so.makefile.baa-status-api.plist
+3, -0
 1@@ -4,6 +4,7 @@
 2   Optional local status API.
 3   Keep the same runtime paths as conductor and worker-runner so that service
 4   logs and temporary files stay under one repo-owned runtime root.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6 -->
 7 <plist version="1.0">
 8   <dict>
 9@@ -39,6 +40,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
M ops/launchd/so.makefile.baa-worker-runner.plist
+3, -0
 1@@ -3,6 +3,7 @@
 2 <!--
 3   Source template kept in the repo.
 4   Defaults target the mini node and share the same runtime tree as conductor.
 5+  Use scripts/runtime/install-launchd.sh to render the actual install copy.
 6   For mac, change BAA_CONDUCTOR_HOST, BAA_CONDUCTOR_ROLE, BAA_NODE_ID, and
 7   BAA_SHARED_TOKEN before copying the file into the launchd install path.
 8 -->
 9@@ -40,6 +41,8 @@
10       <string>/Users/george/code/baa-conductor/logs</string>
11       <key>BAA_TMP_DIR</key>
12       <string>/Users/george/code/baa-conductor/tmp</string>
13+      <key>BAA_STATE_DIR</key>
14+      <string>/Users/george/code/baa-conductor/state</string>
15       <key>BAA_NODE_ID</key>
16       <string>mini-main</string>
17       <key>BAA_SHARED_TOKEN</key>
A ops/nginx/templates/baa-conductor.conf.template
+120, -0
  1@@ -0,0 +1,120 @@
  2+# 部署目标:
  3+# - __NGINX_SITE_INSTALL_PATH__
  4+# - __NGINX_SITE_ENABLED_PATH__ -> symlink to sites-available
  5+# - __NGINX_INCLUDE_GLOB__ 由仓库里的 ops/nginx/includes/* 同步过去
  6+#
  7+# 说明:
  8+# - __CONDUCTOR_HOST__ 作为统一入口,走 mini 主、mac 备的 upstream
  9+# - __MINI_DIRECT_HOST__ 与 __MAC_DIRECT_HOST__ 直连单节点 upstream
 10+# - 所有 upstream 都直接写 Tailscale 100.x 地址
 11+# - 不使用 mini.tail0125d.ts.net / mbp.tail0125d.ts.net 等 MagicDNS 名称
 12+# - 这样可以避开 ClashX 与 MagicDNS 的 DNS 接管冲突
 13+# - 证书路径使用 Let's Encrypt 默认目录,若走 Cloudflare Origin Cert 请替换为实际文件路径
 14+
 15+map $http_upgrade $connection_upgrade {
 16+    default upgrade;
 17+    ''      '';
 18+}
 19+
 20+upstream conductor_primary {
 21+    # mini 主节点,使用 Tailscale IPv4 私网地址回源
 22+    server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__ max_fails=2 fail_timeout=5s;
 23+    # mac 备用节点,使用 Tailscale IPv4 私网地址回源
 24+    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__ backup;
 25+    keepalive 32;
 26+}
 27+
 28+upstream mini_conductor_direct {
 29+    server __MINI_TAILSCALE_IP__:__CONDUCTOR_PORT__;
 30+    keepalive 16;
 31+}
 32+
 33+upstream mac_conductor_direct {
 34+    server __MAC_TAILSCALE_IP__:__CONDUCTOR_PORT__;
 35+    keepalive 16;
 36+}
 37+
 38+server {
 39+    listen 80;
 40+    listen [::]:80;
 41+    server_name __CONDUCTOR_HOST__ __MINI_DIRECT_HOST__ __MAC_DIRECT_HOST__;
 42+
 43+    return 301 https://$host$request_uri;
 44+}
 45+
 46+server {
 47+    listen 443 ssl http2;
 48+    listen [::]:443 ssl http2;
 49+    server_name __CONDUCTOR_HOST__;
 50+
 51+    ssl_certificate     __CONDUCTOR_CERT_FULLCHAIN__;
 52+    ssl_certificate_key __CONDUCTOR_CERT_KEY__;
 53+    ssl_protocols       TLSv1.2 TLSv1.3;
 54+    ssl_session_cache   shared:BAAConductorTLS:10m;
 55+    ssl_session_timeout 1d;
 56+
 57+    access_log /var/log/nginx/baa-conductor.access.log;
 58+    error_log  /var/log/nginx/baa-conductor.error.log warn;
 59+
 60+    location = /healthz {
 61+        proxy_pass http://conductor_primary/healthz;
 62+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 63+    }
 64+
 65+    location = /readyz {
 66+        proxy_pass http://conductor_primary/readyz;
 67+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 68+    }
 69+
 70+    location = /rolez {
 71+        proxy_pass http://conductor_primary/rolez;
 72+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 73+    }
 74+
 75+    location / {
 76+        proxy_pass http://conductor_primary;
 77+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 78+    }
 79+}
 80+
 81+server {
 82+    listen 443 ssl http2;
 83+    listen [::]:443 ssl http2;
 84+    server_name __MINI_DIRECT_HOST__;
 85+
 86+    ssl_certificate     __MINI_CERT_FULLCHAIN__;
 87+    ssl_certificate_key __MINI_CERT_KEY__;
 88+    ssl_protocols       TLSv1.2 TLSv1.3;
 89+    ssl_session_cache   shared:BAAConductorTLS:10m;
 90+    ssl_session_timeout 1d;
 91+
 92+    access_log /var/log/nginx/baa-conductor-mini.access.log;
 93+    error_log  /var/log/nginx/baa-conductor-mini.error.log warn;
 94+
 95+    location / {
 96+        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
 97+        proxy_pass http://mini_conductor_direct;
 98+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
 99+    }
100+}
101+
102+server {
103+    listen 443 ssl http2;
104+    listen [::]:443 ssl http2;
105+    server_name __MAC_DIRECT_HOST__;
106+
107+    ssl_certificate     __MAC_CERT_FULLCHAIN__;
108+    ssl_certificate_key __MAC_CERT_KEY__;
109+    ssl_protocols       TLSv1.2 TLSv1.3;
110+    ssl_session_cache   shared:BAAConductorTLS:10m;
111+    ssl_session_timeout 1d;
112+
113+    access_log /var/log/nginx/baa-conductor-mac.access.log;
114+    error_log  /var/log/nginx/baa-conductor-mac.error.log warn;
115+
116+    location / {
117+        include __NGINX_INCLUDE_DIR__/direct-node-auth.conf;
118+        proxy_pass http://mac_conductor_direct;
119+        include __NGINX_INCLUDE_DIR__/common-proxy.conf;
120+    }
121+}
A ops/nginx/templates/includes/direct-node-auth.conf.template
+10, -0
 1@@ -0,0 +1,10 @@
 2+# 直连 mini/mac 的调试入口默认启用 Basic Auth。
 3+# 如果有固定办公网或 VPN 出口,可再叠加 allowlist:
 4+# satisfy any;
 5+# allow 203.0.113.10;
 6+# allow 2001:db8::/48;
 7+# deny all;
 8+# 注意:若域名经过 Cloudflare 代理,要先恢复真实客户端 IP 再使用 allow/deny。
 9+
10+auth_basic "baa-conductor direct node";
11+auth_basic_user_file __NGINX_HTPASSWD_PATH__;
A scripts/ops/baa-conductor.env.example
+33, -0
 1@@ -0,0 +1,33 @@
 2+# Copy this file to a private path outside the repo, then fill the real values.
 3+# The committed example keeps the current fourth-wave hostnames and Tailscale 100.x
 4+# upstreams, but uses placeholder public IP and Cloudflare metadata.
 5+
 6+BAA_APP_NAME=baa-conductor
 7+
 8+BAA_CF_ZONE_NAME=makefile.so
 9+BAA_CF_ZONE_ID=REPLACE_WITH_CLOUDFLARE_ZONE_ID
10+BAA_CF_API_TOKEN_ENV=CLOUDFLARE_API_TOKEN
11+BAA_CF_TTL=1
12+BAA_CF_PROXY_CONDUCTOR=true
13+BAA_CF_PROXY_MINI=true
14+BAA_CF_PROXY_MAC=true
15+
16+BAA_PUBLIC_IPV4=203.0.113.10
17+BAA_PUBLIC_IPV6=
18+
19+BAA_CONDUCTOR_HOST=conductor.makefile.so
20+BAA_MINI_DIRECT_HOST=mini-conductor.makefile.so
21+BAA_MAC_DIRECT_HOST=mac-conductor.makefile.so
22+
23+BAA_MINI_TAILSCALE_IP=100.71.210.78
24+BAA_MAC_TAILSCALE_IP=100.112.239.13
25+BAA_CONDUCTOR_PORT=4317
26+
27+BAA_NGINX_SITE_NAME=baa-conductor.conf
28+BAA_NGINX_SITE_INSTALL_DIR=/etc/nginx/sites-available
29+BAA_NGINX_SITE_ENABLED_DIR=/etc/nginx/sites-enabled
30+BAA_NGINX_INCLUDE_DIR=/etc/nginx/includes/baa-conductor
31+BAA_NGINX_HTPASSWD_PATH=/etc/nginx/.htpasswd-baa-conductor
32+BAA_TLS_CERT_ROOT=/etc/letsencrypt/live
33+
34+BAA_BUNDLE_DIR=.tmp/ops/baa-conductor-nginx
A scripts/ops/cloudflare-dns-plan.mjs
+362, -0
  1@@ -0,0 +1,362 @@
  2+#!/usr/bin/env node
  3+
  4+import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
  5+import { dirname, resolve } from "node:path";
  6+import {
  7+  buildDesiredDnsRecords,
  8+  buildHostInventory,
  9+  loadOpsConfig,
 10+  parseCliArgs,
 11+  resolvePath,
 12+} from "./lib/ops-config.mjs";
 13+
 14+const usage = `Usage:
 15+  node scripts/ops/cloudflare-dns-plan.mjs [--env PATH] [--fetch-current] [--format text|json]
 16+                                           [--output PATH] [--emit-shell PATH]
 17+
 18+Behavior:
 19+  - Without --fetch-current, only render the desired DNS records.
 20+  - With --fetch-current, call Cloudflare GET endpoints and diff against the desired records.
 21+  - This tool never sends POST/PATCH/DELETE requests. --emit-shell only writes a preview shell script.`;
 22+
 23+async function main() {
 24+  const args = parseCliArgs(process.argv.slice(2));
 25+
 26+  if (args.help) {
 27+    console.log(usage);
 28+    return;
 29+  }
 30+
 31+  const config = loadOpsConfig(args.env);
 32+  const desiredRecords = buildDesiredDnsRecords(config);
 33+  const hostInventory = buildHostInventory(config);
 34+
 35+  let currentRecords = [];
 36+  let plan = buildDesiredOnlyPlan(desiredRecords);
 37+
 38+  if (args.fetchCurrent) {
 39+    currentRecords = await fetchCurrentRecords(config, hostInventory.map((host) => host.hostname));
 40+    plan = buildDiffPlan(desiredRecords, currentRecords);
 41+  }
 42+
 43+  if (args.emitShell) {
 44+    const shellPath = resolvePath(args.emitShell);
 45+    writePreviewShell(shellPath, config, plan);
 46+  }
 47+
 48+  const outputPayload = {
 49+    env_path: config.envPath,
 50+    zone_name: config.cloudflare.zoneName,
 51+    zone_id: config.cloudflare.zoneId,
 52+    fetch_current: Boolean(args.fetchCurrent),
 53+    desired_records: desiredRecords,
 54+    current_records: currentRecords,
 55+    plan,
 56+  };
 57+
 58+  if (args.output) {
 59+    const outputPath = resolvePath(args.output);
 60+    mkdirSync(resolve(outputPath, ".."), { recursive: true });
 61+    writeFileSync(outputPath, `${JSON.stringify(outputPayload, null, 2)}\n`, "utf8");
 62+  }
 63+
 64+  if (args.format === "json") {
 65+    console.log(JSON.stringify(outputPayload, null, 2));
 66+    return;
 67+  }
 68+
 69+  console.log(formatTextOutput(config, desiredRecords, plan, Boolean(args.fetchCurrent), args.emitShell));
 70+}
 71+
 72+function buildDesiredOnlyPlan(desiredRecords) {
 73+  return desiredRecords.map((record) => ({
 74+    action: "desired",
 75+    target: record,
 76+    reason: "Desired state preview only. Re-run with --fetch-current to diff against Cloudflare.",
 77+  }));
 78+}
 79+
 80+function buildDiffPlan(desiredRecords, currentRecords) {
 81+  const recordsByKey = new Map();
 82+
 83+  for (const record of currentRecords) {
 84+    const key = `${record.type}:${record.name}`;
 85+    const existing = recordsByKey.get(key) ?? [];
 86+    existing.push(record);
 87+    recordsByKey.set(key, existing);
 88+  }
 89+
 90+  const plan = [];
 91+  const handledRecordIds = new Set();
 92+
 93+  for (const desired of desiredRecords) {
 94+    const key = `${desired.type}:${desired.hostname}`;
 95+    const candidates = [...(recordsByKey.get(key) ?? [])];
 96+
 97+    if (candidates.length === 0) {
 98+      plan.push({
 99+        action: "create",
100+        target: desired,
101+        reason: "No existing record matched this hostname and type.",
102+      });
103+      continue;
104+    }
105+
106+    const exactMatchIndex = candidates.findIndex((candidate) => recordMatches(candidate, desired));
107+
108+    if (exactMatchIndex >= 0) {
109+      const [exactMatch] = candidates.splice(exactMatchIndex, 1);
110+      handledRecordIds.add(exactMatch.id);
111+      plan.push({
112+        action: "noop",
113+        current: exactMatch,
114+        target: desired,
115+        reason: "Existing record already matches the desired state.",
116+      });
117+
118+      for (const duplicate of candidates) {
119+        handledRecordIds.add(duplicate.id);
120+        plan.push({
121+          action: "delete",
122+          current: duplicate,
123+          reason: "Duplicate record for the same hostname and type.",
124+        });
125+      }
126+
127+      continue;
128+    }
129+
130+    const [current, ...duplicates] = candidates;
131+    handledRecordIds.add(current.id);
132+
133+    plan.push({
134+      action: "update",
135+      current,
136+      target: desired,
137+      reason: "Hostname and type exist, but content/proxied/ttl differ.",
138+    });
139+
140+    for (const duplicate of duplicates) {
141+      handledRecordIds.add(duplicate.id);
142+      plan.push({
143+        action: "delete",
144+        current: duplicate,
145+        reason: "Duplicate record for the same hostname and type.",
146+      });
147+    }
148+  }
149+
150+  for (const current of currentRecords) {
151+    if (handledRecordIds.has(current.id)) {
152+      continue;
153+    }
154+
155+    plan.push({
156+      action: "delete",
157+      current,
158+      reason: "Managed hostname has no desired record for this type.",
159+    });
160+  }
161+
162+  return plan;
163+}
164+
165+async function fetchCurrentRecords(config, hostnames) {
166+  const token = process.env[config.cloudflare.apiTokenEnv];
167+
168+  if (!token) {
169+    throw new Error(`Missing Cloudflare token in env var ${config.cloudflare.apiTokenEnv}`);
170+  }
171+
172+  if (!config.cloudflare.zoneId || config.cloudflare.zoneId.startsWith("REPLACE_WITH_")) {
173+    throw new Error("Set BAA_CF_ZONE_ID before using --fetch-current.");
174+  }
175+
176+  const uniqueHostnames = Array.from(new Set(hostnames));
177+  const currentRecords = [];
178+
179+  for (const hostname of uniqueHostnames) {
180+    const url = new URL(`https://api.cloudflare.com/client/v4/zones/${config.cloudflare.zoneId}/dns_records`);
181+    url.searchParams.set("name", hostname);
182+    url.searchParams.set("per_page", "100");
183+
184+    const response = await fetch(url, {
185+      headers: {
186+        Authorization: `Bearer ${token}`,
187+        "Content-Type": "application/json",
188+      },
189+    });
190+
191+    if (!response.ok) {
192+      throw new Error(`Cloudflare GET failed for ${hostname}: ${response.status} ${response.statusText}`);
193+    }
194+
195+    const payload = await response.json();
196+
197+    if (!payload.success) {
198+      throw new Error(`Cloudflare GET returned an error for ${hostname}: ${JSON.stringify(payload.errors)}`);
199+    }
200+
201+    for (const result of payload.result) {
202+      if (result.type === "A" || result.type === "AAAA") {
203+        currentRecords.push(result);
204+      }
205+    }
206+  }
207+
208+  return currentRecords;
209+}
210+
211+function recordMatches(current, desired) {
212+  return (
213+    current.type === desired.type &&
214+    current.name === desired.hostname &&
215+    current.content === desired.content &&
216+    Boolean(current.proxied) === Boolean(desired.proxied) &&
217+    Number(current.ttl) === Number(desired.ttl)
218+  );
219+}
220+
221+function writePreviewShell(shellPath, config, plan) {
222+  mkdirSync(dirname(shellPath), { recursive: true });
223+
224+  const lines = [
225+    "#!/usr/bin/env bash",
226+    "set -euo pipefail",
227+    "",
228+    `: "\${${config.cloudflare.apiTokenEnv}:?export ${config.cloudflare.apiTokenEnv} first}"`,
229+    `ZONE_ID=${shellQuote(config.cloudflare.zoneId)}`,
230+    "",
231+    "# Preview script only. Review before running any curl command.",
232+  ];
233+
234+  let emittedCommand = false;
235+
236+  for (const item of plan) {
237+    if (item.action === "create") {
238+      emittedCommand = true;
239+      lines.push(`# ${item.reason}`);
240+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "POST", "$ZONE_ID", null, toPayload(item.target)));
241+      lines.push("");
242+      continue;
243+    }
244+
245+    if (item.action === "update") {
246+      emittedCommand = true;
247+      lines.push(`# ${item.reason}`);
248+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "PATCH", "$ZONE_ID", item.current.id, toPayload(item.target)));
249+      lines.push("");
250+      continue;
251+    }
252+
253+    if (item.action === "delete") {
254+      emittedCommand = true;
255+      lines.push(`# ${item.reason}`);
256+      lines.push(buildCurlCommand(config.cloudflare.apiTokenEnv, "DELETE", "$ZONE_ID", item.current.id, null));
257+      lines.push("");
258+    }
259+  }
260+
261+  if (!emittedCommand) {
262+    lines.push("echo 'No create/update/delete operations were generated.'");
263+  }
264+
265+  writeFileSync(shellPath, `${lines.join("\n")}\n`, "utf8");
266+  chmodSync(shellPath, 0o755);
267+}
268+
269+function buildCurlCommand(tokenEnvName, method, zoneIdExpression, recordId, payload) {
270+  const endpoint = recordId
271+    ? `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records/${recordId}`
272+    : `https://api.cloudflare.com/client/v4/zones/${zoneIdExpression}/dns_records`;
273+  const parts = [
274+    `curl -sS -X ${method}`,
275+    `  -H "Authorization: Bearer $${tokenEnvName}"`,
276+    '  -H "Content-Type: application/json"',
277+    `  "${endpoint}"`,
278+  ];
279+
280+  if (payload) {
281+    parts.push(`  --data ${shellQuote(JSON.stringify(payload))}`);
282+  }
283+
284+  return parts.join(" \\\n");
285+}
286+
287+function toPayload(record) {
288+  return {
289+    type: record.type,
290+    name: record.hostname,
291+    content: record.content,
292+    proxied: record.proxied,
293+    ttl: record.ttl,
294+    comment: record.comment,
295+  };
296+}
297+
298+function shellQuote(value) {
299+  return `'${String(value).replace(/'/g, `'\\''`)}'`;
300+}
301+
302+function formatTextOutput(config, desiredRecords, plan, fetchedCurrent, emitShellPath) {
303+  const lines = [];
304+
305+  lines.push(`Zone: ${config.cloudflare.zoneName}`);
306+  lines.push(`Inventory: ${config.envPath}`);
307+  lines.push("");
308+  lines.push("Desired DNS records:");
309+
310+  for (const record of desiredRecords) {
311+    lines.push(`- ${record.type} ${record.hostname} -> ${record.content} proxied=${record.proxied} ttl=${record.ttl}`);
312+  }
313+
314+  lines.push("");
315+  lines.push(fetchedCurrent ? "Diff plan:" : "Plan preview:");
316+
317+  for (const item of plan) {
318+    if (item.action === "desired") {
319+      lines.push(`- desired ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
320+      continue;
321+    }
322+
323+    if (item.action === "noop") {
324+      lines.push(`- noop ${item.target.type} ${item.target.hostname} already ${item.target.content}`);
325+      continue;
326+    }
327+
328+    if (item.action === "create") {
329+      lines.push(`- create ${item.target.type} ${item.target.hostname} -> ${item.target.content}`);
330+      continue;
331+    }
332+
333+    if (item.action === "update") {
334+      lines.push(`- update ${item.target.type} ${item.target.hostname}: ${item.current.content} -> ${item.target.content}`);
335+      continue;
336+    }
337+
338+    if (item.action === "delete") {
339+      lines.push(`- delete ${item.current.type} ${item.current.name} -> ${item.current.content}`);
340+    }
341+  }
342+
343+  lines.push("");
344+  lines.push("Safety:");
345+  lines.push("- This tool never writes DNS records by itself.");
346+
347+  if (fetchedCurrent) {
348+    lines.push("- Cloudflare API access was read-only GET for the compared records.");
349+  } else {
350+    lines.push("- Re-run with --fetch-current after exporting the Cloudflare token to compare against live records.");
351+  }
352+
353+  if (emitShellPath) {
354+    lines.push(`- Preview curl script written to ${resolvePath(emitShellPath)}.`);
355+  }
356+
357+  return lines.join("\n");
358+}
359+
360+main().catch((error) => {
361+  console.error(`cloudflare-dns-plan failed: ${error.message}`);
362+  process.exitCode = 1;
363+});
A scripts/ops/cloudflare-dns-plan.sh
+5, -0
1@@ -0,0 +1,5 @@
2+#!/usr/bin/env bash
3+set -euo pipefail
4+
5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+exec node "$SCRIPT_DIR/cloudflare-dns-plan.mjs" "$@"
A scripts/ops/lib/ops-config.mjs
+315, -0
  1@@ -0,0 +1,315 @@
  2+import { existsSync, readFileSync } from "node:fs";
  3+import { dirname, resolve } from "node:path";
  4+import { fileURLToPath } from "node:url";
  5+
  6+const moduleDir = dirname(fileURLToPath(import.meta.url));
  7+export const repoRoot = resolve(moduleDir, "..", "..", "..");
  8+
  9+const defaultEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env");
 10+const exampleEnvPath = resolve(repoRoot, "scripts/ops/baa-conductor.env.example");
 11+
 12+export function parseCliArgs(argv) {
 13+  const args = { _: [] };
 14+
 15+  for (let index = 0; index < argv.length; index += 1) {
 16+    const token = argv[index];
 17+
 18+    if (!token.startsWith("--")) {
 19+      args._.push(token);
 20+      continue;
 21+    }
 22+
 23+    const trimmed = token.slice(2);
 24+    const separatorIndex = trimmed.indexOf("=");
 25+
 26+    if (separatorIndex >= 0) {
 27+      const key = toCamelCase(trimmed.slice(0, separatorIndex));
 28+      const value = trimmed.slice(separatorIndex + 1);
 29+      args[key] = value;
 30+      continue;
 31+    }
 32+
 33+    const key = toCamelCase(trimmed);
 34+    const nextToken = argv[index + 1];
 35+
 36+    if (nextToken && !nextToken.startsWith("--")) {
 37+      args[key] = nextToken;
 38+      index += 1;
 39+      continue;
 40+    }
 41+
 42+    args[key] = true;
 43+  }
 44+
 45+  return args;
 46+}
 47+
 48+export function resolveOpsEnvPath(inputPath) {
 49+  if (inputPath) {
 50+    return resolvePath(inputPath);
 51+  }
 52+
 53+  if (existsSync(defaultEnvPath)) {
 54+    return defaultEnvPath;
 55+  }
 56+
 57+  return exampleEnvPath;
 58+}
 59+
 60+export function loadOpsConfig(inputPath) {
 61+  const envPath = resolveOpsEnvPath(inputPath);
 62+  const envSource = readFileSync(envPath, "utf8");
 63+  const env = parseEnvFile(envSource);
 64+
 65+  const config = {
 66+    envPath,
 67+    appName: env.BAA_APP_NAME ?? "baa-conductor",
 68+    cloudflare: {
 69+      zoneName: requiredValue(env, "BAA_CF_ZONE_NAME"),
 70+      zoneId: env.BAA_CF_ZONE_ID ?? "",
 71+      apiTokenEnv: env.BAA_CF_API_TOKEN_ENV ?? "CLOUDFLARE_API_TOKEN",
 72+      ttl: parseNumber(env.BAA_CF_TTL ?? "1", "BAA_CF_TTL"),
 73+      proxied: {
 74+        conductor: parseBoolean(env.BAA_CF_PROXY_CONDUCTOR ?? "true", "BAA_CF_PROXY_CONDUCTOR"),
 75+        mini: parseBoolean(env.BAA_CF_PROXY_MINI ?? "true", "BAA_CF_PROXY_MINI"),
 76+        mac: parseBoolean(env.BAA_CF_PROXY_MAC ?? "true", "BAA_CF_PROXY_MAC"),
 77+      },
 78+    },
 79+    vps: {
 80+      publicIpv4: env.BAA_PUBLIC_IPV4 ?? "",
 81+      publicIpv6: env.BAA_PUBLIC_IPV6 ?? "",
 82+    },
 83+    hosts: {
 84+      conductor: requiredValue(env, "BAA_CONDUCTOR_HOST"),
 85+      mini: requiredValue(env, "BAA_MINI_DIRECT_HOST"),
 86+      mac: requiredValue(env, "BAA_MAC_DIRECT_HOST"),
 87+    },
 88+    tailscale: {
 89+      mini: requiredValue(env, "BAA_MINI_TAILSCALE_IP"),
 90+      mac: requiredValue(env, "BAA_MAC_TAILSCALE_IP"),
 91+      port: parseNumber(env.BAA_CONDUCTOR_PORT ?? "4317", "BAA_CONDUCTOR_PORT"),
 92+    },
 93+    nginx: {
 94+      siteName: env.BAA_NGINX_SITE_NAME ?? "baa-conductor.conf",
 95+      siteInstallDir: env.BAA_NGINX_SITE_INSTALL_DIR ?? "/etc/nginx/sites-available",
 96+      siteEnabledDir: env.BAA_NGINX_SITE_ENABLED_DIR ?? "/etc/nginx/sites-enabled",
 97+      includeDir: env.BAA_NGINX_INCLUDE_DIR ?? "/etc/nginx/includes/baa-conductor",
 98+      htpasswdPath: env.BAA_NGINX_HTPASSWD_PATH ?? "/etc/nginx/.htpasswd-baa-conductor",
 99+      tlsCertRoot: env.BAA_TLS_CERT_ROOT ?? "/etc/letsencrypt/live",
100+      bundleDir: env.BAA_BUNDLE_DIR ?? ".tmp/ops/baa-conductor-nginx",
101+    },
102+  };
103+
104+  validateConfig(config);
105+  return config;
106+}
107+
108+export function buildHostInventory(config) {
109+  return [
110+    {
111+      key: "conductor",
112+      hostname: config.hosts.conductor,
113+      proxied: config.cloudflare.proxied.conductor,
114+      description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}, backup mac ${config.tailscale.mac}:${config.tailscale.port}`,
115+    },
116+    {
117+      key: "mini",
118+      hostname: config.hosts.mini,
119+      proxied: config.cloudflare.proxied.mini,
120+      description: `public ingress via VPS -> mini ${config.tailscale.mini}:${config.tailscale.port}`,
121+    },
122+    {
123+      key: "mac",
124+      hostname: config.hosts.mac,
125+      proxied: config.cloudflare.proxied.mac,
126+      description: `public ingress via VPS -> mac ${config.tailscale.mac}:${config.tailscale.port}`,
127+    },
128+  ];
129+}
130+
131+export function buildDesiredDnsRecords(config) {
132+  const hostInventory = buildHostInventory(config);
133+  const records = [];
134+
135+  for (const host of hostInventory) {
136+    if (config.vps.publicIpv4) {
137+      records.push({
138+        hostname: host.hostname,
139+        type: "A",
140+        content: config.vps.publicIpv4,
141+        proxied: host.proxied,
142+        ttl: config.cloudflare.ttl,
143+        comment: `${config.appName} ${host.description}`,
144+      });
145+    }
146+
147+    if (config.vps.publicIpv6) {
148+      records.push({
149+        hostname: host.hostname,
150+        type: "AAAA",
151+        content: config.vps.publicIpv6,
152+        proxied: host.proxied,
153+        ttl: config.cloudflare.ttl,
154+        comment: `${config.appName} ${host.description}`,
155+      });
156+    }
157+  }
158+
159+  if (records.length === 0) {
160+    throw new Error("No desired DNS records were generated. Set BAA_PUBLIC_IPV4 or BAA_PUBLIC_IPV6.");
161+  }
162+
163+  return records;
164+}
165+
166+export function getNginxTemplateTokens(config) {
167+  return {
168+    "__NGINX_SITE_INSTALL_PATH__": `${config.nginx.siteInstallDir}/${config.nginx.siteName}`,
169+    "__NGINX_SITE_ENABLED_PATH__": `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`,
170+    "__NGINX_INCLUDE_GLOB__": `${config.nginx.includeDir}/*.conf`,
171+    "__CONDUCTOR_HOST__": config.hosts.conductor,
172+    "__MINI_DIRECT_HOST__": config.hosts.mini,
173+    "__MAC_DIRECT_HOST__": config.hosts.mac,
174+    "__MINI_TAILSCALE_IP__": config.tailscale.mini,
175+    "__MAC_TAILSCALE_IP__": config.tailscale.mac,
176+    "__CONDUCTOR_PORT__": String(config.tailscale.port),
177+    "__NGINX_INCLUDE_DIR__": config.nginx.includeDir,
178+    "__NGINX_HTPASSWD_PATH__": config.nginx.htpasswdPath,
179+    "__CONDUCTOR_CERT_FULLCHAIN__": certificatePath(config, config.hosts.conductor, "fullchain.pem"),
180+    "__CONDUCTOR_CERT_KEY__": certificatePath(config, config.hosts.conductor, "privkey.pem"),
181+    "__MINI_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mini, "fullchain.pem"),
182+    "__MINI_CERT_KEY__": certificatePath(config, config.hosts.mini, "privkey.pem"),
183+    "__MAC_CERT_FULLCHAIN__": certificatePath(config, config.hosts.mac, "fullchain.pem"),
184+    "__MAC_CERT_KEY__": certificatePath(config, config.hosts.mac, "privkey.pem"),
185+  };
186+}
187+
188+export function renderTemplate(template, replacements) {
189+  let rendered = template;
190+
191+  for (const [token, value] of Object.entries(replacements)) {
192+    rendered = rendered.replaceAll(token, value);
193+  }
194+
195+  const unresolvedMatches = rendered.match(/__[A-Z0-9_]+__/g);
196+
197+  if (unresolvedMatches) {
198+    throw new Error(`Unresolved template tokens: ${Array.from(new Set(unresolvedMatches)).join(", ")}`);
199+  }
200+
201+  return rendered;
202+}
203+
204+export function buildRenderedNginxArtifacts(config, templates) {
205+  const tokens = getNginxTemplateTokens(config);
206+
207+  return {
208+    siteConf: renderTemplate(templates.siteConf, tokens),
209+    directNodeAuth: renderTemplate(templates.directNodeAuth, tokens),
210+    commonProxy: templates.commonProxy,
211+  };
212+}
213+
214+export function resolvePath(inputPath) {
215+  if (inputPath.startsWith("/")) {
216+    return inputPath;
217+  }
218+
219+  return resolve(process.cwd(), inputPath);
220+}
221+
222+function toCamelCase(value) {
223+  return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
224+}
225+
226+function parseEnvFile(source) {
227+  const env = {};
228+  const lines = source.split(/\r?\n/u);
229+
230+  for (const line of lines) {
231+    const trimmed = line.trim();
232+
233+    if (!trimmed || trimmed.startsWith("#")) {
234+      continue;
235+    }
236+
237+    const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
238+
239+    if (!match) {
240+      throw new Error(`Invalid env line: ${line}`);
241+    }
242+
243+    const [, key, rawValue] = match;
244+    env[key] = stripWrappingQuotes(rawValue.trim());
245+  }
246+
247+  return env;
248+}
249+
250+function stripWrappingQuotes(value) {
251+  if (value.length >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")))) {
252+    return value.slice(1, -1);
253+  }
254+
255+  return value;
256+}
257+
258+function requiredValue(env, key) {
259+  const value = env[key];
260+
261+  if (!value) {
262+    throw new Error(`Missing required env value: ${key}`);
263+  }
264+
265+  return value;
266+}
267+
268+function validateConfig(config) {
269+  validatePublicHostname(config.hosts.conductor, "BAA_CONDUCTOR_HOST");
270+  validatePublicHostname(config.hosts.mini, "BAA_MINI_DIRECT_HOST");
271+  validatePublicHostname(config.hosts.mac, "BAA_MAC_DIRECT_HOST");
272+  validateTailscaleIpv4(config.tailscale.mini, "BAA_MINI_TAILSCALE_IP");
273+  validateTailscaleIpv4(config.tailscale.mac, "BAA_MAC_TAILSCALE_IP");
274+}
275+
276+function parseBoolean(value, key) {
277+  if (value === "true") {
278+    return true;
279+  }
280+
281+  if (value === "false") {
282+    return false;
283+  }
284+
285+  throw new Error(`Invalid boolean for ${key}: ${value}`);
286+}
287+
288+function parseNumber(value, key) {
289+  const parsed = Number(value);
290+
291+  if (!Number.isFinite(parsed)) {
292+    throw new Error(`Invalid number for ${key}: ${value}`);
293+  }
294+
295+  return parsed;
296+}
297+
298+function certificatePath(config, hostname, leafName) {
299+  return `${config.nginx.tlsCertRoot}/${hostname}/${leafName}`;
300+}
301+
302+function validatePublicHostname(value, key) {
303+  if (value.includes(".ts.net")) {
304+    throw new Error(`${key} must be a public hostname, not a MagicDNS name: ${value}`);
305+  }
306+}
307+
308+function validateTailscaleIpv4(value, key) {
309+  if (value.includes(".ts.net")) {
310+    throw new Error(`${key} must use a Tailscale 100.x IPv4 address, not a MagicDNS name: ${value}`);
311+  }
312+
313+  if (!/^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value)) {
314+    throw new Error(`${key} must be a Tailscale 100.x IPv4 address: ${value}`);
315+  }
316+}
A scripts/ops/nginx-sync-plan.mjs
+209, -0
  1@@ -0,0 +1,209 @@
  2+#!/usr/bin/env node
  3+
  4+import {
  5+  chmodSync,
  6+  mkdirSync,
  7+  readFileSync,
  8+  writeFileSync,
  9+} from "node:fs";
 10+import { basename, resolve } from "node:path";
 11+import {
 12+  buildDesiredDnsRecords,
 13+  buildHostInventory,
 14+  buildRenderedNginxArtifacts,
 15+  loadOpsConfig,
 16+  parseCliArgs,
 17+  repoRoot,
 18+  resolvePath,
 19+} from "./lib/ops-config.mjs";
 20+
 21+const usage = `Usage:
 22+  node scripts/ops/nginx-sync-plan.mjs [--env PATH] [--bundle-dir PATH] [--check-repo]
 23+
 24+Behavior:
 25+  - Render the committed Nginx templates from the inventory file.
 26+  - Stage a deploy bundle with deploy-on-vps.sh.
 27+  - --check-repo compares the rendered output against ops/nginx/*.conf in the repo.`;
 28+
 29+function main() {
 30+  const args = parseCliArgs(process.argv.slice(2));
 31+
 32+  if (args.help) {
 33+    console.log(usage);
 34+    return;
 35+  }
 36+
 37+  const config = loadOpsConfig(args.env);
 38+  const bundleDir = resolvePath(args.bundleDir ?? config.nginx.bundleDir);
 39+  const templates = loadTemplates();
 40+  const rendered = buildRenderedNginxArtifacts(config, templates);
 41+  const repoCheck = compareWithRepo(rendered);
 42+
 43+  if (args.checkRepo && !repoCheck.clean) {
 44+    console.error("nginx-sync-plan failed: rendered config drifted from committed ops/nginx artifacts.");
 45+    for (const mismatch of repoCheck.mismatches) {
 46+      console.error(`- ${mismatch}`);
 47+    }
 48+    process.exitCode = 1;
 49+    return;
 50+  }
 51+
 52+  writeBundle(bundleDir, config, rendered);
 53+  printSummary(config, bundleDir, repoCheck);
 54+}
 55+
 56+function loadTemplates() {
 57+  return {
 58+    siteConf: readFileSync(resolve(repoRoot, "ops/nginx/templates/baa-conductor.conf.template"), "utf8"),
 59+    directNodeAuth: readFileSync(resolve(repoRoot, "ops/nginx/templates/includes/direct-node-auth.conf.template"), "utf8"),
 60+    commonProxy: readFileSync(resolve(repoRoot, "ops/nginx/includes/common-proxy.conf"), "utf8"),
 61+  };
 62+}
 63+
 64+function compareWithRepo(rendered) {
 65+  const mismatches = [];
 66+  const repoSiteConf = readFileSync(resolve(repoRoot, "ops/nginx/baa-conductor.conf"), "utf8");
 67+  const repoDirectNodeAuth = readFileSync(resolve(repoRoot, "ops/nginx/includes/direct-node-auth.conf"), "utf8");
 68+
 69+  if (repoSiteConf !== rendered.siteConf) {
 70+    mismatches.push("ops/nginx/baa-conductor.conf");
 71+  }
 72+
 73+  if (repoDirectNodeAuth !== rendered.directNodeAuth) {
 74+    mismatches.push("ops/nginx/includes/direct-node-auth.conf");
 75+  }
 76+
 77+  return {
 78+    clean: mismatches.length === 0,
 79+    mismatches,
 80+  };
 81+}
 82+
 83+function writeBundle(bundleDir, config, rendered) {
 84+  const siteTargetPath = resolve(bundleDir, stripLeadingSlash(config.nginx.siteInstallDir), config.nginx.siteName);
 85+  const includeRoot = resolve(bundleDir, stripLeadingSlash(config.nginx.includeDir));
 86+  const commonProxyPath = resolve(includeRoot, "common-proxy.conf");
 87+  const directNodeAuthPath = resolve(includeRoot, "direct-node-auth.conf");
 88+  const deployScriptPath = resolve(bundleDir, "deploy-on-vps.sh");
 89+  const summaryPath = resolve(bundleDir, "inventory-summary.json");
 90+  const deployCommandsPath = resolve(bundleDir, "DEPLOY_COMMANDS.txt");
 91+
 92+  mkdirSync(resolve(siteTargetPath, ".."), { recursive: true });
 93+  mkdirSync(includeRoot, { recursive: true });
 94+
 95+  writeFileSync(siteTargetPath, rendered.siteConf, "utf8");
 96+  writeFileSync(commonProxyPath, rendered.commonProxy, "utf8");
 97+  writeFileSync(directNodeAuthPath, rendered.directNodeAuth, "utf8");
 98+  writeFileSync(summaryPath, `${JSON.stringify(buildSummary(config), null, 2)}\n`, "utf8");
 99+  writeFileSync(deployCommandsPath, `${buildDeployCommands(bundleDir)}\n`, "utf8");
100+  writeFileSync(deployScriptPath, buildDeployScript(config), "utf8");
101+  chmodSync(deployScriptPath, 0o755);
102+}
103+
104+function buildSummary(config) {
105+  let desiredDnsRecords = [];
106+
107+  try {
108+    desiredDnsRecords = buildDesiredDnsRecords(config);
109+  } catch (error) {
110+    desiredDnsRecords = [{ warning: error.message }];
111+  }
112+
113+  return {
114+    env_path: config.envPath,
115+    public_hosts: buildHostInventory(config),
116+    desired_dns_records: desiredDnsRecords,
117+    tailscale: {
118+      mini: `${config.tailscale.mini}:${config.tailscale.port}`,
119+      mac: `${config.tailscale.mac}:${config.tailscale.port}`,
120+    },
121+    nginx: config.nginx,
122+  };
123+}
124+
125+function buildDeployCommands(bundleDir) {
126+  const bundleName = basename(bundleDir);
127+  return [
128+    "Copy the bundle to the VPS, then run:",
129+    `  rsync -av ${bundleDir}/ root@YOUR_VPS:/tmp/${bundleName}/`,
130+    `  ssh root@YOUR_VPS 'cd /tmp/${bundleName} && sudo ./deploy-on-vps.sh'`,
131+    `  ssh root@YOUR_VPS 'cd /tmp/${bundleName} && sudo ./deploy-on-vps.sh --reload'`,
132+    "",
133+    "The first command installs files and runs nginx -t, but skips reload.",
134+    "The second command is the explicit reload step.",
135+  ].join("\n");
136+}
137+
138+function buildDeployScript(config) {
139+  const siteSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.siteInstallDir)}/${config.nginx.siteName}"`;
140+  const commonProxySource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/common-proxy.conf"`;
141+  const directAuthSource = `"$BUNDLE_ROOT/${stripLeadingSlash(config.nginx.includeDir)}/direct-node-auth.conf"`;
142+  const siteTarget = `${config.nginx.siteInstallDir}/${config.nginx.siteName}`;
143+  const siteEnabledTarget = `${config.nginx.siteEnabledDir}/${config.nginx.siteName}`;
144+  const commonProxyTarget = `${config.nginx.includeDir}/common-proxy.conf`;
145+  const directAuthTarget = `${config.nginx.includeDir}/direct-node-auth.conf`;
146+
147+  return `#!/usr/bin/env bash
148+set -euo pipefail
149+
150+BUNDLE_ROOT="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
151+DO_RELOAD=0
152+
153+if [[ "\${1:-}" == "--reload" ]]; then
154+  DO_RELOAD=1
155+fi
156+
157+install -d -m 0755 ${config.nginx.siteInstallDir}
158+install -d -m 0755 ${config.nginx.siteEnabledDir}
159+install -d -m 0755 ${config.nginx.includeDir}
160+
161+install -m 0644 ${siteSource} "${siteTarget}"
162+install -m 0644 ${commonProxySource} "${commonProxyTarget}"
163+install -m 0644 ${directAuthSource} "${directAuthTarget}"
164+ln -sfn "${siteTarget}" "${siteEnabledTarget}"
165+
166+nginx -t
167+
168+if [[ "$DO_RELOAD" -eq 1 ]]; then
169+  systemctl reload nginx
170+else
171+  echo "Installed bundle and nginx -t passed. Reload skipped."
172+  echo "Run again with: sudo ./deploy-on-vps.sh --reload"
173+fi
174+`;
175+}
176+
177+function printSummary(config, bundleDir, repoCheck) {
178+  const lines = [];
179+
180+  lines.push(`Inventory: ${config.envPath}`);
181+  lines.push(`Bundle: ${bundleDir}`);
182+  lines.push("");
183+  lines.push("Public host mapping:");
184+
185+  for (const host of buildHostInventory(config)) {
186+    lines.push(`- ${host.hostname}: ${host.description}`);
187+  }
188+
189+  lines.push("");
190+  lines.push("Repo drift:");
191+  lines.push(repoCheck.clean ? "- none" : `- mismatches: ${repoCheck.mismatches.join(", ")}`);
192+  lines.push("");
193+  lines.push("Next:");
194+  lines.push(`- Review ${resolve(bundleDir, "inventory-summary.json")}`);
195+  lines.push(`- Copy the bundle to the VPS and run ${resolve(bundleDir, "deploy-on-vps.sh")} there`);
196+  lines.push("- deploy-on-vps.sh only reloads nginx when passed --reload");
197+
198+  console.log(lines.join("\n"));
199+}
200+
201+function stripLeadingSlash(value) {
202+  return value.replace(/^\/+/u, "");
203+}
204+
205+try {
206+  main();
207+} catch (error) {
208+  console.error(`nginx-sync-plan failed: ${error.message}`);
209+  process.exitCode = 1;
210+}
A scripts/ops/nginx-sync-plan.sh
+5, -0
1@@ -0,0 +1,5 @@
2+#!/usr/bin/env bash
3+set -euo pipefail
4+
5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+exec node "$SCRIPT_DIR/nginx-sync-plan.mjs" "$@"
A scripts/runtime/bootstrap.sh
+59, -0
 1@@ -0,0 +1,59 @@
 2+#!/usr/bin/env bash
 3+set -euo pipefail
 4+
 5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 6+# shellcheck source=./common.sh
 7+source "${SCRIPT_DIR}/common.sh"
 8+
 9+usage() {
10+  cat <<'EOF'
11+Usage:
12+  scripts/runtime/bootstrap.sh [options]
13+
14+Options:
15+  --repo-dir PATH      Runtime root to initialize. Defaults to the current checkout root.
16+  --mode OCTAL         Directory mode passed to install -d. Defaults to 700.
17+  --owner USER:GROUP   Optional owner to apply to each runtime directory after creation.
18+  --help               Show this help text.
19+EOF
20+}
21+
22+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
23+mode="700"
24+owner=""
25+
26+while [[ $# -gt 0 ]]; do
27+  case "$1" in
28+    --repo-dir)
29+      repo_dir="$2"
30+      shift 2
31+      ;;
32+    --mode)
33+      mode="$2"
34+      shift 2
35+      ;;
36+    --owner)
37+      owner="$2"
38+      shift 2
39+      ;;
40+    --help)
41+      usage
42+      exit 0
43+      ;;
44+    *)
45+      die "Unknown option: $1"
46+      ;;
47+  esac
48+done
49+
50+while IFS= read -r directory; do
51+  ensure_directory "$directory" "$mode"
52+
53+  if [[ -n "$owner" ]]; then
54+    chown "$owner" "$directory"
55+  fi
56+
57+  runtime_log "ready: $directory"
58+done < <(resolve_runtime_paths "$repo_dir")
59+
60+runtime_log "runtime bootstrap completed for ${repo_dir}"
A scripts/runtime/check-launchd.sh
+251, -0
  1@@ -0,0 +1,251 @@
  2+#!/usr/bin/env bash
  3+set -euo pipefail
  4+
  5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  6+# shellcheck source=./common.sh
  7+source "${SCRIPT_DIR}/common.sh"
  8+
  9+usage() {
 10+  cat <<'EOF'
 11+Usage:
 12+  scripts/runtime/check-launchd.sh [options]
 13+
 14+Options:
 15+  --node mini|mac           Select node defaults. Defaults to mini.
 16+  --scope agent|daemon      Expected launchd scope for install copies. Defaults to agent.
 17+  --service NAME            Add one service to the check set. Repeatable.
 18+  --all-services            Check conductor, worker-runner, and status-api.
 19+  --repo-dir PATH           Repo root used to derive runtime paths.
 20+  --home-dir PATH           HOME value expected in installed plist files.
 21+  --install-dir PATH        Validate installed copies under this directory.
 22+  --shared-token TOKEN      Expect this exact token in installed copies.
 23+  --shared-token-file PATH  Read the expected token from a file.
 24+  --control-api-base URL    Expected BAA_CONTROL_API_BASE.
 25+  --local-api-base URL      Expected BAA_CONDUCTOR_LOCAL_API.
 26+  --username NAME           Expected UserName for LaunchDaemons.
 27+  --skip-dist-check         Skip dist/index.js existence checks.
 28+  --check-loaded            Also require launchctl print to succeed for each selected service.
 29+  --domain TARGET           Override launchctl domain target for --check-loaded.
 30+  --help                    Show this help text.
 31+
 32+Notes:
 33+  If no service is specified, only conductor is checked. Use --all-services or
 34+  repeat --service to opt into worker-runner/status-api templates.
 35+EOF
 36+}
 37+
 38+require_command plutil
 39+assert_file /usr/libexec/PlistBuddy
 40+
 41+node="mini"
 42+scope="agent"
 43+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
 44+home_dir="$(default_home_dir)"
 45+install_dir=""
 46+shared_token=""
 47+shared_token_file=""
 48+control_api_base="${BAA_RUNTIME_DEFAULT_CONTROL_API_BASE}"
 49+local_api_base="${BAA_RUNTIME_DEFAULT_LOCAL_API}"
 50+username="$(default_username)"
 51+skip_dist_check="0"
 52+check_loaded="0"
 53+domain_target=""
 54+services=()
 55+
 56+while [[ $# -gt 0 ]]; do
 57+  case "$1" in
 58+    --node)
 59+      node="$2"
 60+      shift 2
 61+      ;;
 62+    --scope)
 63+      scope="$2"
 64+      shift 2
 65+      ;;
 66+    --service)
 67+      validate_service "$2"
 68+      if ! contains_value "$2" "${services[@]-}"; then
 69+        services+=("$2")
 70+      fi
 71+      shift 2
 72+      ;;
 73+    --all-services)
 74+      while IFS= read -r service; do
 75+        if ! contains_value "$service" "${services[@]-}"; then
 76+          services+=("$service")
 77+        fi
 78+      done < <(all_services)
 79+      shift
 80+      ;;
 81+    --repo-dir)
 82+      repo_dir="$2"
 83+      shift 2
 84+      ;;
 85+    --home-dir)
 86+      home_dir="$2"
 87+      shift 2
 88+      ;;
 89+    --install-dir)
 90+      install_dir="$2"
 91+      shift 2
 92+      ;;
 93+    --shared-token)
 94+      shared_token="$2"
 95+      shift 2
 96+      ;;
 97+    --shared-token-file)
 98+      shared_token_file="$2"
 99+      shift 2
100+      ;;
101+    --control-api-base)
102+      control_api_base="$2"
103+      shift 2
104+      ;;
105+    --local-api-base)
106+      local_api_base="$2"
107+      shift 2
108+      ;;
109+    --username)
110+      username="$2"
111+      shift 2
112+      ;;
113+    --skip-dist-check)
114+      skip_dist_check="1"
115+      shift
116+      ;;
117+    --check-loaded)
118+      check_loaded="1"
119+      shift
120+      ;;
121+    --domain)
122+      domain_target="$2"
123+      shift 2
124+      ;;
125+    --help)
126+      usage
127+      exit 0
128+      ;;
129+    *)
130+      die "Unknown option: $1"
131+      ;;
132+  esac
133+done
134+
135+validate_node "$node"
136+validate_scope "$scope"
137+
138+if [[ "${#services[@]}" -eq 0 ]]; then
139+  while IFS= read -r service; do
140+    services+=("$service")
141+  done < <(default_services)
142+fi
143+
144+if [[ -n "$shared_token" || -n "$shared_token_file" ]]; then
145+  shared_token="$(load_shared_token "$shared_token" "$shared_token_file")"
146+fi
147+
148+set -- $(resolve_node_defaults "$node")
149+conductor_host="$1"
150+conductor_role="$2"
151+node_id="$3"
152+launchd_path="$(default_launchd_path "$home_dir")"
153+state_dir="${repo_dir}/state"
154+runs_dir="${repo_dir}/runs"
155+worktrees_dir="${repo_dir}/worktrees"
156+logs_dir="${repo_dir}/logs"
157+logs_launchd_dir="${logs_dir}/launchd"
158+tmp_dir="${repo_dir}/tmp"
159+
160+assert_directory "$state_dir"
161+assert_directory "$runs_dir"
162+assert_directory "$worktrees_dir"
163+assert_directory "$logs_dir"
164+assert_directory "$logs_launchd_dir"
165+assert_directory "$tmp_dir"
166+
167+check_string_equals() {
168+  local name="$1"
169+  local actual="$2"
170+  local expected="$3"
171+
172+  if [[ "$actual" != "$expected" ]]; then
173+    die "${name} mismatch: expected '${expected}', got '${actual}'"
174+  fi
175+}
176+
177+check_installed_plist() {
178+  local service="$1"
179+  local plist_path="$2"
180+  local stdout_path="$3"
181+  local stderr_path="$4"
182+  local dist_entry="$5"
183+  local actual_shared_token=""
184+
185+  assert_file "$plist_path"
186+  plutil -lint "$plist_path" >/dev/null
187+
188+  check_string_equals "${service}:WorkingDirectory" "$(plist_print_value "$plist_path" ":WorkingDirectory")" "$repo_dir"
189+  check_string_equals "${service}:PATH" "$(plist_print_value "$plist_path" ":EnvironmentVariables:PATH")" "$launchd_path"
190+  check_string_equals "${service}:HOME" "$(plist_print_value "$plist_path" ":EnvironmentVariables:HOME")" "$home_dir"
191+  check_string_equals "${service}:BAA_CONDUCTOR_HOST" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_HOST")" "$conductor_host"
192+  check_string_equals "${service}:BAA_CONDUCTOR_ROLE" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_ROLE")" "$conductor_role"
193+  check_string_equals "${service}:BAA_CONTROL_API_BASE" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONTROL_API_BASE")" "$control_api_base"
194+  check_string_equals "${service}:BAA_CONDUCTOR_LOCAL_API" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_CONDUCTOR_LOCAL_API")" "$local_api_base"
195+  check_string_equals "${service}:BAA_RUNS_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_RUNS_DIR")" "$runs_dir"
196+  check_string_equals "${service}:BAA_WORKTREES_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_WORKTREES_DIR")" "$worktrees_dir"
197+  check_string_equals "${service}:BAA_LOGS_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_LOGS_DIR")" "$logs_dir"
198+  check_string_equals "${service}:BAA_TMP_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_TMP_DIR")" "$tmp_dir"
199+  check_string_equals "${service}:BAA_STATE_DIR" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_STATE_DIR")" "$state_dir"
200+  check_string_equals "${service}:BAA_NODE_ID" "$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_NODE_ID")" "$node_id"
201+  check_string_equals "${service}:stdout" "$(plist_print_value "$plist_path" ":StandardOutPath")" "$stdout_path"
202+  check_string_equals "${service}:stderr" "$(plist_print_value "$plist_path" ":StandardErrorPath")" "$stderr_path"
203+  check_string_equals "${service}:entry" "$(plist_print_value "$plist_path" ":ProgramArguments:2")" "$dist_entry"
204+
205+  actual_shared_token="$(plist_print_value "$plist_path" ":EnvironmentVariables:BAA_SHARED_TOKEN")"
206+  if [[ -n "$shared_token" ]]; then
207+    check_string_equals "${service}:BAA_SHARED_TOKEN" "$actual_shared_token" "$shared_token"
208+  elif [[ -z "$actual_shared_token" || "$actual_shared_token" == "replace-me" ]]; then
209+    die "${service}: BAA_SHARED_TOKEN is empty or still replace-me"
210+  fi
211+
212+  if [[ "$service" == "conductor" ]]; then
213+    check_string_equals "${service}:host-arg" "$(plist_print_value "$plist_path" ":ProgramArguments:4")" "$conductor_host"
214+    check_string_equals "${service}:role-arg" "$(plist_print_value "$plist_path" ":ProgramArguments:6")" "$conductor_role"
215+  fi
216+
217+  if [[ "$scope" == "daemon" ]]; then
218+    check_string_equals "${service}:UserName" "$(plist_print_value "$plist_path" ":UserName")" "$username"
219+  fi
220+}
221+
222+for service in "${services[@]}"; do
223+  template_path="$(service_template_path "$repo_dir" "$service")"
224+  dist_entry="${repo_dir}/$(service_dist_entry_relative "$service")"
225+  stdout_path="$(service_stdout_path "$logs_launchd_dir" "$service")"
226+  stderr_path="$(service_stderr_path "$logs_launchd_dir" "$service")"
227+
228+  assert_file "$template_path"
229+  plutil -lint "$template_path" >/dev/null
230+
231+  if [[ "$skip_dist_check" != "1" ]]; then
232+    assert_file "$dist_entry"
233+  fi
234+
235+  if [[ -n "$install_dir" ]]; then
236+    check_installed_plist "$service" "$(service_install_path "$install_dir" "$service")" "$stdout_path" "$stderr_path" "$dist_entry"
237+  fi
238+done
239+
240+if [[ "$check_loaded" == "1" ]]; then
241+  require_command launchctl
242+
243+  if [[ -z "$domain_target" ]]; then
244+    domain_target="$(default_domain_target "$scope")"
245+  fi
246+
247+  for service in "${services[@]}"; do
248+    launchctl print "${domain_target}/$(service_label "$service")" >/dev/null
249+  done
250+fi
251+
252+runtime_log "launchd checks passed"
A scripts/runtime/common.sh
+294, -0
  1@@ -0,0 +1,294 @@
  2+#!/usr/bin/env bash
  3+
  4+if [[ -n "${BAA_RUNTIME_COMMON_SH_LOADED:-}" ]]; then
  5+  return 0
  6+fi
  7+
  8+readonly BAA_RUNTIME_COMMON_SH_LOADED=1
  9+readonly BAA_RUNTIME_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 10+readonly BAA_RUNTIME_REPO_DIR_DEFAULT="$(cd -- "${BAA_RUNTIME_SCRIPT_DIR}/../.." && pwd)"
 11+readonly BAA_RUNTIME_DEFAULT_CONTROL_API_BASE="https://control-api.makefile.so"
 12+readonly BAA_RUNTIME_DEFAULT_LOCAL_API="http://127.0.0.1:4317"
 13+readonly BAA_RUNTIME_DEFAULT_LOCALE="en_US.UTF-8"
 14+
 15+runtime_log() {
 16+  printf '[runtime] %s\n' "$*"
 17+}
 18+
 19+runtime_error() {
 20+  printf '[runtime] error: %s\n' "$*" >&2
 21+}
 22+
 23+die() {
 24+  runtime_error "$*"
 25+  exit 1
 26+}
 27+
 28+require_command() {
 29+  if ! command -v "$1" >/dev/null 2>&1; then
 30+    die "Missing required command: $1"
 31+  fi
 32+}
 33+
 34+contains_value() {
 35+  local needle="$1"
 36+  shift
 37+
 38+  local value
 39+  for value in "$@"; do
 40+    if [[ "$value" == "$needle" ]]; then
 41+      return 0
 42+    fi
 43+  done
 44+
 45+  return 1
 46+}
 47+
 48+validate_service() {
 49+  case "$1" in
 50+    conductor | worker-runner | status-api) ;;
 51+    *)
 52+      die "Unsupported service: $1"
 53+      ;;
 54+  esac
 55+}
 56+
 57+validate_scope() {
 58+  case "$1" in
 59+    agent | daemon) ;;
 60+    *)
 61+      die "Unsupported launchd scope: $1"
 62+      ;;
 63+  esac
 64+}
 65+
 66+validate_node() {
 67+  case "$1" in
 68+    mini | mac) ;;
 69+    *)
 70+      die "Unsupported node: $1"
 71+      ;;
 72+  esac
 73+}
 74+
 75+default_services() {
 76+  printf '%s\n' conductor
 77+}
 78+
 79+all_services() {
 80+  printf '%s\n' conductor worker-runner status-api
 81+}
 82+
 83+service_label() {
 84+  case "$1" in
 85+    conductor)
 86+      printf '%s\n' "so.makefile.baa-conductor"
 87+      ;;
 88+    worker-runner)
 89+      printf '%s\n' "so.makefile.baa-worker-runner"
 90+      ;;
 91+    status-api)
 92+      printf '%s\n' "so.makefile.baa-status-api"
 93+      ;;
 94+  esac
 95+}
 96+
 97+service_dist_entry_relative() {
 98+  case "$1" in
 99+    conductor)
100+      printf '%s\n' "apps/conductor-daemon/dist/index.js"
101+      ;;
102+    worker-runner)
103+      printf '%s\n' "apps/worker-runner/dist/index.js"
104+      ;;
105+    status-api)
106+      printf '%s\n' "apps/status-api/dist/index.js"
107+      ;;
108+  esac
109+}
110+
111+service_template_path() {
112+  local repo_dir="$1"
113+  local service="$2"
114+
115+  printf '%s/ops/launchd/%s.plist\n' "$repo_dir" "$(service_label "$service")"
116+}
117+
118+service_install_path() {
119+  local install_dir="$1"
120+  local service="$2"
121+
122+  printf '%s/%s.plist\n' "$install_dir" "$(service_label "$service")"
123+}
124+
125+service_stdout_path() {
126+  local logs_launchd_dir="$1"
127+  local service="$2"
128+
129+  printf '%s/%s.out.log\n' "$logs_launchd_dir" "$(service_label "$service")"
130+}
131+
132+service_stderr_path() {
133+  local logs_launchd_dir="$1"
134+  local service="$2"
135+
136+  printf '%s/%s.err.log\n' "$logs_launchd_dir" "$(service_label "$service")"
137+}
138+
139+resolve_node_defaults() {
140+  case "$1" in
141+    mini)
142+      printf '%s %s %s\n' "mini" "primary" "mini-main"
143+      ;;
144+    mac)
145+      printf '%s %s %s\n' "mac" "standby" "mac-standby"
146+      ;;
147+  esac
148+}
149+
150+default_home_dir() {
151+  if [[ -n "${HOME:-}" ]]; then
152+    printf '%s\n' "$HOME"
153+    return 0
154+  fi
155+
156+  printf '/Users/%s\n' "$(id -un)"
157+}
158+
159+default_username() {
160+  printf '%s\n' "$(id -un)"
161+}
162+
163+default_launchd_path() {
164+  local home_dir="$1"
165+
166+  printf '%s\n' "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${home_dir}/.local/bin:${home_dir}/bin"
167+}
168+
169+default_install_dir() {
170+  local scope="$1"
171+  local home_dir="$2"
172+
173+  case "$scope" in
174+    agent)
175+      printf '%s/Library/LaunchAgents\n' "$home_dir"
176+      ;;
177+    daemon)
178+      printf '%s\n' "/Library/LaunchDaemons"
179+      ;;
180+  esac
181+}
182+
183+default_domain_target() {
184+  local scope="$1"
185+
186+  case "$scope" in
187+    agent)
188+      printf 'gui/%s\n' "$(id -u)"
189+      ;;
190+    daemon)
191+      printf '%s\n' "system"
192+      ;;
193+  esac
194+}
195+
196+ensure_directory() {
197+  local path="$1"
198+  local mode="$2"
199+
200+  install -d -m "$mode" "$path"
201+}
202+
203+assert_directory() {
204+  if [[ ! -d "$1" ]]; then
205+    die "Missing directory: $1"
206+  fi
207+}
208+
209+assert_file() {
210+  if [[ ! -f "$1" ]]; then
211+    die "Missing file: $1"
212+  fi
213+}
214+
215+escape_plist_value() {
216+  local value="$1"
217+
218+  value=${value//\\/\\\\}
219+  value=${value//\"/\\\"}
220+
221+  printf '%s' "$value"
222+}
223+
224+plist_set_string() {
225+  local plist_path="$1"
226+  local key="$2"
227+  local value
228+
229+  value="$(escape_plist_value "$3")"
230+
231+  if ! /usr/libexec/PlistBuddy -c "Set ${key} \"${value}\"" "$plist_path" >/dev/null 2>&1; then
232+    /usr/libexec/PlistBuddy -c "Add ${key} string \"${value}\"" "$plist_path" >/dev/null
233+  fi
234+}
235+
236+plist_delete_key() {
237+  local plist_path="$1"
238+  local key="$2"
239+
240+  /usr/libexec/PlistBuddy -c "Delete ${key}" "$plist_path" >/dev/null 2>&1 || true
241+}
242+
243+plist_print_value() {
244+  local plist_path="$1"
245+  local key="$2"
246+
247+  /usr/libexec/PlistBuddy -c "Print ${key}" "$plist_path"
248+}
249+
250+print_shell_command() {
251+  printf '+'
252+
253+  local arg
254+  for arg in "$@"; do
255+    printf ' %q' "$arg"
256+  done
257+
258+  printf '\n'
259+}
260+
261+run_or_print() {
262+  local dry_run="$1"
263+  shift
264+
265+  if [[ "$dry_run" == "1" ]]; then
266+    print_shell_command "$@"
267+    return 0
268+  fi
269+
270+  "$@"
271+}
272+
273+resolve_runtime_paths() {
274+  local repo_dir="$1"
275+
276+  printf '%s\n' \
277+    "${repo_dir}/state" \
278+    "${repo_dir}/runs" \
279+    "${repo_dir}/worktrees" \
280+    "${repo_dir}/logs" \
281+    "${repo_dir}/logs/launchd" \
282+    "${repo_dir}/tmp"
283+}
284+
285+load_shared_token() {
286+  local shared_token="$1"
287+  local shared_token_file="$2"
288+
289+  if [[ -n "$shared_token_file" ]]; then
290+    assert_file "$shared_token_file"
291+    shared_token="$(tr -d '\r\n' <"$shared_token_file")"
292+  fi
293+
294+  printf '%s' "$shared_token"
295+}
A scripts/runtime/install-launchd.sh
+204, -0
  1@@ -0,0 +1,204 @@
  2+#!/usr/bin/env bash
  3+set -euo pipefail
  4+
  5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  6+# shellcheck source=./common.sh
  7+source "${SCRIPT_DIR}/common.sh"
  8+
  9+usage() {
 10+  cat <<'EOF'
 11+Usage:
 12+  scripts/runtime/install-launchd.sh [options]
 13+
 14+Options:
 15+  --node mini|mac           Select node defaults. Defaults to mini.
 16+  --scope agent|daemon      Install under LaunchAgents or LaunchDaemons. Defaults to agent.
 17+  --service NAME            Add one service to the install set. Repeatable.
 18+  --all-services            Install conductor, worker-runner, and status-api templates.
 19+  --repo-dir PATH           Repo root used for WorkingDirectory and runtime paths.
 20+  --home-dir PATH           HOME value written into plist files.
 21+  --install-dir PATH        Override launchd install directory.
 22+  --shared-token TOKEN      Shared token written into the install copy.
 23+  --shared-token-file PATH  Read the shared token from a file.
 24+  --control-api-base URL    Override BAA_CONTROL_API_BASE.
 25+  --local-api-base URL      Override BAA_CONDUCTOR_LOCAL_API.
 26+  --username NAME           UserName for LaunchDaemons. Defaults to the current user.
 27+  --help                    Show this help text.
 28+
 29+Notes:
 30+  If no service is specified, only conductor is installed. Use --all-services or
 31+  repeat --service to opt into worker-runner/status-api templates.
 32+EOF
 33+}
 34+
 35+require_command cp
 36+require_command plutil
 37+assert_file /usr/libexec/PlistBuddy
 38+
 39+node="mini"
 40+scope="agent"
 41+repo_dir="${BAA_RUNTIME_REPO_DIR_DEFAULT}"
 42+home_dir="$(default_home_dir)"
 43+install_dir=""
 44+shared_token="${BAA_SHARED_TOKEN:-}"
 45+shared_token_file=""
 46+control_api_base="${BAA_RUNTIME_DEFAULT_CONTROL_API_BASE}"
 47+local_api_base="${BAA_RUNTIME_DEFAULT_LOCAL_API}"
 48+username="$(default_username)"
 49+services=()
 50+
 51+while [[ $# -gt 0 ]]; do
 52+  case "$1" in
 53+    --node)
 54+      node="$2"
 55+      shift 2
 56+      ;;
 57+    --scope)
 58+      scope="$2"
 59+      shift 2
 60+      ;;
 61+    --service)
 62+      validate_service "$2"
 63+      if ! contains_value "$2" "${services[@]-}"; then
 64+        services+=("$2")
 65+      fi
 66+      shift 2
 67+      ;;
 68+    --all-services)
 69+      while IFS= read -r service; do
 70+        if ! contains_value "$service" "${services[@]-}"; then
 71+          services+=("$service")
 72+        fi
 73+      done < <(all_services)
 74+      shift
 75+      ;;
 76+    --repo-dir)
 77+      repo_dir="$2"
 78+      shift 2
 79+      ;;
 80+    --home-dir)
 81+      home_dir="$2"
 82+      shift 2
 83+      ;;
 84+    --install-dir)
 85+      install_dir="$2"
 86+      shift 2
 87+      ;;
 88+    --shared-token)
 89+      shared_token="$2"
 90+      shift 2
 91+      ;;
 92+    --shared-token-file)
 93+      shared_token_file="$2"
 94+      shift 2
 95+      ;;
 96+    --control-api-base)
 97+      control_api_base="$2"
 98+      shift 2
 99+      ;;
100+    --local-api-base)
101+      local_api_base="$2"
102+      shift 2
103+      ;;
104+    --username)
105+      username="$2"
106+      shift 2
107+      ;;
108+    --help)
109+      usage
110+      exit 0
111+      ;;
112+    *)
113+      die "Unknown option: $1"
114+      ;;
115+  esac
116+done
117+
118+validate_node "$node"
119+validate_scope "$scope"
120+
121+if [[ "${#services[@]}" -eq 0 ]]; then
122+  while IFS= read -r service; do
123+    services+=("$service")
124+  done < <(default_services)
125+fi
126+
127+shared_token="$(load_shared_token "$shared_token" "$shared_token_file")"
128+if [[ -z "$shared_token" ]]; then
129+  die "A shared token is required. Use --shared-token, --shared-token-file, or BAA_SHARED_TOKEN."
130+fi
131+
132+if [[ -z "$install_dir" ]]; then
133+  install_dir="$(default_install_dir "$scope" "$home_dir")"
134+fi
135+
136+set -- $(resolve_node_defaults "$node")
137+conductor_host="$1"
138+conductor_role="$2"
139+node_id="$3"
140+
141+launchd_path="$(default_launchd_path "$home_dir")"
142+state_dir="${repo_dir}/state"
143+runs_dir="${repo_dir}/runs"
144+worktrees_dir="${repo_dir}/worktrees"
145+logs_dir="${repo_dir}/logs"
146+logs_launchd_dir="${logs_dir}/launchd"
147+tmp_dir="${repo_dir}/tmp"
148+
149+assert_directory "$state_dir"
150+assert_directory "$runs_dir"
151+assert_directory "$worktrees_dir"
152+assert_directory "$logs_dir"
153+assert_directory "$logs_launchd_dir"
154+assert_directory "$tmp_dir"
155+
156+ensure_directory "$install_dir" "755"
157+
158+for service in "${services[@]}"; do
159+  template_path="$(service_template_path "$repo_dir" "$service")"
160+  install_path="$(service_install_path "$install_dir" "$service")"
161+  stdout_path="$(service_stdout_path "$logs_launchd_dir" "$service")"
162+  stderr_path="$(service_stderr_path "$logs_launchd_dir" "$service")"
163+  dist_entry="${repo_dir}/$(service_dist_entry_relative "$service")"
164+
165+  assert_file "$template_path"
166+  cp "$template_path" "$install_path"
167+
168+  plist_set_string "$install_path" ":WorkingDirectory" "$repo_dir"
169+  plist_set_string "$install_path" ":EnvironmentVariables:PATH" "$launchd_path"
170+  plist_set_string "$install_path" ":EnvironmentVariables:HOME" "$home_dir"
171+  plist_set_string "$install_path" ":EnvironmentVariables:LANG" "$BAA_RUNTIME_DEFAULT_LOCALE"
172+  plist_set_string "$install_path" ":EnvironmentVariables:LC_ALL" "$BAA_RUNTIME_DEFAULT_LOCALE"
173+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_HOST" "$conductor_host"
174+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_ROLE" "$conductor_role"
175+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONTROL_API_BASE" "$control_api_base"
176+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_CONDUCTOR_LOCAL_API" "$local_api_base"
177+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_RUNS_DIR" "$runs_dir"
178+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_WORKTREES_DIR" "$worktrees_dir"
179+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_LOGS_DIR" "$logs_dir"
180+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_TMP_DIR" "$tmp_dir"
181+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_STATE_DIR" "$state_dir"
182+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_NODE_ID" "$node_id"
183+  plist_set_string "$install_path" ":EnvironmentVariables:BAA_SHARED_TOKEN" "$shared_token"
184+  plist_set_string "$install_path" ":StandardOutPath" "$stdout_path"
185+  plist_set_string "$install_path" ":StandardErrorPath" "$stderr_path"
186+  plist_set_string "$install_path" ":ProgramArguments:2" "$dist_entry"
187+
188+  if [[ "$service" == "conductor" ]]; then
189+    plist_set_string "$install_path" ":ProgramArguments:4" "$conductor_host"
190+    plist_set_string "$install_path" ":ProgramArguments:6" "$conductor_role"
191+  fi
192+
193+  if [[ "$scope" == "daemon" ]]; then
194+    plist_set_string "$install_path" ":UserName" "$username"
195+  else
196+    plist_delete_key "$install_path" ":UserName"
197+  fi
198+
199+  chmod 644 "$install_path"
200+  plutil -lint "$install_path" >/dev/null
201+
202+  runtime_log "installed ${service} template -> ${install_path}"
203+done
204+
205+runtime_log "launchd install copies rendered for ${node} (${scope})"
A scripts/runtime/reload-launchd.sh
+137, -0
  1@@ -0,0 +1,137 @@
  2+#!/usr/bin/env bash
  3+set -euo pipefail
  4+
  5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
  6+# shellcheck source=./common.sh
  7+source "${SCRIPT_DIR}/common.sh"
  8+
  9+usage() {
 10+  cat <<'EOF'
 11+Usage:
 12+  scripts/runtime/reload-launchd.sh [options]
 13+
 14+Options:
 15+  --scope agent|daemon   launchd domain type. Defaults to agent.
 16+  --service NAME         Add one service to the reload set. Repeatable.
 17+  --all-services         Reload conductor, worker-runner, and status-api.
 18+  --home-dir PATH        Used only to derive the default LaunchAgents path.
 19+  --install-dir PATH     Override launchd install directory.
 20+  --domain TARGET        Override launchctl domain target. Defaults to gui/<uid> or system.
 21+  --skip-kickstart       Skip launchctl kickstart after bootstrap.
 22+  --dry-run              Print launchctl commands instead of executing them.
 23+  --help                 Show this help text.
 24+EOF
 25+}
 26+
 27+require_command launchctl
 28+require_command plutil
 29+
 30+scope="agent"
 31+home_dir="$(default_home_dir)"
 32+install_dir=""
 33+domain_target=""
 34+dry_run="0"
 35+skip_kickstart="0"
 36+services=()
 37+
 38+while [[ $# -gt 0 ]]; do
 39+  case "$1" in
 40+    --scope)
 41+      scope="$2"
 42+      shift 2
 43+      ;;
 44+    --service)
 45+      validate_service "$2"
 46+      if ! contains_value "$2" "${services[@]-}"; then
 47+        services+=("$2")
 48+      fi
 49+      shift 2
 50+      ;;
 51+    --all-services)
 52+      while IFS= read -r service; do
 53+        if ! contains_value "$service" "${services[@]-}"; then
 54+          services+=("$service")
 55+        fi
 56+      done < <(all_services)
 57+      shift
 58+      ;;
 59+    --home-dir)
 60+      home_dir="$2"
 61+      shift 2
 62+      ;;
 63+    --install-dir)
 64+      install_dir="$2"
 65+      shift 2
 66+      ;;
 67+    --domain)
 68+      domain_target="$2"
 69+      shift 2
 70+      ;;
 71+    --skip-kickstart)
 72+      skip_kickstart="1"
 73+      shift
 74+      ;;
 75+    --dry-run)
 76+      dry_run="1"
 77+      shift
 78+      ;;
 79+    --help)
 80+      usage
 81+      exit 0
 82+      ;;
 83+    *)
 84+      die "Unknown option: $1"
 85+      ;;
 86+  esac
 87+done
 88+
 89+validate_scope "$scope"
 90+
 91+if [[ "${#services[@]}" -eq 0 ]]; then
 92+  while IFS= read -r service; do
 93+    services+=("$service")
 94+  done < <(default_services)
 95+fi
 96+
 97+if [[ -z "$install_dir" ]]; then
 98+  install_dir="$(default_install_dir "$scope" "$home_dir")"
 99+fi
100+
101+if [[ -z "$domain_target" ]]; then
102+  domain_target="$(default_domain_target "$scope")"
103+fi
104+
105+bootout_service() {
106+  local plist_path="$1"
107+
108+  if [[ "$dry_run" == "1" ]]; then
109+    printf '+ launchctl bootout %q %q 2>/dev/null || true\n' "$domain_target" "$plist_path"
110+    return 0
111+  fi
112+
113+  launchctl bootout "$domain_target" "$plist_path" 2>/dev/null || true
114+}
115+
116+for service in "${services[@]}"; do
117+  plist_path="$(service_install_path "$install_dir" "$service")"
118+  assert_file "$plist_path"
119+  plutil -lint "$plist_path" >/dev/null
120+done
121+
122+for service in "${services[@]}"; do
123+  plist_path="$(service_install_path "$install_dir" "$service")"
124+  bootout_service "$plist_path"
125+done
126+
127+for service in "${services[@]}"; do
128+  plist_path="$(service_install_path "$install_dir" "$service")"
129+  run_or_print "$dry_run" launchctl bootstrap "$domain_target" "$plist_path"
130+done
131+
132+if [[ "$skip_kickstart" != "1" ]]; then
133+  for service in "${services[@]}"; do
134+    run_or_print "$dry_run" launchctl kickstart -k "${domain_target}/$(service_label "$service")"
135+  done
136+fi
137+
138+runtime_log "launchd reload completed for ${domain_target}"