- 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
+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 });
+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}` : ""}`);
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+}
+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-
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
+3,
-0
1@@ -0,0 +1,3 @@
2+.dev.vars*
3+!.dev.vars.example
4+.wrangler/
+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 }
+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 {
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 }
+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
+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+}
+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 }
+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+}
+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+}
+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+}
+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 {};
+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 }
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
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
+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
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
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
+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`
+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`。
+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` 的节点身份。
+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` 重载流程;它不替代各服务自己的运行时接线。这里解决的是部署路径与构建产物的一致性,而不是把所有业务进程都变成完整生产守护进程。
+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
+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 时会直接拒绝请求,而不是静默降级为匿名访问。
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 "$@"
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
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 "$@"
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>
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>
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>
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+}
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__;
+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
+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+});
+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" "$@"
+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+}
+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+}
+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" "$@"
+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}"
+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"
+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+}
+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})"
+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}"