@odla-ai/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.cjs CHANGED
@@ -28,31 +28,529 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
28
28
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
29
 
30
30
  // src/cli.ts
31
- var import_node_fs7 = require("fs");
31
+ var import_security2 = require("@odla-ai/security");
32
32
 
33
- // src/config.ts
33
+ // src/admin-ai.ts
34
+ var import_node_process3 = __toESM(require("process"), 1);
35
+ var import_node_fs2 = require("fs");
36
+ var import_db2 = require("@odla-ai/db");
37
+
38
+ // src/open.ts
39
+ var import_node_child_process = require("child_process");
40
+ var import_node_process = __toESM(require("process"), 1);
41
+ async function openUrl(url, options = {}) {
42
+ const command = openerFor(options.platform ?? import_node_process.default.platform);
43
+ const doSpawn = options.spawnImpl ?? import_node_child_process.spawn;
44
+ await new Promise((resolve7, reject) => {
45
+ const child = doSpawn(command.cmd, [...command.args, url], {
46
+ stdio: "ignore",
47
+ detached: true
48
+ });
49
+ child.once("error", reject);
50
+ child.once("spawn", () => {
51
+ child.unref();
52
+ resolve7();
53
+ });
54
+ });
55
+ }
56
+ function openerFor(platform) {
57
+ if (platform === "darwin") return { cmd: "open", args: [] };
58
+ if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
59
+ return { cmd: "xdg-open", args: [] };
60
+ }
61
+
62
+ // src/token.ts
63
+ var import_db = require("@odla-ai/db");
64
+ var import_node_process2 = __toESM(require("process"), 1);
65
+
66
+ // src/local.ts
34
67
  var import_node_fs = require("fs");
35
68
  var import_node_path = require("path");
69
+ var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
70
+ function readJsonFile(path) {
71
+ try {
72
+ return JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ function writePrivateJson(path, value) {
78
+ writePrivateText(path, `${JSON.stringify(value, null, 2)}
79
+ `);
80
+ }
81
+ function readCredentials(path) {
82
+ if (!(0, import_node_fs.existsSync)(path)) return null;
83
+ let value;
84
+ try {
85
+ value = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
86
+ } catch {
87
+ throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
88
+ }
89
+ if (!value || typeof value !== "object" || typeof value.appId !== "string" || !value.envs || typeof value.envs !== "object") {
90
+ throw new Error(`credentials file ${path} has an invalid shape; fix or remove it before provisioning`);
91
+ }
92
+ return value;
93
+ }
94
+ function mergeCredential(current, update) {
95
+ const next = current ?? {
96
+ appId: update.appId,
97
+ platformUrl: update.platformUrl,
98
+ dbEndpoint: update.dbEndpoint,
99
+ updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
100
+ envs: {}
101
+ };
102
+ next.appId = update.appId;
103
+ next.platformUrl = update.platformUrl;
104
+ next.dbEndpoint = update.dbEndpoint;
105
+ next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
106
+ next.envs[update.env] = {
107
+ ...next.envs[update.env] ?? {},
108
+ tenantId: update.tenantId,
109
+ ...update.dbKey ? { dbKey: update.dbKey } : {},
110
+ ...update.o11yToken ? { o11yToken: update.o11yToken } : {}
111
+ };
112
+ return next;
113
+ }
114
+ function ensureGitignore(rootDir, localPaths = []) {
115
+ const path = (0, import_node_path.resolve)(rootDir, ".gitignore");
116
+ const existing = (0, import_node_fs.existsSync)(path) ? (0, import_node_fs.readFileSync)(path, "utf8") : "";
117
+ const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
118
+ const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
119
+ const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
120
+ if (missing.length === 0) return;
121
+ const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
122
+ (0, import_node_fs.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
123
+ `);
124
+ }
125
+ function o11yDevVars(cfg) {
126
+ if (!cfg.services.includes("o11y")) return void 0;
127
+ return {
128
+ endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
129
+ service: cfg.o11y?.service ?? cfg.app.id,
130
+ ...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
131
+ };
132
+ }
133
+ function resolveWriteDevVarsTarget(cfg, requested) {
134
+ if (!requested) return null;
135
+ if (requested === true) return cfg.local.devVarsFile;
136
+ return (0, import_node_path.resolve)((0, import_node_path.dirname)(cfg.configPath), requested);
137
+ }
138
+ function writeDevVars(path, credentials, env, o11y) {
139
+ const entry = credentials.envs[env];
140
+ if (!entry) throw new Error(`no credentials for env "${env}" in ${path}`);
141
+ const lines = [`ODLA_PLATFORM="${credentials.platformUrl}"`];
142
+ if (entry.dbKey) lines.push(`ODLA_ENDPOINT="${credentials.dbEndpoint}"`);
143
+ lines.push(`ODLA_APP_ID="${credentials.appId}"`, `ODLA_ENV="${env}"`, `ODLA_TENANT="${entry.tenantId}"`);
144
+ if (entry.dbKey) lines.push(`ODLA_API_KEY="${entry.dbKey}"`);
145
+ if (o11y) {
146
+ lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
147
+ if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
148
+ if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
149
+ }
150
+ const existing = (0, import_node_fs.existsSync)(path) ? (0, import_node_fs.readFileSync)(path, "utf8") : "";
151
+ const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
152
+ while (retained.at(-1) === "") retained.pop();
153
+ const prefix = retained.length ? `${retained.join("\n")}
154
+
155
+ ` : "";
156
+ writePrivateText(path, `${prefix}${lines.join("\n")}
157
+ `);
158
+ }
159
+ var MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
160
+ "ODLA_PLATFORM",
161
+ "ODLA_ENDPOINT",
162
+ "ODLA_APP_ID",
163
+ "ODLA_ENV",
164
+ "ODLA_TENANT",
165
+ "ODLA_API_KEY",
166
+ "ODLA_O11Y_ENDPOINT",
167
+ "ODLA_O11Y_SERVICE",
168
+ "ODLA_O11Y_VERSION",
169
+ "ODLA_O11Y_TOKEN"
170
+ ]);
171
+ function isManagedDevVar(line) {
172
+ const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
173
+ return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
174
+ }
175
+ function writePrivateText(path, text) {
176
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
177
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
178
+ (0, import_node_fs.writeFileSync)(temporary, text, { mode: 384 });
179
+ (0, import_node_fs.chmodSync)(temporary, 384);
180
+ (0, import_node_fs.renameSync)(temporary, path);
181
+ }
182
+ function gitignoreEntry(rootDir, path) {
183
+ const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(rootDir), (0, import_node_path.resolve)(path));
184
+ if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path.isAbsolute)(rel)) return null;
185
+ return rel.replaceAll("\\", "/");
186
+ }
187
+ function displayPath(path, rootDir = process.cwd()) {
188
+ const rel = (0, import_node_path.relative)(rootDir, path);
189
+ return rel && !rel.startsWith("..") ? rel : path;
190
+ }
191
+
192
+ // src/token.ts
193
+ async function getDeveloperToken(cfg, options, doFetch, out) {
194
+ if (options.token) return options.token;
195
+ if (import_node_process2.default.env.ODLA_DEV_TOKEN) return import_node_process2.default.env.ODLA_DEV_TOKEN;
196
+ const cached = readJsonFile(cfg.local.tokenFile);
197
+ if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
198
+ out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
199
+ return cached.token;
200
+ }
201
+ const browser = approvalBrowser(options);
202
+ const { token, expiresAt } = await (0, import_db.requestToken)({
203
+ endpoint: cfg.platformUrl,
204
+ label: `${cfg.app.id} provisioner`,
205
+ fetch: doFetch,
206
+ onCode: async ({ userCode, expiresIn }) => {
207
+ const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
208
+ out.log("");
209
+ out.log(`Approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
210
+ if (browser.open) {
211
+ try {
212
+ await (options.openApprovalUrl ?? openUrl)(approvalUrl);
213
+ out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
214
+ } catch (err) {
215
+ out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
216
+ }
217
+ } else if (browser.reason) {
218
+ out.log(`auth: browser launch skipped (${browser.reason})`);
219
+ }
220
+ out.log("");
221
+ }
222
+ });
223
+ writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
224
+ out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
225
+ return token;
226
+ }
227
+ function approvalBrowser(options) {
228
+ if (options.open === true) return { open: true, mode: "forced" };
229
+ if (options.open === false) return { open: false, reason: "disabled by --no-open" };
230
+ if (import_node_process2.default.env.CI) return { open: false, reason: "CI environment" };
231
+ const interactive = options.interactive ?? Boolean(import_node_process2.default.stdin.isTTY && import_node_process2.default.stdout.isTTY);
232
+ if (!interactive) return { open: false, reason: "non-interactive shell; pass --open to force" };
233
+ return { open: true, mode: "auto" };
234
+ }
235
+ function handshakeUrl(platformUrl, userCode) {
236
+ const url = new URL("/studio", platformUrl);
237
+ url.searchParams.set("code", userCode);
238
+ return url.toString();
239
+ }
240
+
241
+ // src/admin-ai.ts
242
+ var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
243
+ async function getScopedPlatformToken(options) {
244
+ const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
245
+ if (fromEnv) return fromEnv;
246
+ return scopedToken(
247
+ options.platform.replace(/\/$/, ""),
248
+ options.scope,
249
+ { action: "show", ...options },
250
+ options.fetch ?? fetch,
251
+ options.stdout ?? console
252
+ );
253
+ }
254
+ async function adminAi(options) {
255
+ const platform = (options.platform ?? import_node_process3.default.env.ODLA_PLATFORM ?? "https://odla.ai").replace(/\/$/, "");
256
+ const doFetch = options.fetch ?? fetch;
257
+ const out = options.stdout ?? console;
258
+ const scope = options.action === "set" || options.action === "credential-set" ? "platform:ai:write" : "platform:ai:read";
259
+ const token = options.token ?? import_node_process3.default.env.ODLA_ADMIN_TOKEN ?? await scopedToken(platform, scope, options, doFetch, out);
260
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
261
+ if (options.action === "credentials") {
262
+ const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
263
+ const body2 = await responseBody(res2);
264
+ if (!res2.ok) throw new Error(apiError("read platform AI credential status", res2.status, body2));
265
+ const credentials = isRecord(body2) && isRecord(body2.credentials) ? body2.credentials : body2;
266
+ if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
267
+ else for (const [provider, status] of Object.entries(isRecord(credentials) ? credentials : {})) {
268
+ out.log(`${provider} ${isRecord(status) && status.set === true ? "set" : "not set"}`);
269
+ }
270
+ return;
271
+ }
272
+ if (options.action === "credential-set") {
273
+ const provider = requireProvider(options.credentialProvider);
274
+ const value = await credentialValue(options);
275
+ const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
276
+ method: "PUT",
277
+ headers,
278
+ body: JSON.stringify({ value })
279
+ });
280
+ const body2 = await responseBody(res2);
281
+ if (!res2.ok) throw new Error(apiError(`store ${provider} platform credential`, res2.status, body2));
282
+ out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
283
+ return;
284
+ }
285
+ if (options.action === "show") {
286
+ const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
287
+ const body2 = await responseBody(res2);
288
+ if (!res2.ok) throw new Error(apiError("read platform AI policies", res2.status, body2));
289
+ const policies = policyArray(body2);
290
+ if (options.json) {
291
+ out.log(JSON.stringify({ policies }, null, 2));
292
+ return;
293
+ }
294
+ out.log("purpose enabled provider model max calls max input bytes max output tokens");
295
+ for (const policy2 of policies) {
296
+ out.log([
297
+ policy2.purpose,
298
+ String(policy2.enabled),
299
+ policy2.provider,
300
+ policy2.model,
301
+ String(policy2.maxCallsPerRun ?? ""),
302
+ String(policy2.maxInputBytes ?? ""),
303
+ String(policy2.maxOutputTokens ?? "")
304
+ ].join(" "));
305
+ }
306
+ return;
307
+ }
308
+ const purpose = requirePurpose(options.purpose);
309
+ if (!options.provider?.trim()) throw new Error("--provider is required");
310
+ if (!options.model?.trim()) throw new Error("--model is required");
311
+ const body = {
312
+ provider: options.provider.trim(),
313
+ model: options.model.trim(),
314
+ ...options.enabled === void 0 ? {} : { enabled: options.enabled },
315
+ ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
316
+ ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
317
+ ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
318
+ };
319
+ const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
320
+ method: "PUT",
321
+ headers,
322
+ body: JSON.stringify(body)
323
+ });
324
+ const response = await responseBody(res);
325
+ if (!res.ok) throw new Error(apiError(`update ${purpose}`, res.status, response));
326
+ const policy = isRecord(response) && isRecord(response.policy) ? response.policy : isRecord(response) ? response : {};
327
+ out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? body.provider)}/${String(policy.model ?? body.model)}`);
328
+ }
329
+ async function scopedToken(platform, scope, options, doFetch, out) {
330
+ const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
331
+ const cache = readJsonFile(tokenFile);
332
+ const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
333
+ if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
334
+ out.log(`auth: using cached ${scope} grant (${tokenFile})`);
335
+ return cached.token;
336
+ }
337
+ const { token, expiresAt } = await (0, import_db2.requestToken)({
338
+ endpoint: platform,
339
+ label: `odla CLI admin AI (${scope})`,
340
+ scopes: [scope],
341
+ fetch: doFetch,
342
+ onCode: async ({ userCode, expiresIn }) => {
343
+ const approvalUrl = handshakeUrl(platform, userCode);
344
+ out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
345
+ const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
346
+ if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
347
+ }
348
+ });
349
+ const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
350
+ tokens[scope] = { token, expiresAt };
351
+ if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
352
+ writePrivateJson(tokenFile, { platform, tokens });
353
+ out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
354
+ return token;
355
+ }
356
+ function requirePurpose(value) {
357
+ if (!SYSTEM_AI_PURPOSES.includes(value)) {
358
+ throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
359
+ }
360
+ return value;
361
+ }
362
+ function requireProvider(value) {
363
+ if (value !== "anthropic" && value !== "openai" && value !== "google") {
364
+ throw new Error("provider must be one of: anthropic, openai, google");
365
+ }
366
+ return value;
367
+ }
368
+ async function credentialValue(options) {
369
+ if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
370
+ let value;
371
+ if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
372
+ else if (options.stdin) value = await (options.readStdin ?? readStdin)();
373
+ else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
374
+ value = value?.replace(/[\r\n]+$/, "");
375
+ if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
376
+ if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
377
+ return value;
378
+ }
379
+ async function readStdin() {
380
+ let value = "";
381
+ for await (const chunk of import_node_process3.default.stdin) {
382
+ value += String(chunk);
383
+ if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
384
+ }
385
+ return value;
386
+ }
387
+ function policyArray(body) {
388
+ if (isRecord(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord);
389
+ if (isRecord(body) && isRecord(body.policies)) return Object.values(body.policies).filter(isRecord);
390
+ throw new Error("platform returned an invalid AI policy response");
391
+ }
392
+ async function responseBody(res) {
393
+ const text = await res.text();
394
+ if (!text) return {};
395
+ try {
396
+ return JSON.parse(text);
397
+ } catch {
398
+ return { message: text.slice(0, 300) };
399
+ }
400
+ }
401
+ function apiError(action, status, body) {
402
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
403
+ const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
404
+ return `${action} failed (${status}): ${message}`;
405
+ }
406
+ function isRecord(value) {
407
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
408
+ }
409
+
410
+ // src/argv.ts
411
+ function parseArgv(argv) {
412
+ const positionals = [];
413
+ const options = {};
414
+ for (let i = 0; i < argv.length; i++) {
415
+ const arg = argv[i];
416
+ if (!arg.startsWith("--")) {
417
+ positionals.push(arg);
418
+ continue;
419
+ }
420
+ const eq = arg.indexOf("=");
421
+ const rawName = arg.slice(2, eq === -1 ? void 0 : eq);
422
+ if (!rawName) continue;
423
+ if (rawName.startsWith("no-")) {
424
+ options[rawName.slice(3)] = false;
425
+ continue;
426
+ }
427
+ if (eq !== -1) {
428
+ addOption(options, rawName, arg.slice(eq + 1));
429
+ continue;
430
+ }
431
+ const value = argv[i + 1];
432
+ if (value !== void 0 && !value.startsWith("--")) {
433
+ i++;
434
+ addOption(options, rawName, value);
435
+ } else {
436
+ addOption(options, rawName, true);
437
+ }
438
+ }
439
+ return { positionals, options };
440
+ }
441
+ function assertArgs(parsed, allowedOptions, maxPositionals) {
442
+ const allowed = new Set(allowedOptions);
443
+ for (const name of Object.keys(parsed.options)) {
444
+ if (!allowed.has(name)) throw new Error(`unknown option "--${name}"; run "odla-ai help" for supported options`);
445
+ }
446
+ if (parsed.positionals.length > maxPositionals) {
447
+ throw new Error(`unexpected argument "${parsed.positionals[maxPositionals]}"; run "odla-ai help"`);
448
+ }
449
+ }
450
+ function requiredString(value, name) {
451
+ const result = stringOpt(value);
452
+ if (!result) throw new Error(`${name} is required`);
453
+ return result;
454
+ }
455
+ function stringOpt(value) {
456
+ if (typeof value === "string") return value;
457
+ if (Array.isArray(value)) return value[value.length - 1];
458
+ return void 0;
459
+ }
460
+ function listOpt(value) {
461
+ if (value === void 0 || typeof value === "boolean") return void 0;
462
+ const values = Array.isArray(value) ? value : [value];
463
+ return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
464
+ }
465
+ function boolOpt(value) {
466
+ if (typeof value === "boolean") return value;
467
+ if (typeof value === "string" && (value === "true" || value === "false")) return value === "true";
468
+ if (value === void 0) return void 0;
469
+ throw new Error("boolean option must be true or false");
470
+ }
471
+ function numberOpt(value, flag) {
472
+ if (value === void 0) return void 0;
473
+ const raw = stringOpt(value);
474
+ const parsed = raw === void 0 ? Number.NaN : Number(raw);
475
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${flag} requires a positive integer`);
476
+ return parsed;
477
+ }
478
+ function addOption(options, name, value) {
479
+ const current = options[name];
480
+ if (current === void 0) options[name] = value;
481
+ else if (Array.isArray(current)) current.push(String(value));
482
+ else options[name] = [String(current), String(value)];
483
+ }
484
+
485
+ // src/capabilities.ts
486
+ var CAPABILITIES = {
487
+ cli: [
488
+ "register the app and enable configured services per environment",
489
+ "issue, persist, and explicitly rotate configured service credentials",
490
+ "write local Worker values and push deployed Worker secrets through Wrangler stdin",
491
+ "push db schema/rules and configure platform AI, auth, and deployment links",
492
+ "validate config offline and smoke-test a provisioned db environment",
493
+ "run app-attributed hosted security discovery and independent validation without provider keys",
494
+ "let admins manage system AI routes/credentials through short-lived exact-scope approval"
495
+ ],
496
+ agent: [
497
+ "install and import the selected odla SDKs",
498
+ "wrap the Worker with withObservability and choose useful telemetry",
499
+ "make application-specific schema, rules, auth, UI, and migration decisions"
500
+ ],
501
+ human: [
502
+ "approve the odla device code and one-off third-party logins",
503
+ "consent to production changes with --yes",
504
+ "request destructive credential rotation explicitly",
505
+ "review application semantics, security findings, releases, and merges",
506
+ "approve redacted-source disclosure before hosted security reasoning"
507
+ ],
508
+ studio: [
509
+ "view telemetry and environment state",
510
+ "perform manual credential recovery when the CLI's local shown-once copy is unavailable",
511
+ "configure system AI purposes and view app/environment/run-attributed usage"
512
+ ]
513
+ };
514
+ function printCapabilities(json = false, out = console) {
515
+ if (json) {
516
+ out.log(JSON.stringify(CAPABILITIES, null, 2));
517
+ return;
518
+ }
519
+ out.log("odla-ai responsibility boundary\n");
520
+ printGroup(out, "CLI automates", CAPABILITIES.cli);
521
+ printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
522
+ printGroup(out, "Human checkpoints", CAPABILITIES.human);
523
+ printGroup(out, "Studio", CAPABILITIES.studio);
524
+ }
525
+ function printGroup(out, heading, items) {
526
+ out.log(`${heading}:`);
527
+ for (const item of items) out.log(` - ${item}`);
528
+ out.log("");
529
+ }
530
+
531
+ // src/config.ts
532
+ var import_node_fs3 = require("fs");
533
+ var import_node_path2 = require("path");
36
534
  var import_node_url = require("url");
37
535
  var DEFAULT_PLATFORM = "https://odla.ai";
38
- var DEFAULT_ENVS = ["prod", "dev"];
536
+ var DEFAULT_ENVS = ["dev"];
39
537
  var DEFAULT_SERVICES = ["db", "ai"];
40
538
  async function loadProjectConfig(configPath = "odla.config.mjs") {
41
- const resolved = (0, import_node_path.resolve)(configPath);
42
- if (!(0, import_node_fs.existsSync)(resolved)) {
539
+ const resolved = (0, import_node_path2.resolve)(configPath);
540
+ if (!(0, import_node_fs3.existsSync)(resolved)) {
43
541
  throw new Error(`config not found: ${configPath}. Run "odla-ai init" first or pass --config.`);
44
542
  }
45
543
  const raw = await loadConfigModule(resolved);
46
- const rootDir = (0, import_node_path.dirname)(resolved);
544
+ const rootDir = (0, import_node_path2.dirname)(resolved);
47
545
  validateRawConfig(raw, resolved);
48
546
  const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
49
547
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
50
548
  const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
51
549
  const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
52
550
  const local = {
53
- tokenFile: (0, import_node_path.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
54
- credentialsFile: (0, import_node_path.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
55
- devVarsFile: (0, import_node_path.resolve)(rootDir, raw.local?.devVarsFile ?? ".dev.vars"),
551
+ tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
552
+ credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
553
+ devVarsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.devVarsFile ?? ".dev.vars"),
56
554
  gitignore: raw.local?.gitignore ?? true
57
555
  };
58
556
  return {
@@ -69,9 +567,9 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
69
567
  async function resolveDataExport(cfg, value, names) {
70
568
  if (value === void 0 || value === null || value === false) return void 0;
71
569
  if (typeof value !== "string") return value;
72
- const target = (0, import_node_path.isAbsolute)(value) ? value : (0, import_node_path.resolve)(cfg.rootDir, value);
570
+ const target = (0, import_node_path2.isAbsolute)(value) ? value : (0, import_node_path2.resolve)(cfg.rootDir, value);
73
571
  if (target.endsWith(".json")) {
74
- return JSON.parse((0, import_node_fs.readFileSync)(target, "utf8"));
572
+ return JSON.parse((0, import_node_fs3.readFileSync)(target, "utf8"));
75
573
  }
76
574
  const mod = await import((0, import_node_url.pathToFileURL)(target).href);
77
575
  for (const name of names) {
@@ -122,7 +620,7 @@ function validId(value) {
122
620
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
123
621
  }
124
622
  async function loadConfigModule(path) {
125
- if (path.endsWith(".json")) return JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
623
+ if (path.endsWith(".json")) return JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
126
624
  const mod = await import(`${(0, import_node_url.pathToFileURL)(path).href}?t=${Date.now()}`);
127
625
  const value = mod.default ?? mod.config;
128
626
  if (typeof value === "function") return await value();
@@ -136,9 +634,9 @@ function unique(values) {
136
634
  }
137
635
 
138
636
  // src/doctor-checks.ts
139
- var import_node_child_process2 = require("child_process");
140
- var import_node_fs3 = require("fs");
141
- var import_node_path3 = require("path");
637
+ var import_node_child_process3 = require("child_process");
638
+ var import_node_fs5 = require("fs");
639
+ var import_node_path4 = require("path");
142
640
 
143
641
  // src/redact.ts
144
642
  var REPLACEMENTS = [
@@ -160,11 +658,11 @@ function looksSecret(value) {
160
658
  }
161
659
 
162
660
  // src/wrangler.ts
163
- var import_node_child_process = require("child_process");
164
- var import_node_fs2 = require("fs");
165
- var import_node_path2 = require("path");
661
+ var import_node_child_process2 = require("child_process");
662
+ var import_node_fs4 = require("fs");
663
+ var import_node_path3 = require("path");
166
664
  var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
167
- const child = (0, import_node_child_process.spawn)(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
665
+ const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
168
666
  let stdout = "";
169
667
  let stderr = "";
170
668
  child.stdout.on("data", (chunk) => stdout += chunk.toString());
@@ -176,15 +674,15 @@ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) =>
176
674
  var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
177
675
  function findWranglerConfig(rootDir) {
178
676
  for (const name of WRANGLER_CONFIG_FILES) {
179
- const path = (0, import_node_path2.join)(rootDir, name);
180
- if ((0, import_node_fs2.existsSync)(path)) return path;
677
+ const path = (0, import_node_path3.join)(rootDir, name);
678
+ if ((0, import_node_fs4.existsSync)(path)) return path;
181
679
  }
182
680
  return null;
183
681
  }
184
682
  function readWranglerConfig(path) {
185
683
  if (path.endsWith(".toml")) return null;
186
684
  try {
187
- return JSON.parse(stripJsonComments((0, import_node_fs2.readFileSync)(path, "utf8")));
685
+ return JSON.parse(stripJsonComments((0, import_node_fs4.readFileSync)(path, "utf8")));
188
686
  } catch {
189
687
  return null;
190
688
  }
@@ -255,11 +753,12 @@ function lintRules(rules, entities, publicRead) {
255
753
  }
256
754
  return warnings;
257
755
  }
258
- var defaultExec = (cmd, args, cwd) => (0, import_node_child_process2.execFileSync)(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
259
- function trackedSecretFiles(rootDir, exec = defaultExec) {
756
+ var defaultExec = (cmd, args, cwd) => (0, import_node_child_process3.execFileSync)(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
757
+ function trackedSecretFiles(rootDir, exec = defaultExec, localPaths = []) {
260
758
  let output;
261
759
  try {
262
- output = exec("git", ["ls-files", "--", ".dev.vars", ".odla"], rootDir);
760
+ const custom = localPaths.map((path) => gitignoreEntry(rootDir, path)).filter((path) => !!path);
761
+ output = exec("git", ["ls-files", "--", ".dev.vars", ".odla", ...custom], rootDir);
263
762
  } catch {
264
763
  return [];
265
764
  }
@@ -281,10 +780,10 @@ function wranglerWarnings(rootDir) {
281
780
  for (const { label, block } of blocks) {
282
781
  const assets = block.assets;
283
782
  if (assets?.directory) {
284
- const dir = (0, import_node_path3.resolve)(rootDir, assets.directory);
285
- if (dir === (0, import_node_path3.resolve)(rootDir)) {
783
+ const dir = (0, import_node_path4.resolve)(rootDir, assets.directory);
784
+ if (dir === (0, import_node_path4.resolve)(rootDir)) {
286
785
  warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
287
- } else if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(dir, "node_modules"))) {
786
+ } else if ((0, import_node_fs5.existsSync)((0, import_node_path4.join)(dir, "node_modules"))) {
288
787
  warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
289
788
  }
290
789
  }
@@ -303,96 +802,57 @@ function wranglerWarnings(rootDir) {
303
802
  }
304
803
  return warnings;
305
804
  }
306
- function readPackageJson(rootDir) {
307
- try {
308
- return JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.join)(rootDir, "package.json"), "utf8"));
309
- } catch {
310
- return null;
805
+ function o11yProjectWarnings(rootDir) {
806
+ const warnings = [];
807
+ const pkg = readPackageJson(rootDir);
808
+ const dependencies = pkg?.dependencies;
809
+ const devDependencies = pkg?.devDependencies;
810
+ if (!dependencies?.["@odla-ai/o11y"]) {
811
+ warnings.push(
812
+ devDependencies?.["@odla-ai/o11y"] ? "@odla-ai/o11y is a devDependency \u2014 move it to dependencies so the Worker can import it" : 'Worker instrumentation is missing @odla-ai/o11y \u2014 install it and wrap the handler with "withObservability"'
813
+ );
311
814
  }
815
+ const configPath = findWranglerConfig(rootDir);
816
+ const config = configPath ? readWranglerConfig(configPath) : null;
817
+ if (!configPath || !config) {
818
+ warnings.push("cannot verify o11y Worker instrumentation \u2014 add a parseable wrangler.jsonc/json config");
819
+ return warnings;
820
+ }
821
+ const main = typeof config.main === "string" ? (0, import_node_path4.resolve)(rootDir, config.main) : null;
822
+ if (!main || !(0, import_node_fs5.existsSync)(main)) {
823
+ warnings.push("cannot verify o11y Worker instrumentation \u2014 wrangler main is missing or unreadable");
824
+ } else {
825
+ let source = "";
826
+ try {
827
+ source = (0, import_node_fs5.readFileSync)(main, "utf8");
828
+ } catch {
829
+ }
830
+ if (!/\bwithObservability\b/.test(source)) {
831
+ warnings.push(`wrangler entrypoint ${config.main} does not reference withObservability`);
832
+ }
833
+ }
834
+ const flags = Array.isArray(config.compatibility_flags) ? config.compatibility_flags : [];
835
+ if (!flags.includes("nodejs_compat")) {
836
+ warnings.push('wrangler compatibility_flags is missing "nodejs_compat" required by @odla-ai/o11y');
837
+ }
838
+ return warnings;
312
839
  }
313
-
314
- // src/local.ts
315
- var import_node_fs4 = require("fs");
316
- var import_node_path4 = require("path");
317
- var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
318
- function readJsonFile(path) {
840
+ function readPackageJson(rootDir) {
319
841
  try {
320
- return JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
842
+ return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
321
843
  } catch {
322
844
  return null;
323
845
  }
324
846
  }
325
- function writePrivateJson(path, value) {
326
- (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(path), { recursive: true });
327
- (0, import_node_fs4.writeFileSync)(path, `${JSON.stringify(value, null, 2)}
328
- `);
329
- (0, import_node_fs4.chmodSync)(path, 384);
330
- }
331
- function readCredentials(path) {
332
- return readJsonFile(path);
333
- }
334
- function mergeCredential(current, update) {
335
- const next = current ?? {
336
- appId: update.appId,
337
- platformUrl: update.platformUrl,
338
- dbEndpoint: update.dbEndpoint,
339
- updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
340
- envs: {}
341
- };
342
- next.appId = update.appId;
343
- next.platformUrl = update.platformUrl;
344
- next.dbEndpoint = update.dbEndpoint;
345
- next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
346
- next.envs[update.env] = {
347
- ...next.envs[update.env] ?? {},
348
- tenantId: update.tenantId,
349
- ...update.dbKey ? { dbKey: update.dbKey } : {},
350
- ...update.o11yToken ? { o11yToken: update.o11yToken } : {}
351
- };
352
- return next;
353
- }
354
- function ensureGitignore(rootDir) {
355
- const path = (0, import_node_path4.resolve)(rootDir, ".gitignore");
356
- const existing = (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : "";
357
- const missing = GITIGNORE_LINES.filter((line) => !existing.split(/\r?\n/).includes(line));
358
- if (missing.length === 0) return;
359
- const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
360
- (0, import_node_fs4.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
361
- `);
362
- }
363
- function writeDevVars(path, credentials, env, o11y) {
364
- const entry = credentials.envs[env];
365
- if (!entry?.dbKey) throw new Error(`no db key for env "${env}" in ${path}`);
366
- const lines = [
367
- `ODLA_PLATFORM="${credentials.platformUrl}"`,
368
- `ODLA_ENDPOINT="${credentials.dbEndpoint}"`,
369
- `ODLA_APP_ID="${credentials.appId}"`,
370
- `ODLA_ENV="${env}"`,
371
- `ODLA_TENANT="${entry.tenantId}"`,
372
- `ODLA_API_KEY="${entry.dbKey}"`
373
- ];
374
- if (o11y) {
375
- lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
376
- if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
377
- if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
378
- }
379
- (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(path), { recursive: true });
380
- (0, import_node_fs4.writeFileSync)(path, `${lines.join("\n")}
381
- `);
382
- (0, import_node_fs4.chmodSync)(path, 384);
383
- }
384
- function displayPath(path, rootDir = process.cwd()) {
385
- const rel = (0, import_node_path4.relative)(rootDir, path);
386
- return rel && !rel.startsWith("..") ? rel : path;
387
- }
388
847
 
389
848
  // src/doctor.ts
390
849
  async function doctor(options) {
391
850
  const out = options.stdout ?? console;
392
851
  const cfg = await loadProjectConfig(options.configPath);
393
852
  const plan = buildPlan(cfg);
394
- const schema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
395
- const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
853
+ const hasDb = cfg.services.includes("db");
854
+ const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
855
+ const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
396
856
  const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
397
857
  const entities = serializedEntities(schema);
398
858
  out.log(`config: ${cfg.configPath}`);
@@ -401,7 +861,7 @@ async function doctor(options) {
401
861
  out.log(`svc: ${plan.services.join(", ")}`);
402
862
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
403
863
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
404
- out.log(`ai: ${cfg.ai?.provider ?? "not configured"}`);
864
+ out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
405
865
  const warnings = [];
406
866
  if (schema && entities.length === 0) warnings.push("schema has no entities");
407
867
  if (rules) {
@@ -417,7 +877,9 @@ async function doctor(options) {
417
877
  }
418
878
  }
419
879
  }
420
- if (cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv]) warnings.push(`${cfg.ai.keyEnv} is not set; provision will skip provider key storage`);
880
+ if (cfg.services.includes("ai") && cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv]) {
881
+ warnings.push(`${cfg.ai.keyEnv} is not set; provision will skip provider key storage`);
882
+ }
421
883
  if (cfg.services.includes("o11y")) {
422
884
  const credentials = readCredentials(cfg.local.credentialsFile);
423
885
  for (const env of cfg.envs) {
@@ -425,9 +887,14 @@ async function doctor(options) {
425
887
  warnings.push(`o11y is enabled but env "${env}" has no ingest token \u2014 run "odla-ai provision" to mint one`);
426
888
  }
427
889
  }
890
+ warnings.push(...o11yProjectWarnings(cfg.rootDir));
428
891
  }
429
892
  warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
430
- warnings.push(...trackedSecretFiles(cfg.rootDir));
893
+ warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
894
+ cfg.local.tokenFile,
895
+ cfg.local.credentialsFile,
896
+ cfg.local.devVarsFile
897
+ ]));
431
898
  warnings.push(...wranglerWarnings(cfg.rootDir));
432
899
  if (warnings.length) {
433
900
  out.log("");
@@ -439,25 +906,25 @@ async function doctor(options) {
439
906
  }
440
907
 
441
908
  // src/init.ts
442
- var import_node_fs5 = require("fs");
909
+ var import_node_fs6 = require("fs");
443
910
  var import_node_path5 = require("path");
444
911
  function initProject(options) {
445
912
  const out = options.stdout ?? console;
446
913
  const rootDir = (0, import_node_path5.resolve)(options.rootDir ?? process.cwd());
447
914
  const configPath = (0, import_node_path5.resolve)(rootDir, options.configPath ?? "odla.config.mjs");
448
- if ((0, import_node_fs5.existsSync)(configPath) && !options.force) {
915
+ if ((0, import_node_fs6.existsSync)(configPath) && !options.force) {
449
916
  throw new Error(`${configPath} already exists. Pass --force to overwrite.`);
450
917
  }
451
918
  if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
452
919
  throw new Error("--app-id must be lowercase letters, numbers, and hyphens");
453
920
  }
454
- const envs = options.envs?.length ? options.envs : ["prod", "dev"];
921
+ const envs = options.envs?.length ? options.envs : ["dev"];
455
922
  const services = options.services?.length ? options.services : ["db", "ai"];
456
923
  const aiProvider = options.aiProvider ?? "anthropic";
457
- (0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
458
- (0, import_node_fs5.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
459
- (0, import_node_fs5.mkdirSync)((0, import_node_path5.resolve)(rootDir, ".odla"), { recursive: true });
460
- (0, import_node_fs5.writeFileSync)(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
924
+ (0, import_node_fs6.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
925
+ (0, import_node_fs6.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
926
+ (0, import_node_fs6.mkdirSync)((0, import_node_path5.resolve)(rootDir, ".odla"), { recursive: true });
927
+ (0, import_node_fs6.writeFileSync)(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
461
928
  writeIfMissing((0, import_node_path5.resolve)(rootDir, "src/odla/schema.mjs"), schemaTemplate());
462
929
  writeIfMissing((0, import_node_path5.resolve)(rootDir, "src/odla/rules.mjs"), rulesTemplate());
463
930
  ensureGitignore(rootDir);
@@ -466,8 +933,8 @@ function initProject(options) {
466
933
  out.log("updated .gitignore for local odla credentials");
467
934
  }
468
935
  function writeIfMissing(path, text) {
469
- if ((0, import_node_fs5.existsSync)(path)) return;
470
- (0, import_node_fs5.writeFileSync)(path, text);
936
+ if ((0, import_node_fs6.existsSync)(path)) return;
937
+ (0, import_node_fs6.writeFileSync)(path, text);
471
938
  }
472
939
  function configTemplate(input) {
473
940
  return `export default {
@@ -554,86 +1021,176 @@ function relativeDisplay(path, rootDir) {
554
1021
  }
555
1022
 
556
1023
  // src/provision.ts
557
- var import_apps = require("@odla-ai/apps");
1024
+ var import_apps2 = require("@odla-ai/apps");
558
1025
  var import_ai = require("@odla-ai/ai");
559
- var import_node_path6 = require("path");
560
- var import_node_process3 = __toESM(require("process"), 1);
1026
+ var import_node_process4 = __toESM(require("process"), 1);
561
1027
 
562
- // src/token.ts
563
- var import_db = require("@odla-ai/db");
564
- var import_node_process2 = __toESM(require("process"), 1);
565
-
566
- // src/open.ts
567
- var import_node_child_process3 = require("child_process");
568
- var import_node_process = __toESM(require("process"), 1);
569
- async function openUrl(url, options = {}) {
570
- const command = openerFor(options.platform ?? import_node_process.default.platform);
571
- const doSpawn = options.spawnImpl ?? import_node_child_process3.spawn;
572
- await new Promise((resolve7, reject) => {
573
- const child = doSpawn(command.cmd, [...command.args, url], {
574
- stdio: "ignore",
575
- detached: true
1028
+ // src/provision-credentials.ts
1029
+ var import_apps = require("@odla-ai/apps");
1030
+ async function provisionEnvCredentials(opts) {
1031
+ const tenantId = (0, import_apps.tenantIdFor)(opts.cfg.app.id, opts.env);
1032
+ const prior = opts.credentials?.envs[opts.env];
1033
+ let credentials = opts.credentials;
1034
+ let dbKey = opts.cfg.services.includes("db") && !opts.rotateDb ? prior?.dbKey : void 0;
1035
+ let o11yToken = opts.cfg.services.includes("o11y") && !opts.rotateO11y ? prior?.o11yToken : void 0;
1036
+ if (opts.cfg.services.includes("db")) {
1037
+ if (dbKey) {
1038
+ opts.stdout.log(`${opts.env}: reusing local db key for ${tenantId}`);
1039
+ } else {
1040
+ dbKey = await mintDbKey(opts, tenantId);
1041
+ credentials = save(opts, credentials, tenantId, { dbKey });
1042
+ opts.stdout.log(`${opts.env}: minted db key for ${tenantId}`);
1043
+ }
1044
+ }
1045
+ if (opts.cfg.services.includes("o11y")) {
1046
+ if (o11yToken) {
1047
+ opts.stdout.log(`${opts.env}: reusing local o11y ingest token`);
1048
+ } else {
1049
+ o11yToken = await issueO11yToken(opts);
1050
+ credentials = save(opts, credentials, tenantId, { ...dbKey ? { dbKey } : {}, o11yToken });
1051
+ opts.stdout.log(`${opts.env}: ${opts.rotateO11y ? "rotated" : "issued"} o11y ingest token`);
1052
+ }
1053
+ }
1054
+ return save(opts, credentials, tenantId, {
1055
+ ...dbKey ? { dbKey } : {},
1056
+ ...o11yToken ? { o11yToken } : {}
1057
+ });
1058
+ }
1059
+ function save(opts, current, tenantId, values) {
1060
+ const next = mergeCredential(current, {
1061
+ appId: opts.cfg.app.id,
1062
+ platformUrl: opts.cfg.platformUrl,
1063
+ dbEndpoint: opts.cfg.dbEndpoint,
1064
+ env: opts.env,
1065
+ tenantId,
1066
+ ...values
1067
+ });
1068
+ if (opts.write) writePrivateJson(opts.cfg.local.credentialsFile, next);
1069
+ return next;
1070
+ }
1071
+ async function mintDbKey(opts, tenantId) {
1072
+ const headers = { authorization: `Bearer ${opts.developerToken}`, "content-type": "application/json" };
1073
+ let res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1074
+ method: "POST",
1075
+ headers,
1076
+ body: "{}"
1077
+ });
1078
+ if (res.status === 404) {
1079
+ const created = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps`, {
1080
+ method: "POST",
1081
+ headers,
1082
+ body: JSON.stringify({
1083
+ name: opts.env === "prod" ? opts.cfg.app.name : `${opts.cfg.app.name} (${opts.env})`,
1084
+ appId: tenantId
1085
+ })
576
1086
  });
577
- child.once("error", reject);
578
- child.once("spawn", () => {
579
- child.unref();
580
- resolve7();
1087
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
1088
+ res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1089
+ method: "POST",
1090
+ headers,
1091
+ body: "{}"
581
1092
  });
582
- });
1093
+ }
1094
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
1095
+ const body = await res.json();
1096
+ if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
1097
+ return body.key;
583
1098
  }
584
- function openerFor(platform) {
585
- if (platform === "darwin") return { cmd: "open", args: [] };
586
- if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
587
- return { cmd: "xdg-open", args: [] };
1099
+ async function issueO11yToken(opts) {
1100
+ const suffix = opts.rotateO11y ? "/rotate" : "";
1101
+ const res = await opts.fetch(
1102
+ `${opts.cfg.platformUrl}/o11y/${encodeURIComponent(opts.cfg.app.id)}/token${suffix}?env=${encodeURIComponent(opts.env)}`,
1103
+ { method: "POST", headers: { authorization: `Bearer ${opts.developerToken}` } }
1104
+ );
1105
+ if (res.status === 409 && !opts.rotateO11y) {
1106
+ throw new Error(
1107
+ `o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
1108
+ );
1109
+ }
1110
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
1111
+ const body = await res.json();
1112
+ if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
1113
+ return body.token;
1114
+ }
1115
+ async function safeText(res) {
1116
+ try {
1117
+ return redactSecrets((await res.text()).slice(0, 500));
1118
+ } catch {
1119
+ return "";
1120
+ }
588
1121
  }
589
1122
 
590
- // src/token.ts
591
- async function getDeveloperToken(cfg, options, doFetch, out) {
592
- if (options.token) return options.token;
593
- if (import_node_process2.default.env.ODLA_DEV_TOKEN) return import_node_process2.default.env.ODLA_DEV_TOKEN;
594
- const cached = readJsonFile(cfg.local.tokenFile);
595
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
596
- out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
597
- return cached.token;
1123
+ // src/secrets.ts
1124
+ var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
1125
+ async function secretsPush(options) {
1126
+ await secretsPushImpl(options, true);
1127
+ }
1128
+ async function secretsPushAfterPreflight(options) {
1129
+ await secretsPushImpl(options, false);
1130
+ }
1131
+ async function secretsPushImpl(options, preflight) {
1132
+ const out = options.stdout ?? console;
1133
+ const cfg = await loadProjectConfig(options.configPath);
1134
+ const env = options.env;
1135
+ if (!cfg.envs.includes(env)) {
1136
+ throw new Error(`env "${env}" is not in config envs (${cfg.envs.join(", ")})`);
598
1137
  }
599
- const browser = approvalBrowser(options);
600
- const { token, expiresAt } = await (0, import_db.requestToken)({
601
- endpoint: cfg.platformUrl,
602
- label: `${cfg.app.id} provisioner`,
603
- fetch: doFetch,
604
- onCode: async ({ userCode, expiresIn }) => {
605
- const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
606
- out.log("");
607
- out.log(`Approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
608
- if (browser.open) {
609
- try {
610
- await (options.openApprovalUrl ?? openUrl)(approvalUrl);
611
- out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
612
- } catch (err) {
613
- out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
614
- }
615
- } else if (browser.reason) {
616
- out.log(`auth: browser launch skipped (${browser.reason})`);
617
- }
618
- out.log("");
1138
+ if (PROD_ENV_NAMES.has(env) && !options.yes) {
1139
+ throw new Error(`refusing to push a secret to "${env}" without --yes`);
1140
+ }
1141
+ const credentialsPath = displayPath(cfg.local.credentialsFile, cfg.rootDir);
1142
+ const credentials = readCredentials(cfg.local.credentialsFile);
1143
+ if (!credentials) throw new Error(`no credentials at ${credentialsPath} \u2014 run "odla-ai provision" first`);
1144
+ if (credentials.appId !== cfg.app.id) {
1145
+ throw new Error(`credentials at ${credentialsPath} are for "${credentials.appId}", not "${cfg.app.id}"`);
1146
+ }
1147
+ const entry = credentials.envs[env];
1148
+ if (!entry) throw new Error(`no credentials for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
1149
+ const secrets = [];
1150
+ if (cfg.services.includes("o11y")) {
1151
+ if (!entry.o11yToken) throw new Error(`no o11y token for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
1152
+ secrets.push({ name: "ODLA_O11Y_TOKEN", value: entry.o11yToken });
1153
+ }
1154
+ if (cfg.services.includes("db")) {
1155
+ if (!entry.dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
1156
+ secrets.push({ name: "ODLA_API_KEY", value: entry.dbKey });
1157
+ }
1158
+ if (secrets.length === 0) {
1159
+ out.log(`${env}: no configured Worker secrets to push`);
1160
+ return;
1161
+ }
1162
+ const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
1163
+ const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
1164
+ if (options.dryRun) {
1165
+ assertWranglerConfig(cfg);
1166
+ for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
1167
+ return;
1168
+ }
1169
+ const run = options.runner ?? defaultRunner;
1170
+ if (preflight) await preflightLoadedConfig(cfg, run);
1171
+ for (const s of secrets) {
1172
+ const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
1173
+ if (result.code !== 0) {
1174
+ throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
619
1175
  }
620
- });
621
- writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
622
- out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
623
- return token;
1176
+ out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
1177
+ }
624
1178
  }
625
- function approvalBrowser(options) {
626
- if (options.open === true) return { open: true, mode: "forced" };
627
- if (options.open === false) return { open: false, reason: "disabled by --no-open" };
628
- if (import_node_process2.default.env.CI) return { open: false, reason: "CI environment" };
629
- const interactive = options.interactive ?? Boolean(import_node_process2.default.stdin.isTTY && import_node_process2.default.stdout.isTTY);
630
- if (!interactive) return { open: false, reason: "non-interactive shell; pass --open to force" };
631
- return { open: true, mode: "auto" };
1179
+ async function preflightSecretsPush(options) {
1180
+ const cfg = await loadProjectConfig(options.configPath);
1181
+ if (!cfg.services.some((service) => service === "db" || service === "o11y")) return;
1182
+ await preflightLoadedConfig(cfg, options.runner ?? defaultRunner);
632
1183
  }
633
- function handshakeUrl(platformUrl, userCode) {
634
- const url = new URL("/handshakes", platformUrl);
635
- url.searchParams.set("code", userCode);
636
- return url.toString();
1184
+ async function preflightLoadedConfig(cfg, run) {
1185
+ assertWranglerConfig(cfg);
1186
+ if (!await wranglerLoggedIn(run, cfg.rootDir)) {
1187
+ throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
1188
+ }
1189
+ }
1190
+ function assertWranglerConfig(cfg) {
1191
+ if (!findWranglerConfig(cfg.rootDir)) {
1192
+ throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
1193
+ }
637
1194
  }
638
1195
 
639
1196
  // src/provision.ts
@@ -641,25 +1198,60 @@ async function provision(options) {
641
1198
  const out = options.stdout ?? console;
642
1199
  const cfg = await loadProjectConfig(options.configPath);
643
1200
  const plan = buildPlan(cfg);
1201
+ const hasDb = cfg.services.includes("db");
1202
+ const hasO11y = cfg.services.includes("o11y");
1203
+ if (options.rotateO11yToken && !hasO11y) {
1204
+ throw new Error("--rotate-o11y-token requires the o11y service in odla.config.mjs");
1205
+ }
1206
+ if (options.pushSecrets && options.writeCredentials === false) {
1207
+ throw new Error("--push-secrets cannot be combined with --no-write-credentials");
1208
+ }
644
1209
  out.log(`odla-ai: ${plan.appName} (${plan.appId})`);
645
1210
  out.log(` platform: ${plan.platformUrl}`);
646
1211
  out.log(` db: ${plan.dbEndpoint}`);
647
1212
  out.log(` envs: ${plan.envs.join(", ")}`);
648
1213
  out.log(` services: ${plan.services.join(", ")}`);
649
- const schema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
650
- const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
1214
+ const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
1215
+ if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
1216
+ throw new Error(
1217
+ `refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
1218
+ );
1219
+ }
1220
+ const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
1221
+ const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
651
1222
  const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
652
1223
  if (options.dryRun) {
653
1224
  out.log("dry run: no network calls or file writes");
654
1225
  out.log(` schema: ${schema ? "yes" : "no"}`);
655
1226
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
656
- out.log(` ai: ${cfg.ai?.provider ?? "not configured"}`);
1227
+ out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1228
+ out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
657
1229
  return;
658
1230
  }
1231
+ let credentials = readCredentials(cfg.local.credentialsFile);
1232
+ if (credentials && credentials.appId !== cfg.app.id) {
1233
+ throw new Error(
1234
+ `credentials at ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} are for "${credentials.appId}", not "${cfg.app.id}"`
1235
+ );
1236
+ }
1237
+ const rotatesO11y = !!(options.rotateKeys || options.rotateO11yToken);
1238
+ const missingO11y = cfg.envs.some((env) => !credentials?.envs[env]?.o11yToken);
1239
+ const missingDb = cfg.envs.some((env) => !credentials?.envs[env]?.dbKey);
1240
+ const losesShownOnceCredential = hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y);
1241
+ if (options.writeCredentials === false && losesShownOnceCredential) {
1242
+ throw new Error("credential issuance/rotation requires the private credentials file; remove --no-write-credentials");
1243
+ }
1244
+ const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
1245
+ if (cfg.local.gitignore) {
1246
+ ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
1247
+ }
1248
+ if (options.pushSecrets) {
1249
+ await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
1250
+ out.log("secrets: Wrangler config and login preflight passed");
1251
+ }
659
1252
  const doFetch = options.fetch ?? fetch;
660
1253
  const token = await getDeveloperToken(cfg, options, doFetch, out);
661
- const auth = { authorization: `Bearer ${token}`, "content-type": "application/json" };
662
- const apps = (0, import_apps.createAppsClient)({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
1254
+ const apps = (0, import_apps2.createAppsClient)({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
663
1255
  const existing = await readRegistryApp(cfg, token, doFetch);
664
1256
  if (existing) {
665
1257
  out.log(`app: ${cfg.app.id} already exists`);
@@ -693,26 +1285,40 @@ async function provision(options) {
693
1285
  out.log(`${env}: link ${link ? "set" : "cleared"}`);
694
1286
  }
695
1287
  }
696
- let credentials = readCredentials(cfg.local.credentialsFile);
697
1288
  for (const env of cfg.envs) {
698
- const tenantId = (0, import_apps.tenantIdFor)(cfg.app.id, env);
699
- let dbKey = !options.rotateKeys ? credentials?.envs[env]?.dbKey : void 0;
700
- if (dbKey) {
701
- out.log(`${env}: reusing local db key for ${tenantId}`);
702
- } else {
703
- dbKey = await mintDbKey({ cfg, tenantId, env, token, auth, fetch: doFetch });
704
- out.log(`${env}: minted db key for ${tenantId}`);
705
- }
706
- let o11yToken = cfg.services.includes("o11y") && !options.rotateKeys ? credentials?.envs[env]?.o11yToken : void 0;
707
- if (cfg.services.includes("o11y")) {
708
- if (o11yToken) {
709
- out.log(`${env}: reusing local o11y ingest token`);
710
- } else {
711
- o11yToken = await mintO11yToken(cfg, env, token, doFetch);
712
- out.log(`${env}: minted o11y ingest token`);
1289
+ const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
1290
+ credentials = await provisionEnvCredentials({
1291
+ cfg,
1292
+ env,
1293
+ developerToken: token,
1294
+ credentials,
1295
+ rotateDb: !!options.rotateKeys,
1296
+ rotateO11y: rotatesO11y,
1297
+ write: options.writeCredentials !== false,
1298
+ fetch: doFetch,
1299
+ stdout: out
1300
+ });
1301
+ const dbKey = credentials.envs[env]?.dbKey;
1302
+ if (options.pushSecrets) {
1303
+ try {
1304
+ await secretsPushAfterPreflight({
1305
+ configPath: cfg.configPath,
1306
+ env,
1307
+ yes: options.yes,
1308
+ runner: options.secretRunner,
1309
+ stdout: out
1310
+ });
1311
+ } catch (error) {
1312
+ const message = error instanceof Error ? error.message : String(error);
1313
+ const consent = env === "prod" || env === "production" ? " --yes" : "";
1314
+ throw new Error(
1315
+ `${message}
1316
+ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}${consent}" without issuing or rotating again`,
1317
+ { cause: error }
1318
+ );
713
1319
  }
714
1320
  }
715
- if (schema) {
1321
+ if (schema && dbKey) {
716
1322
  await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
717
1323
  out.log(`${env}: schema pushed`);
718
1324
  }
@@ -720,8 +1326,8 @@ async function provision(options) {
720
1326
  await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
721
1327
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
722
1328
  }
723
- if (cfg.ai?.provider && cfg.ai.keyEnv) {
724
- const key = import_node_process3.default.env[cfg.ai.keyEnv];
1329
+ if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
1330
+ const key = import_node_process4.default.env[cfg.ai.keyEnv];
725
1331
  if (key) {
726
1332
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
727
1333
  await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
@@ -730,36 +1336,17 @@ async function provision(options) {
730
1336
  out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
731
1337
  }
732
1338
  }
733
- credentials = mergeCredential(credentials, {
734
- appId: cfg.app.id,
735
- platformUrl: cfg.platformUrl,
736
- dbEndpoint: cfg.dbEndpoint,
737
- env,
738
- tenantId,
739
- dbKey,
740
- ...o11yToken ? { o11yToken } : {}
741
- });
742
1339
  }
743
- if (cfg.local.gitignore) ensureGitignore(cfg.rootDir);
744
1340
  if (options.writeCredentials !== false && credentials) {
745
- writePrivateJson(cfg.local.credentialsFile, credentials);
746
- out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600, gitignored)`);
1341
+ const ignored = cfg.local.gitignore && gitignoreEntry(cfg.rootDir, cfg.local.credentialsFile);
1342
+ out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600${ignored ? ", gitignored" : ""})`);
747
1343
  }
748
- const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
749
1344
  if (devVarsTarget && credentials) {
750
1345
  const env = cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
751
1346
  writeDevVars(devVarsTarget, credentials, env, o11yDevVars(cfg));
752
1347
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
753
1348
  }
754
1349
  }
755
- function o11yDevVars(cfg) {
756
- if (!cfg.services.includes("o11y")) return void 0;
757
- return {
758
- endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
759
- service: cfg.o11y?.service ?? cfg.app.id,
760
- ...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
761
- };
762
- }
763
1350
  async function readRegistryApp(cfg, token, doFetch) {
764
1351
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
765
1352
  headers: { authorization: `Bearer ${token}` }
@@ -769,50 +1356,13 @@ async function readRegistryApp(cfg, token, doFetch) {
769
1356
  const json = await res.json();
770
1357
  return json.app ?? null;
771
1358
  }
772
- async function mintDbKey(opts) {
773
- let res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(opts.tenantId)}/keys`, {
774
- method: "POST",
775
- headers: opts.auth,
776
- body: "{}"
777
- });
778
- if (res.status === 404) {
779
- const created = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps`, {
780
- method: "POST",
781
- headers: opts.auth,
782
- body: JSON.stringify({
783
- name: opts.env === "prod" ? opts.cfg.app.name : `${opts.cfg.app.name} (${opts.env})`,
784
- appId: opts.tenantId
785
- })
786
- });
787
- if (!created.ok) throw new Error(`db app create (${opts.tenantId}) failed: ${created.status} ${await safeText(created)}`);
788
- res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(opts.tenantId)}/keys`, {
789
- method: "POST",
790
- headers: opts.auth,
791
- body: "{}"
792
- });
793
- }
794
- if (!res.ok) throw new Error(`db key mint (${opts.tenantId}) failed: ${res.status} ${await safeText(res)}`);
795
- const body = await res.json();
796
- if (!body.key) throw new Error(`db key mint (${opts.tenantId}) returned no key`);
797
- return body.key;
798
- }
799
- async function mintO11yToken(cfg, env, token, doFetch) {
800
- const res = await doFetch(
801
- `${cfg.platformUrl}/o11y/${encodeURIComponent(cfg.app.id)}/token?env=${encodeURIComponent(env)}`,
802
- { method: "POST", headers: { authorization: `Bearer ${token}` } }
803
- );
804
- if (!res.ok) throw new Error(`o11y token mint (${env}) failed: ${res.status} ${await safeText(res)}`);
805
- const body = await res.json();
806
- if (!body.token) throw new Error(`o11y token mint (${env}) returned no token`);
807
- return body.token;
808
- }
809
1359
  async function postJson(doFetch, url, bearer, body) {
810
1360
  const res = await doFetch(url, {
811
1361
  method: "POST",
812
1362
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
813
1363
  body: JSON.stringify(body)
814
1364
  });
815
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText(res)}`);
1365
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
816
1366
  }
817
1367
  function normalizeClerkConfig(value) {
818
1368
  if (!value) return null;
@@ -829,12 +1379,7 @@ function defaultSecretName(provider) {
829
1379
  const names = import_ai.DEFAULT_SECRET_NAMES;
830
1380
  return names[provider] ?? `${provider}_api_key`;
831
1381
  }
832
- function resolveWriteDevVarsTarget(cfg, requested) {
833
- if (!requested) return null;
834
- if (requested === true) return cfg.local.devVarsFile;
835
- return (0, import_node_path6.resolve)((0, import_node_path6.dirname)(cfg.configPath), requested);
836
- }
837
- async function safeText(res) {
1382
+ async function safeText2(res) {
838
1383
  try {
839
1384
  return redactSecrets((await res.text()).slice(0, 500));
840
1385
  } catch {
@@ -842,103 +1387,401 @@ async function safeText(res) {
842
1387
  }
843
1388
  }
844
1389
 
845
- // src/secrets.ts
846
- var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
847
- async function secretsPush(options) {
848
- const out = options.stdout ?? console;
849
- const cfg = await loadProjectConfig(options.configPath);
850
- const env = options.env;
851
- if (!cfg.envs.includes(env)) {
852
- throw new Error(`env "${env}" is not in config envs (${cfg.envs.join(", ")})`);
853
- }
854
- if (PROD_ENV_NAMES.has(env) && !options.yes) {
855
- throw new Error(`refusing to push a secret to "${env}" without --yes`);
856
- }
857
- const credentialsPath = displayPath(cfg.local.credentialsFile, cfg.rootDir);
858
- const credentials = readCredentials(cfg.local.credentialsFile);
859
- if (!credentials) throw new Error(`no credentials at ${credentialsPath} \u2014 run "odla-ai provision" first`);
860
- if (credentials.appId !== cfg.app.id) {
861
- throw new Error(`credentials at ${credentialsPath} are for "${credentials.appId}", not "${cfg.app.id}"`);
862
- }
863
- const dbKey = credentials.envs[env]?.dbKey;
864
- if (!dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
865
- const wranglerConfig = findWranglerConfig(cfg.rootDir);
866
- if (!wranglerConfig) {
867
- throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
868
- }
869
- const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
870
- const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
871
- const secrets = [{ name: "ODLA_API_KEY", value: dbKey }];
872
- const o11yToken = credentials.envs[env]?.o11yToken;
873
- if (o11yToken) secrets.push({ name: "ODLA_O11Y_TOKEN", value: o11yToken });
874
- if (options.dryRun) {
875
- for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
876
- return;
877
- }
878
- const run = options.runner ?? defaultRunner;
879
- if (!await wranglerLoggedIn(run, cfg.rootDir)) {
880
- throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
1390
+ // src/security.ts
1391
+ var import_node_path6 = require("path");
1392
+ var import_security = require("@odla-ai/security");
1393
+ var import_node = require("@odla-ai/security/node");
1394
+ async function runHostedSecurity(options) {
1395
+ if (options.sourceDisclosureAck !== "redacted") {
1396
+ throw new Error("Hosted security requires sourceDisclosureAck=redacted before repository source may leave this process");
881
1397
  }
882
- for (const s of secrets) {
883
- const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
884
- if (result.code !== 0) {
885
- throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
1398
+ const selfAudit = options.selfAudit === true;
1399
+ const cfg = selfAudit ? void 0 : await loadProjectConfig(options.configPath ?? "odla.config.mjs");
1400
+ const appId = selfAudit ? "odla-ai" : cfg.app.id;
1401
+ const env = selfAudit ? "prod" : selectEnv(options.env, cfg.envs, cfg.configPath, cfg.rootDir);
1402
+ const platform = options.platform ?? cfg?.platformUrl ?? "https://odla.ai";
1403
+ const target = (0, import_node_path6.resolve)(options.target ?? cfg?.rootDir ?? ".");
1404
+ const output = (0, import_node_path6.resolve)(options.out ?? (0, import_node_path6.resolve)(target, ".odla/security/hosted"));
1405
+ const outputRelative = (0, import_node_path6.relative)(target, output).split(import_node_path6.sep).join("/");
1406
+ if (!outputRelative) throw new Error("Hosted security output cannot be the repository root");
1407
+ const profile = profileFor(options.profile ?? "odla", options.maxHuntTasks ?? 12);
1408
+ const tokenRequest = {
1409
+ platform,
1410
+ appId,
1411
+ env,
1412
+ selfAudit,
1413
+ scope: selfAudit ? "platform:security:self" : null
1414
+ };
1415
+ const token = await injectedToken(options, tokenRequest);
1416
+ const snapshot = await (0, import_node.snapshotDirectory)(target, {
1417
+ exclude: !outputRelative.startsWith("../") && !(0, import_node_path6.isAbsolute)(outputRelative) ? [outputRelative] : []
1418
+ });
1419
+ const hosted = await (0, import_security.createPlatformSecurityReasoners)({
1420
+ platform,
1421
+ token,
1422
+ appId,
1423
+ env,
1424
+ repository: snapshot.repository,
1425
+ revision: snapshot.revision,
1426
+ snapshotDigest: snapshot.digest,
1427
+ sourceDisclosure: "redacted",
1428
+ clientRunId: options.runId ?? crypto.randomUUID(),
1429
+ ...selfAudit ? { selfAudit: { enabled: true, subject: "service:odla-security-self-audit" } } : {},
1430
+ fetch: options.fetch,
1431
+ signal: options.signal
1432
+ });
1433
+ const harness = (0, import_security.createSecurityHarness)({
1434
+ profile,
1435
+ store: new import_node.FileRunStore((0, import_node_path6.resolve)(output, "state")),
1436
+ discoveryReasoner: hosted.discoveryReasoner,
1437
+ validationReasoner: hosted.validationReasoner,
1438
+ policy: {
1439
+ modelSourceDisclosure: "redacted",
1440
+ active: false,
1441
+ allowNetwork: false
886
1442
  }
887
- out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
1443
+ });
1444
+ const report = await harness.run(snapshot, { runId: hosted.run.runId, signal: options.signal });
1445
+ await (0, import_node.writeSecurityArtifacts)(output, report);
1446
+ const reportDigest = await (0, import_security.securityFingerprint)(report);
1447
+ await hosted.complete({
1448
+ reportDigest,
1449
+ coverageStatus: report.coverageStatus,
1450
+ confirmed: report.metrics.confirmed,
1451
+ candidates: report.metrics.candidates
1452
+ }, { signal: options.signal });
1453
+ printSummary(options.stdout ?? console, appId, env, hosted.run, report, output);
1454
+ return Object.freeze({ report, run: hosted.run, output });
1455
+ }
1456
+ function selectEnv(requested, declared, configPath, rootDir) {
1457
+ const env = requested ?? (declared.includes("dev") ? "dev" : declared[0]);
1458
+ if (!env || !declared.includes(env)) {
1459
+ const shown = (0, import_node_path6.relative)(rootDir, configPath) || configPath;
1460
+ throw new Error(`env "${env ?? ""}" is not declared in ${shown}`);
1461
+ }
1462
+ return env;
1463
+ }
1464
+ async function injectedToken(options, request) {
1465
+ const value = options.token ?? await options.getToken?.(Object.freeze({ ...request }));
1466
+ if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
1467
+ throw new Error(
1468
+ request.selfAudit ? "Self-audit requires an injected, scoped platform security token" : "Hosted security requires an injected app developer token or getToken callback"
1469
+ );
888
1470
  }
1471
+ return value;
1472
+ }
1473
+ function profileFor(name, maxHuntTasks) {
1474
+ const profile = name === "odla" ? (0, import_security.odlaProfile)() : name === "cloudflare-app" ? (0, import_security.cloudflareAppProfile)() : name === "generic" ? (0, import_security.genericProfile)() : void 0;
1475
+ if (!profile) throw new Error(`unknown security profile "${String(name)}"`);
1476
+ if (maxHuntTasks === void 0) return profile;
1477
+ if (!Number.isSafeInteger(maxHuntTasks) || maxHuntTasks < 1) throw new Error("maxHuntTasks must be a positive integer");
1478
+ return { ...profile, maxHuntTasks };
1479
+ }
1480
+ function printSummary(out, appId, env, run, report, output) {
1481
+ const complete = report.coverage.filter((cell) => cell.state === "complete").length;
1482
+ out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
1483
+ out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
1484
+ out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
1485
+ out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
1486
+ out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
1487
+ out.log(` report: ${(0, import_node_path6.resolve)(output, "REPORT.md")}`);
1488
+ }
1489
+
1490
+ // src/help.ts
1491
+ var import_node_fs7 = require("fs");
1492
+ function cliVersion() {
1493
+ const pkg = JSON.parse((0, import_node_fs7.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
1494
+ return pkg.version ?? "unknown";
1495
+ }
1496
+ function printHelp() {
1497
+ console.log(`odla-ai
1498
+
1499
+ Usage:
1500
+ odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
1501
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
1502
+ odla-ai doctor [--config odla.config.mjs]
1503
+ odla-ai capabilities [--json]
1504
+ odla-ai admin ai show [--platform https://odla.ai] [--json]
1505
+ odla-ai admin ai set <purpose> --provider <id> --model <id> [--enabled|--no-enabled]
1506
+ odla-ai admin ai credentials [--json]
1507
+ odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
1508
+ odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1509
+ odla-ai security run [target] --self --ack-redacted-source
1510
+ odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
1511
+ odla-ai smoke [--config odla.config.mjs] [--env dev]
1512
+ odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
1513
+ odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1514
+ odla-ai version
1515
+
1516
+ Commands:
1517
+ setup Install offline odla runbooks for common coding-agent harnesses.
1518
+ init Create a generic odla.config.mjs plus starter schema/rules files.
1519
+ doctor Validate and summarize the project config without network calls.
1520
+ capabilities Show what the CLI automates vs agent edits and human checkpoints.
1521
+ admin Manage platform-funded AI routing with a narrow admin-approved device grant.
1522
+ security Run hosted discovery + independent validation with app/run attribution.
1523
+ provision Register services, configure them, persist credentials, optionally push secrets.
1524
+ smoke Verify local credentials, public-config, live schema, and db aggregate.
1525
+ skill Same installer; --agent accepts all, claude, codex, cursor,
1526
+ copilot, gemini, or agents (repeatable or comma-separated).
1527
+ secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
1528
+ version Print the CLI version.
1529
+
1530
+ Safety:
1531
+ New projects target dev only. Add prod explicitly to odla.config.mjs and pass
1532
+ --yes to provision it; use --dry-run first to inspect the resolved plan.
1533
+ Provision caches the approved developer token and service credentials under
1534
+ .odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
1535
+ preflights Wrangler before any shown-once issuance or destructive rotation.
1536
+ Provision opens the approval page automatically in interactive terminals;
1537
+ use --open to force browser launch or --no-open to suppress it.
1538
+ `);
889
1539
  }
890
1540
 
891
1541
  // src/skill.ts
892
- var import_node_fs6 = require("fs");
1542
+ var import_node_fs8 = require("fs");
893
1543
  var import_node_os = require("os");
894
1544
  var import_node_path7 = require("path");
895
1545
  var import_node_url2 = require("url");
1546
+
1547
+ // src/skill-adapters.ts
1548
+ var PROJECT_INSTRUCTIONS = `<!-- odla-ai agent setup:start -->
1549
+ ## odla agent workflow
1550
+
1551
+ For work that creates an odla app or adds odla services, read and follow
1552
+ \`.agents/skills/odla/SKILL.md\`. It dispatches an existing static site to
1553
+ \`.agents/skills/odla-migrate/SKILL.md\`. For production telemetry triage, use
1554
+ \`.agents/skills/odla-o11y-debug/SKILL.md\`.
1555
+
1556
+ The complete runbooks and their references are installed in this repository.
1557
+ Do not fetch online odla documentation as setup context. Network access is only
1558
+ needed when a runbook deliberately calls the odla service, npm, Cloudflare, or
1559
+ another configured provider.
1560
+ <!-- odla-ai agent setup:end -->`;
1561
+ var CURSOR_RULE = `---
1562
+ description: Build, migrate, provision, secure, or debug an odla app using the locally installed odla runbooks
1563
+ globs:
1564
+ alwaysApply: false
1565
+ ---
1566
+
1567
+ ${PROJECT_INSTRUCTIONS}
1568
+ `;
1569
+ function claudeAdapter(skill, canonical) {
1570
+ const match = canonical.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1571
+ if (!match) throw new Error(`bundled skill ${skill} has no YAML frontmatter`);
1572
+ const lines = match[1].split(/\r?\n/);
1573
+ const frontmatter = [];
1574
+ let keepIndented = false;
1575
+ for (const line of lines) {
1576
+ if (line.startsWith("name:") || line.startsWith("description:")) {
1577
+ frontmatter.push(line);
1578
+ keepIndented = line.startsWith("description:");
1579
+ } else if (keepIndented && /^\s+/.test(line)) {
1580
+ frontmatter.push(line);
1581
+ } else {
1582
+ keepIndented = false;
1583
+ }
1584
+ }
1585
+ return `---
1586
+ ${frontmatter.join("\n")}
1587
+ ---
1588
+
1589
+ # ${skill} (odla adapter)
1590
+
1591
+ Read \`../../../.agents/skills/${skill}/SKILL.md\` completely and follow it. That
1592
+ canonical local skill owns every reference path and contains the full offline
1593
+ runbook. Do not substitute a remote documentation page for the installed copy.
1594
+ `;
1595
+ }
1596
+
1597
+ // src/skill.ts
1598
+ var AGENT_HARNESSES = ["claude", "codex", "cursor", "copilot", "gemini", "agents"];
896
1599
  function installSkill(options = {}) {
897
1600
  const out = options.stdout ?? console;
898
1601
  const sourceDir = options.sourceDir ?? (0, import_node_url2.fileURLToPath)(new URL("../skills", importMetaUrl));
899
- const targetDir = options.global ? (0, import_node_path7.join)(options.homeDir ?? (0, import_node_os.homedir)(), ".claude", "skills") : (0, import_node_path7.resolve)(options.dir ?? process.cwd(), ".claude", "skills");
900
1602
  const files = listFiles(sourceDir);
901
1603
  if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
902
- const written = [];
903
- const unchanged = [];
904
- const conflicts = [];
905
- for (const rel of files) {
906
- const target = (0, import_node_path7.join)(targetDir, rel);
907
- const source = (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(sourceDir, rel), "utf8");
908
- if ((0, import_node_fs6.existsSync)(target)) {
909
- const current = (0, import_node_fs6.readFileSync)(target, "utf8");
910
- if (current === source) {
911
- unchanged.push(rel);
912
- continue;
913
- }
914
- if (!options.force) {
915
- conflicts.push(rel);
916
- continue;
1604
+ const harnesses = normalizeHarnesses(options.harnesses, options.global === true);
1605
+ const root = (0, import_node_path7.resolve)(options.dir ?? process.cwd());
1606
+ const home = (0, import_node_path7.resolve)(options.homeDir ?? (0, import_node_os.homedir)());
1607
+ const plans = /* @__PURE__ */ new Map();
1608
+ const targets = /* @__PURE__ */ new Map();
1609
+ const rememberTarget = (harness, target) => {
1610
+ const current = targets.get(harness) ?? /* @__PURE__ */ new Set();
1611
+ current.add(target);
1612
+ targets.set(harness, current);
1613
+ };
1614
+ const plan = (target, content, managedMerge = false, boundary = root) => {
1615
+ const existing = plans.get(target);
1616
+ if (existing && existing.content !== content) throw new Error(`internal agent setup conflict for ${target}`);
1617
+ plans.set(target, { target, content, boundary, managedMerge });
1618
+ };
1619
+ const planSkillTree = (targetDir2, boundary = root) => {
1620
+ for (const rel of files) plan((0, import_node_path7.join)(targetDir2, rel), (0, import_node_fs8.readFileSync)((0, import_node_path7.join)(sourceDir, rel), "utf8"), false, boundary);
1621
+ };
1622
+ let targetDir;
1623
+ if (options.global) {
1624
+ const claudeRoot = (0, import_node_path7.join)(home, ".claude", "skills");
1625
+ const codexRoot = (0, import_node_path7.resolve)(options.codexHomeDir ?? process.env.CODEX_HOME ?? (0, import_node_path7.join)(home, ".codex"), "skills");
1626
+ targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
1627
+ for (const harness of harnesses) {
1628
+ const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
1629
+ planSkillTree(skillRoot, harness === "claude" ? home : (0, import_node_path7.dirname)((0, import_node_path7.dirname)(codexRoot)));
1630
+ rememberTarget(harness, skillRoot);
1631
+ }
1632
+ } else {
1633
+ const sharedRoot = (0, import_node_path7.join)(root, ".agents", "skills");
1634
+ planSkillTree(sharedRoot);
1635
+ const claudeRoot = (0, import_node_path7.join)(root, ".claude", "skills");
1636
+ targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
1637
+ for (const harness of harnesses) rememberTarget(harness, sharedRoot);
1638
+ if (harnesses.includes("claude")) {
1639
+ for (const skill of skillNames(files)) {
1640
+ const canonical = (0, import_node_fs8.readFileSync)((0, import_node_path7.join)(sourceDir, skill, "SKILL.md"), "utf8");
1641
+ plan((0, import_node_path7.join)(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
917
1642
  }
1643
+ rememberTarget("claude", claudeRoot);
1644
+ }
1645
+ if (harnesses.includes("cursor")) {
1646
+ const cursorRule = (0, import_node_path7.join)(root, ".cursor", "rules", "odla.mdc");
1647
+ plan(cursorRule, CURSOR_RULE);
1648
+ rememberTarget("cursor", cursorRule);
1649
+ }
1650
+ if (harnesses.includes("agents")) {
1651
+ const agentsFile = (0, import_node_path7.join)(root, "AGENTS.md");
1652
+ plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
1653
+ rememberTarget("agents", agentsFile);
1654
+ }
1655
+ if (harnesses.includes("copilot")) {
1656
+ const copilotFile = (0, import_node_path7.join)(root, ".github", "copilot-instructions.md");
1657
+ plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
1658
+ rememberTarget("copilot", copilotFile);
1659
+ }
1660
+ if (harnesses.includes("gemini")) {
1661
+ const geminiFile = (0, import_node_path7.join)(root, "GEMINI.md");
1662
+ plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
1663
+ rememberTarget("gemini", geminiFile);
1664
+ }
1665
+ }
1666
+ const writtenPaths = /* @__PURE__ */ new Set();
1667
+ const unchangedPaths = /* @__PURE__ */ new Set();
1668
+ const conflicts = [];
1669
+ for (const file of plans.values()) {
1670
+ const symlink = symlinkedComponent(file.boundary, file.target);
1671
+ if (symlink) {
1672
+ conflicts.push(`${file.target} (redirected by symbolic link ${symlink})`);
1673
+ continue;
1674
+ }
1675
+ if (!(0, import_node_fs8.existsSync)(file.target)) {
1676
+ writtenPaths.add(file.target);
1677
+ continue;
1678
+ }
1679
+ const current = (0, import_node_fs8.readFileSync)(file.target, "utf8");
1680
+ if (current === file.content) {
1681
+ unchangedPaths.add(file.target);
1682
+ } else if (file.managedMerge || options.force) {
1683
+ writtenPaths.add(file.target);
1684
+ unchangedPaths.delete(file.target);
1685
+ } else {
1686
+ conflicts.push(file.target);
918
1687
  }
919
- written.push(rel);
920
1688
  }
921
1689
  if (conflicts.length > 0) {
922
1690
  throw new Error(
923
- `skill files modified locally (re-run with --force to overwrite):
924
- ${conflicts.map((f) => ` - ${(0, import_node_path7.join)(targetDir, f)}`).join("\n")}`
1691
+ `agent setup files modified locally (re-run with --force to replace only odla-managed content):
1692
+ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
925
1693
  );
926
1694
  }
927
- for (const rel of written) {
928
- const target = (0, import_node_path7.join)(targetDir, rel);
929
- (0, import_node_fs6.mkdirSync)((0, import_node_path7.join)(target, ".."), { recursive: true });
930
- (0, import_node_fs6.writeFileSync)(target, (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(sourceDir, rel), "utf8"));
1695
+ for (const file of plans.values()) {
1696
+ if (!(0, import_node_fs8.existsSync)(file.target) || (0, import_node_fs8.readFileSync)(file.target, "utf8") !== file.content) {
1697
+ (0, import_node_fs8.mkdirSync)((0, import_node_path7.dirname)(file.target), { recursive: true });
1698
+ (0, import_node_fs8.writeFileSync)(file.target, file.content);
1699
+ }
1700
+ }
1701
+ const skills = skillNames(files);
1702
+ const installations = harnesses.map((harness) => ({ harness, targets: [...targets.get(harness) ?? []] }));
1703
+ out.log(`agent harnesses: ${harnesses.join(", ")}`);
1704
+ out.log(`offline skills: ${skills.join(", ")}`);
1705
+ for (const installation of installations) out.log(`${installation.harness}: ${installation.targets.join(", ")}`);
1706
+ out.log(`installed ${writtenPaths.size} managed file(s)${unchangedPaths.size ? ` (${unchangedPaths.size} unchanged)` : ""}`);
1707
+ return {
1708
+ targetDir,
1709
+ written: pathsUnder(targetDir, writtenPaths),
1710
+ unchanged: pathsUnder(targetDir, unchangedPaths),
1711
+ writtenPaths: [...writtenPaths].sort(),
1712
+ unchangedPaths: [...unchangedPaths].sort(),
1713
+ harnesses: installations
1714
+ };
1715
+ }
1716
+ function pathsUnder(root, paths) {
1717
+ return [...paths].map((path) => (0, import_node_path7.relative)(root, path)).filter((path) => path !== ".." && !path.startsWith(`..${import_node_path7.sep}`) && !(0, import_node_path7.isAbsolute)(path)).sort();
1718
+ }
1719
+ function normalizeHarnesses(values, global) {
1720
+ const requested = values?.length ? values : ["claude"];
1721
+ const allowed = /* @__PURE__ */ new Set([...AGENT_HARNESSES, "all"]);
1722
+ for (const harness of requested) {
1723
+ if (!allowed.has(harness)) {
1724
+ throw new Error(`unknown agent harness "${harness}"; choose all, ${AGENT_HARNESSES.join(", ")}`);
1725
+ }
1726
+ }
1727
+ const expanded = requested.includes("all") ? global ? ["claude", "codex"] : [...AGENT_HARNESSES] : [...new Set(requested)];
1728
+ if (global) {
1729
+ const unsupported = expanded.filter((harness) => harness !== "claude" && harness !== "codex");
1730
+ if (unsupported.length) {
1731
+ throw new Error(`--global supports claude and codex only; ${unsupported.join(", ")} require a project-local adapter`);
1732
+ }
1733
+ }
1734
+ return expanded;
1735
+ }
1736
+ function managedFileContent(path, block, force, boundary) {
1737
+ const symlink = symlinkedComponent(boundary, path);
1738
+ if (symlink) throw new Error(`refusing to manage ${path}: symbolic link component ${symlink}`);
1739
+ if (!(0, import_node_fs8.existsSync)(path)) return `${block}
1740
+ `;
1741
+ const current = (0, import_node_fs8.readFileSync)(path, "utf8");
1742
+ const start = "<!-- odla-ai agent setup:start -->";
1743
+ const end = "<!-- odla-ai agent setup:end -->";
1744
+ const startAt = current.indexOf(start);
1745
+ const endAt = current.indexOf(end);
1746
+ if (startAt === -1 !== (endAt === -1) || startAt !== -1 && endAt < startAt) {
1747
+ throw new Error(`malformed odla-managed section in ${path}`);
1748
+ }
1749
+ if (startAt === -1) {
1750
+ const separator = current.length === 0 || current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
1751
+ return `${current}${separator}${block}
1752
+ `;
1753
+ }
1754
+ const afterEnd = endAt + end.length;
1755
+ const existing = current.slice(startAt, afterEnd);
1756
+ if (existing !== block && !force) {
1757
+ throw new Error(`odla-managed section modified locally in ${path}; re-run with --force to replace that section`);
1758
+ }
1759
+ return `${current.slice(0, startAt)}${block}${current.slice(afterEnd)}`;
1760
+ }
1761
+ function symlinkedComponent(boundary, target) {
1762
+ const rel = (0, import_node_path7.relative)(boundary, target);
1763
+ if (rel === ".." || rel.startsWith(`..${import_node_path7.sep}`) || (0, import_node_path7.isAbsolute)(rel)) {
1764
+ throw new Error(`agent setup target escapes its install root: ${target}`);
931
1765
  }
932
- const skills = [...new Set(files.map((f) => f.split(/[\\/]/)[0]))].sort();
933
- out.log(`skills: ${skills.join(", ")}`);
934
- out.log(`installed ${written.length} file(s) to ${targetDir}${unchanged.length ? ` (${unchanged.length} unchanged)` : ""}`);
935
- return { targetDir, written, unchanged };
1766
+ let current = boundary;
1767
+ for (const part of rel.split(import_node_path7.sep).filter(Boolean)) {
1768
+ current = (0, import_node_path7.join)(current, part);
1769
+ try {
1770
+ if ((0, import_node_fs8.lstatSync)(current).isSymbolicLink()) return current;
1771
+ } catch (error) {
1772
+ if (error.code !== "ENOENT") throw error;
1773
+ }
1774
+ }
1775
+ return void 0;
1776
+ }
1777
+ function skillNames(files) {
1778
+ return [...new Set(files.filter((file) => /(^|[\\/])SKILL\.md$/.test(file)).map((file) => file.split(/[\\/]/)[0]))].sort();
936
1779
  }
937
1780
  function listFiles(dir) {
938
- if (!(0, import_node_fs6.existsSync)(dir)) return [];
1781
+ if (!(0, import_node_fs8.existsSync)(dir)) return [];
939
1782
  const results = [];
940
1783
  const walk = (current) => {
941
- for (const entry of (0, import_node_fs6.readdirSync)(current, { withFileTypes: true })) {
1784
+ for (const entry of (0, import_node_fs8.readdirSync)(current, { withFileTypes: true })) {
942
1785
  const path = (0, import_node_path7.join)(current, entry.name);
943
1786
  if (entry.isDirectory()) walk(path);
944
1787
  else results.push((0, import_node_path7.relative)(dir, path));
@@ -1004,7 +1847,7 @@ async function getJson(doFetch, url, bearer) {
1004
1847
  const res = await doFetch(url, {
1005
1848
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
1006
1849
  });
1007
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText2(res)}`);
1850
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
1008
1851
  return res.json();
1009
1852
  }
1010
1853
  async function postJson2(doFetch, url, bearer, body) {
@@ -1013,7 +1856,7 @@ async function postJson2(doFetch, url, bearer, body) {
1013
1856
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
1014
1857
  body: JSON.stringify(body)
1015
1858
  });
1016
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText2(res)}`);
1859
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
1017
1860
  return res.json();
1018
1861
  }
1019
1862
  function publicConfigUrl(platformUrl, appId, env) {
@@ -1021,7 +1864,7 @@ function publicConfigUrl(platformUrl, appId, env) {
1021
1864
  url.searchParams.set("env", env);
1022
1865
  return url.toString();
1023
1866
  }
1024
- async function safeText2(res) {
1867
+ async function safeText3(res) {
1025
1868
  try {
1026
1869
  return (await res.text()).slice(0, 500);
1027
1870
  } catch {
@@ -1034,14 +1877,17 @@ async function runCli(argv = process.argv.slice(2)) {
1034
1877
  const parsed = parseArgv(argv);
1035
1878
  const command = parsed.positionals[0] ?? "help";
1036
1879
  if (command === "version" || command === "-v" || parsed.options.version === true) {
1880
+ assertArgs(parsed, ["version"], 1);
1037
1881
  console.log(cliVersion());
1038
1882
  return;
1039
1883
  }
1040
1884
  if (command === "help" || command === "--help" || command === "-h") {
1885
+ assertArgs(parsed, ["help"], 1);
1041
1886
  printHelp();
1042
1887
  return;
1043
1888
  }
1044
1889
  if (command === "init") {
1890
+ assertArgs(parsed, ["app-id", "name", "config", "env", "services", "ai-provider", "force"], 1);
1045
1891
  initProject({
1046
1892
  appId: requiredString(parsed.options["app-id"], "--app-id"),
1047
1893
  name: requiredString(parsed.options.name, "--name"),
@@ -1054,15 +1900,132 @@ async function runCli(argv = process.argv.slice(2)) {
1054
1900
  return;
1055
1901
  }
1056
1902
  if (command === "doctor") {
1903
+ assertArgs(parsed, ["config"], 1);
1057
1904
  await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
1058
1905
  return;
1059
1906
  }
1907
+ if (command === "capabilities") {
1908
+ assertArgs(parsed, ["json"], 1);
1909
+ printCapabilities(parsed.options.json === true);
1910
+ return;
1911
+ }
1912
+ if (command === "admin") {
1913
+ const area = parsed.positionals[1];
1914
+ const action = parsed.positionals[2];
1915
+ const credentialSet = action === "credential" && parsed.positionals[3] === "set";
1916
+ const credentials = action === "credentials";
1917
+ if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials) {
1918
+ throw new Error('unknown admin command. Try "odla-ai admin ai show".');
1919
+ }
1920
+ assertArgs(parsed, [
1921
+ "platform",
1922
+ "token",
1923
+ "json",
1924
+ "open",
1925
+ "provider",
1926
+ "model",
1927
+ "enabled",
1928
+ "max-input-bytes",
1929
+ "max-output-tokens",
1930
+ "max-calls-per-run",
1931
+ "from-env",
1932
+ "stdin"
1933
+ ], credentialSet ? 5 : action === "set" ? 4 : 3);
1934
+ await adminAi({
1935
+ action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
1936
+ purpose: action === "set" ? parsed.positionals[3] : void 0,
1937
+ provider: stringOpt(parsed.options.provider),
1938
+ model: stringOpt(parsed.options.model),
1939
+ enabled: boolOpt(parsed.options.enabled),
1940
+ maxInputBytes: numberOpt(parsed.options["max-input-bytes"], "--max-input-bytes"),
1941
+ maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
1942
+ maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
1943
+ platform: stringOpt(parsed.options.platform),
1944
+ token: stringOpt(parsed.options.token),
1945
+ json: parsed.options.json === true,
1946
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
1947
+ credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
1948
+ fromEnv: stringOpt(parsed.options["from-env"]),
1949
+ stdin: parsed.options.stdin === true
1950
+ });
1951
+ return;
1952
+ }
1953
+ if (command === "security") {
1954
+ const sub = parsed.positionals[1];
1955
+ if (sub !== "run") throw new Error('unknown security command. Try "odla-ai security run".');
1956
+ assertArgs(parsed, [
1957
+ "config",
1958
+ "env",
1959
+ "out",
1960
+ "profile",
1961
+ "max-hunt-tasks",
1962
+ "run-id",
1963
+ "platform",
1964
+ "self",
1965
+ "ack-redacted-source",
1966
+ "open",
1967
+ "fail-on",
1968
+ "fail-on-candidates",
1969
+ "allow-incomplete"
1970
+ ], 3);
1971
+ const configPath = stringOpt(parsed.options.config) ?? "odla.config.mjs";
1972
+ const selfAudit = parsed.options.self === true;
1973
+ const open = parsed.options.open === false ? false : parsed.options.open === true ? true : void 0;
1974
+ const platform = stringOpt(parsed.options.platform);
1975
+ const result = await runHostedSecurity({
1976
+ configPath,
1977
+ selfAudit,
1978
+ target: parsed.positionals[2],
1979
+ out: stringOpt(parsed.options.out),
1980
+ env: stringOpt(parsed.options.env),
1981
+ profile: securityProfile(stringOpt(parsed.options.profile)),
1982
+ maxHuntTasks: numberOpt(parsed.options["max-hunt-tasks"], "--max-hunt-tasks"),
1983
+ runId: stringOpt(parsed.options["run-id"]),
1984
+ platform,
1985
+ sourceDisclosureAck: parsed.options["ack-redacted-source"] === true ? "redacted" : void 0,
1986
+ getToken: async (request) => {
1987
+ if (request.scope === "platform:security:self") {
1988
+ return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
1989
+ }
1990
+ const cfg = await loadProjectConfig(configPath);
1991
+ return getDeveloperToken(cfg, { configPath, open }, fetch, console);
1992
+ }
1993
+ });
1994
+ const failOn = severityOpt(stringOpt(parsed.options["fail-on"]) ?? "high", "--fail-on");
1995
+ const candidateValue = parsed.options["fail-on-candidates"];
1996
+ const failOnCandidates = candidateValue === false ? void 0 : severityOpt(stringOpt(candidateValue) ?? "critical", "--fail-on-candidates");
1997
+ const confirmed = (0, import_security2.findingsAtOrAbove)(result.report, failOn);
1998
+ const leads = failOnCandidates ? (0, import_security2.findingsAtOrAbove)(result.report, failOnCandidates, true).filter((finding) => finding.disposition !== "confirmed") : [];
1999
+ const incomplete = result.report.coverageStatus === "incomplete" && parsed.options["allow-incomplete"] !== true;
2000
+ if (confirmed.length || leads.length || incomplete) {
2001
+ throw new Error(`hosted security gate failed: ${confirmed.length} confirmed >= ${failOn}; ${leads.length} leads >= ${failOnCandidates ?? "disabled"}${incomplete ? "; coverage incomplete" : ""}`);
2002
+ }
2003
+ return;
2004
+ }
1060
2005
  if (command === "provision") {
2006
+ assertArgs(
2007
+ parsed,
2008
+ [
2009
+ "config",
2010
+ "dry-run",
2011
+ "rotate-keys",
2012
+ "rotate-o11y-token",
2013
+ "push-secrets",
2014
+ "write-credentials",
2015
+ "write-dev-vars",
2016
+ "token",
2017
+ "open",
2018
+ "yes"
2019
+ ],
2020
+ 1
2021
+ );
1061
2022
  const writeDevVars2 = parsed.options["write-dev-vars"];
1062
2023
  const opts = {
1063
2024
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
1064
2025
  dryRun: parsed.options["dry-run"] === true,
1065
2026
  rotateKeys: parsed.options["rotate-keys"] === true,
2027
+ rotateO11yToken: parsed.options["rotate-o11y-token"] === true,
2028
+ pushSecrets: parsed.options["push-secrets"] === true,
1066
2029
  writeCredentials: parsed.options["write-credentials"] !== false,
1067
2030
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
1068
2031
  token: stringOpt(parsed.options.token),
@@ -1073,6 +2036,7 @@ async function runCli(argv = process.argv.slice(2)) {
1073
2036
  return;
1074
2037
  }
1075
2038
  if (command === "smoke") {
2039
+ assertArgs(parsed, ["config", "env"], 1);
1076
2040
  await smoke({
1077
2041
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
1078
2042
  env: stringOpt(parsed.options.env)
@@ -1080,22 +2044,26 @@ async function runCli(argv = process.argv.slice(2)) {
1080
2044
  return;
1081
2045
  }
1082
2046
  if (command === "setup") {
2047
+ assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 1);
1083
2048
  installSkill({
1084
2049
  dir: stringOpt(parsed.options.dir),
1085
2050
  global: parsed.options.global === true,
2051
+ harnesses: harnessList(parsed),
1086
2052
  force: parsed.options.force === true
1087
2053
  });
1088
2054
  console.log(
1089
- "\nSkills installed. Point your coding agent at this repo \u2014 the `odla` skill orients it\n(build a new app, or migrate an existing site) and drives `odla-ai init` / `provision`."
2055
+ "\nOffline runbooks installed. Open this repo in your coding agent and ask it to set up odla.\nIts native adapter finds the local `odla` skill, chooses build or migration, and drives the CLI."
1090
2056
  );
1091
2057
  return;
1092
2058
  }
1093
2059
  if (command === "skill") {
1094
2060
  const sub = parsed.positionals[1];
1095
2061
  if (sub !== "install") throw new Error(`unknown skill subcommand "${sub ?? ""}". Try "odla-ai skill install".`);
2062
+ assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 2);
1096
2063
  installSkill({
1097
2064
  dir: stringOpt(parsed.options.dir),
1098
2065
  global: parsed.options.global === true,
2066
+ harnesses: harnessList(parsed),
1099
2067
  force: parsed.options.force === true
1100
2068
  });
1101
2069
  return;
@@ -1103,6 +2071,7 @@ async function runCli(argv = process.argv.slice(2)) {
1103
2071
  if (command === "secrets") {
1104
2072
  const sub = parsed.positionals[1];
1105
2073
  if (sub !== "push") throw new Error(`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev".`);
2074
+ assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
1106
2075
  await secretsPush({
1107
2076
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
1108
2077
  env: requiredString(parsed.options.env, "--env"),
@@ -1113,94 +2082,29 @@ async function runCli(argv = process.argv.slice(2)) {
1113
2082
  }
1114
2083
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
1115
2084
  }
1116
- function parseArgv(argv) {
1117
- const positionals = [];
1118
- const options = {};
1119
- for (let i = 0; i < argv.length; i++) {
1120
- const arg = argv[i];
1121
- if (!arg.startsWith("--")) {
1122
- positionals.push(arg);
1123
- continue;
1124
- }
1125
- const eq = arg.indexOf("=");
1126
- const rawName = arg.slice(2, eq === -1 ? void 0 : eq);
1127
- if (!rawName) continue;
1128
- if (rawName.startsWith("no-")) {
1129
- options[rawName.slice(3)] = false;
1130
- continue;
1131
- }
1132
- if (eq !== -1) {
1133
- addOption(options, rawName, arg.slice(eq + 1));
1134
- continue;
1135
- }
1136
- const value = argv[i + 1];
1137
- if (value !== void 0 && !value.startsWith("--")) {
1138
- i++;
1139
- addOption(options, rawName, value);
1140
- } else {
1141
- addOption(options, rawName, true);
1142
- }
1143
- }
1144
- return { positionals, options };
1145
- }
1146
- function addOption(options, name, value) {
1147
- const cur = options[name];
1148
- if (cur === void 0) {
1149
- options[name] = value;
1150
- } else if (Array.isArray(cur)) {
1151
- cur.push(String(value));
1152
- } else {
1153
- options[name] = [String(cur), String(value)];
1154
- }
1155
- }
1156
- function requiredString(value, name) {
1157
- const s = stringOpt(value);
1158
- if (!s) throw new Error(`${name} is required`);
1159
- return s;
1160
- }
1161
- function stringOpt(value) {
1162
- if (typeof value === "string") return value;
1163
- if (Array.isArray(value)) return value[value.length - 1];
1164
- return void 0;
1165
- }
1166
- function listOpt(value) {
1167
- if (value === void 0 || value === false || value === true) return void 0;
1168
- const values = Array.isArray(value) ? value : [value];
1169
- return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
2085
+ function securityProfile(value) {
2086
+ if (value === void 0) return void 0;
2087
+ if (value === "odla" || value === "cloudflare-app" || value === "generic") return value;
2088
+ throw new Error("--profile must be odla, cloudflare-app, or generic");
1170
2089
  }
1171
- function cliVersion() {
1172
- const pkg = JSON.parse((0, import_node_fs7.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
1173
- return pkg.version ?? "unknown";
2090
+ function severityOpt(value, flag) {
2091
+ if (["informational", "low", "medium", "high", "critical"].includes(value)) return value;
2092
+ throw new Error(`${flag} must be informational, low, medium, high, or critical`);
1174
2093
  }
1175
- function printHelp() {
1176
- console.log(`odla-ai
1177
-
1178
- Usage:
1179
- odla-ai setup [--dir <project>] [--global] [--force]
1180
- odla-ai init --app-id <id> --name <name> [--services db,ai] [--env dev --env prod]
1181
- odla-ai doctor [--config odla.config.mjs]
1182
- odla-ai provision [--config odla.config.mjs] [--dry-run] [--open|--no-open] [--rotate-keys] [--write-dev-vars[=path]]
1183
- odla-ai smoke [--config odla.config.mjs] [--env dev]
1184
- odla-ai skill install [--dir <project>] [--global] [--force]
1185
- odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1186
- odla-ai version
1187
-
1188
- Commands:
1189
- setup Install the odla skills so a coding agent can drive the rest.
1190
- init Create a generic odla.config.mjs plus starter schema/rules files.
1191
- doctor Validate and summarize the project config without network calls.
1192
- provision Register the app, enable services, push schema/rules, configure AI/auth.
1193
- smoke Verify local credentials, public-config, live schema, and db aggregate.
1194
- skill Install the bundled Claude Code skills into .claude/skills/.
1195
- secrets Push the env's db key into the Worker via wrangler, stdin-piped.
1196
- version Print the CLI version.
1197
-
1198
- Safety:
1199
- Provision caches the approved developer token and local db keys under .odla/
1200
- with mode 0600, and init adds those paths to .gitignore.
1201
- Provision opens the approval page automatically in interactive terminals;
1202
- use --open to force browser launch or --no-open to suppress it.
1203
- `);
2094
+ function harnessList(parsed) {
2095
+ const selected = [
2096
+ ...harnessOption(parsed.options.agent, "--agent"),
2097
+ ...harnessOption(parsed.options.harness, "--harness")
2098
+ ];
2099
+ return selected.length ? selected : ["all"];
2100
+ }
2101
+ function harnessOption(value, flag) {
2102
+ if (value === void 0) return [];
2103
+ if (typeof value === "boolean") throw new Error(`${flag} requires a harness name`);
2104
+ const raw = Array.isArray(value) ? value : [value];
2105
+ const parts = raw.flatMap((item) => item.split(","));
2106
+ if (parts.some((item) => !item.trim())) throw new Error(`${flag} requires a harness name`);
2107
+ return parts.map((item) => item.trim());
1204
2108
  }
1205
2109
 
1206
2110
  // src/bin.ts