@odla-ai/cli 0.3.0 → 0.5.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/README.md +104 -30
- package/REQUIREMENTS.md +18 -3
- package/dist/bin.cjs +790 -273
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-ZU6QJXRQ.js → chunk-7WZIZCGA.js} +790 -270
- package/dist/chunk-7WZIZCGA.js.map +1 -0
- package/dist/index.cjs +796 -273
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -21
- package/dist/index.d.ts +205 -21
- package/dist/index.js +7 -1
- package/llms.txt +311 -53
- package/package.json +14 -6
- package/skills/odla/SKILL.md +103 -0
- package/skills/odla/references/build.md +93 -0
- package/skills/odla/references/sdks.md +94 -0
- package/skills/odla-migrate/SKILL.md +38 -13
- package/skills/odla-migrate/references/phase-0-preflight.md +0 -2
- package/skills/odla-migrate/references/phase-1-static.md +2 -4
- package/skills/odla-migrate/references/phase-2-db.md +25 -17
- package/skills/odla-migrate/references/phase-3-auth.md +106 -20
- package/skills/odla-migrate/references/phase-3b-user-sync.md +57 -0
- package/skills/odla-migrate/references/phase-4-ai.md +6 -5
- package/skills/odla-migrate/references/phase-5-cutover.md +43 -16
- package/skills/odla-migrate/references/secrets-map.md +21 -11
- package/skills/odla-migrate/references/troubleshooting.md +27 -13
- package/skills/odla-o11y-debug/SKILL.md +93 -0
- package/dist/chunk-ZU6QJXRQ.js.map +0 -1
package/dist/bin.cjs
CHANGED
|
@@ -30,12 +30,54 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
|
30
30
|
// src/cli.ts
|
|
31
31
|
var import_node_fs7 = require("fs");
|
|
32
32
|
|
|
33
|
+
// src/capabilities.ts
|
|
34
|
+
var CAPABILITIES = {
|
|
35
|
+
cli: [
|
|
36
|
+
"register the app and enable configured services per environment",
|
|
37
|
+
"issue, persist, and explicitly rotate configured service credentials",
|
|
38
|
+
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
39
|
+
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
40
|
+
"validate config offline and smoke-test a provisioned db environment"
|
|
41
|
+
],
|
|
42
|
+
agent: [
|
|
43
|
+
"install and import the selected odla SDKs",
|
|
44
|
+
"wrap the Worker with withObservability and choose useful telemetry",
|
|
45
|
+
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
46
|
+
],
|
|
47
|
+
human: [
|
|
48
|
+
"approve the odla device code and one-off third-party logins",
|
|
49
|
+
"consent to production changes with --yes",
|
|
50
|
+
"request destructive credential rotation explicitly",
|
|
51
|
+
"review application semantics, security findings, releases, and merges"
|
|
52
|
+
],
|
|
53
|
+
studio: [
|
|
54
|
+
"view telemetry and environment state",
|
|
55
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable"
|
|
56
|
+
]
|
|
57
|
+
};
|
|
58
|
+
function printCapabilities(json = false, out = console) {
|
|
59
|
+
if (json) {
|
|
60
|
+
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
out.log("odla-ai responsibility boundary\n");
|
|
64
|
+
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
65
|
+
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
66
|
+
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
67
|
+
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
68
|
+
}
|
|
69
|
+
function printGroup(out, heading, items) {
|
|
70
|
+
out.log(`${heading}:`);
|
|
71
|
+
for (const item of items) out.log(` - ${item}`);
|
|
72
|
+
out.log("");
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
// src/config.ts
|
|
34
76
|
var import_node_fs = require("fs");
|
|
35
77
|
var import_node_path = require("path");
|
|
36
78
|
var import_node_url = require("url");
|
|
37
79
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
38
|
-
var DEFAULT_ENVS = ["
|
|
80
|
+
var DEFAULT_ENVS = ["dev"];
|
|
39
81
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
40
82
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
41
83
|
const resolved = (0, import_node_path.resolve)(configPath);
|
|
@@ -137,8 +179,8 @@ function unique(values) {
|
|
|
137
179
|
|
|
138
180
|
// src/doctor-checks.ts
|
|
139
181
|
var import_node_child_process2 = require("child_process");
|
|
140
|
-
var
|
|
141
|
-
var
|
|
182
|
+
var import_node_fs4 = require("fs");
|
|
183
|
+
var import_node_path4 = require("path");
|
|
142
184
|
|
|
143
185
|
// src/redact.ts
|
|
144
186
|
var REPLACEMENTS = [
|
|
@@ -159,10 +201,136 @@ function looksSecret(value) {
|
|
|
159
201
|
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
160
202
|
}
|
|
161
203
|
|
|
162
|
-
// src/
|
|
163
|
-
var import_node_child_process = require("child_process");
|
|
204
|
+
// src/local.ts
|
|
164
205
|
var import_node_fs2 = require("fs");
|
|
165
206
|
var import_node_path2 = require("path");
|
|
207
|
+
var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
|
|
208
|
+
function readJsonFile(path) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function writePrivateJson(path, value) {
|
|
216
|
+
writePrivateText(path, `${JSON.stringify(value, null, 2)}
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
function readCredentials(path) {
|
|
220
|
+
if (!(0, import_node_fs2.existsSync)(path)) return null;
|
|
221
|
+
let value;
|
|
222
|
+
try {
|
|
223
|
+
value = JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
|
|
224
|
+
} catch {
|
|
225
|
+
throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
|
|
226
|
+
}
|
|
227
|
+
if (!value || typeof value !== "object" || typeof value.appId !== "string" || !value.envs || typeof value.envs !== "object") {
|
|
228
|
+
throw new Error(`credentials file ${path} has an invalid shape; fix or remove it before provisioning`);
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function mergeCredential(current, update) {
|
|
233
|
+
const next = current ?? {
|
|
234
|
+
appId: update.appId,
|
|
235
|
+
platformUrl: update.platformUrl,
|
|
236
|
+
dbEndpoint: update.dbEndpoint,
|
|
237
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
238
|
+
envs: {}
|
|
239
|
+
};
|
|
240
|
+
next.appId = update.appId;
|
|
241
|
+
next.platformUrl = update.platformUrl;
|
|
242
|
+
next.dbEndpoint = update.dbEndpoint;
|
|
243
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
244
|
+
next.envs[update.env] = {
|
|
245
|
+
...next.envs[update.env] ?? {},
|
|
246
|
+
tenantId: update.tenantId,
|
|
247
|
+
...update.dbKey ? { dbKey: update.dbKey } : {},
|
|
248
|
+
...update.o11yToken ? { o11yToken: update.o11yToken } : {}
|
|
249
|
+
};
|
|
250
|
+
return next;
|
|
251
|
+
}
|
|
252
|
+
function ensureGitignore(rootDir, localPaths = []) {
|
|
253
|
+
const path = (0, import_node_path2.resolve)(rootDir, ".gitignore");
|
|
254
|
+
const existing = (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : "";
|
|
255
|
+
const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
|
|
256
|
+
const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
|
|
257
|
+
const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
|
|
258
|
+
if (missing.length === 0) return;
|
|
259
|
+
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
260
|
+
(0, import_node_fs2.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
function o11yDevVars(cfg) {
|
|
264
|
+
if (!cfg.services.includes("o11y")) return void 0;
|
|
265
|
+
return {
|
|
266
|
+
endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
|
|
267
|
+
service: cfg.o11y?.service ?? cfg.app.id,
|
|
268
|
+
...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function resolveWriteDevVarsTarget(cfg, requested) {
|
|
272
|
+
if (!requested) return null;
|
|
273
|
+
if (requested === true) return cfg.local.devVarsFile;
|
|
274
|
+
return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(cfg.configPath), requested);
|
|
275
|
+
}
|
|
276
|
+
function writeDevVars(path, credentials, env, o11y) {
|
|
277
|
+
const entry = credentials.envs[env];
|
|
278
|
+
if (!entry) throw new Error(`no credentials for env "${env}" in ${path}`);
|
|
279
|
+
const lines = [`ODLA_PLATFORM="${credentials.platformUrl}"`];
|
|
280
|
+
if (entry.dbKey) lines.push(`ODLA_ENDPOINT="${credentials.dbEndpoint}"`);
|
|
281
|
+
lines.push(`ODLA_APP_ID="${credentials.appId}"`, `ODLA_ENV="${env}"`, `ODLA_TENANT="${entry.tenantId}"`);
|
|
282
|
+
if (entry.dbKey) lines.push(`ODLA_API_KEY="${entry.dbKey}"`);
|
|
283
|
+
if (o11y) {
|
|
284
|
+
lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
|
|
285
|
+
if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
|
|
286
|
+
if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
|
|
287
|
+
}
|
|
288
|
+
const existing = (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : "";
|
|
289
|
+
const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
|
|
290
|
+
while (retained.at(-1) === "") retained.pop();
|
|
291
|
+
const prefix = retained.length ? `${retained.join("\n")}
|
|
292
|
+
|
|
293
|
+
` : "";
|
|
294
|
+
writePrivateText(path, `${prefix}${lines.join("\n")}
|
|
295
|
+
`);
|
|
296
|
+
}
|
|
297
|
+
var MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
|
|
298
|
+
"ODLA_PLATFORM",
|
|
299
|
+
"ODLA_ENDPOINT",
|
|
300
|
+
"ODLA_APP_ID",
|
|
301
|
+
"ODLA_ENV",
|
|
302
|
+
"ODLA_TENANT",
|
|
303
|
+
"ODLA_API_KEY",
|
|
304
|
+
"ODLA_O11Y_ENDPOINT",
|
|
305
|
+
"ODLA_O11Y_SERVICE",
|
|
306
|
+
"ODLA_O11Y_VERSION",
|
|
307
|
+
"ODLA_O11Y_TOKEN"
|
|
308
|
+
]);
|
|
309
|
+
function isManagedDevVar(line) {
|
|
310
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
|
|
311
|
+
return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
|
|
312
|
+
}
|
|
313
|
+
function writePrivateText(path, text) {
|
|
314
|
+
(0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
|
|
315
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
316
|
+
(0, import_node_fs2.writeFileSync)(temporary, text, { mode: 384 });
|
|
317
|
+
(0, import_node_fs2.chmodSync)(temporary, 384);
|
|
318
|
+
(0, import_node_fs2.renameSync)(temporary, path);
|
|
319
|
+
}
|
|
320
|
+
function gitignoreEntry(rootDir, path) {
|
|
321
|
+
const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(path));
|
|
322
|
+
if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path2.isAbsolute)(rel)) return null;
|
|
323
|
+
return rel.replaceAll("\\", "/");
|
|
324
|
+
}
|
|
325
|
+
function displayPath(path, rootDir = process.cwd()) {
|
|
326
|
+
const rel = (0, import_node_path2.relative)(rootDir, path);
|
|
327
|
+
return rel && !rel.startsWith("..") ? rel : path;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/wrangler.ts
|
|
331
|
+
var import_node_child_process = require("child_process");
|
|
332
|
+
var import_node_fs3 = require("fs");
|
|
333
|
+
var import_node_path3 = require("path");
|
|
166
334
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
167
335
|
const child = (0, import_node_child_process.spawn)(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
168
336
|
let stdout = "";
|
|
@@ -176,15 +344,15 @@ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) =>
|
|
|
176
344
|
var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
177
345
|
function findWranglerConfig(rootDir) {
|
|
178
346
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
179
|
-
const path = (0,
|
|
180
|
-
if ((0,
|
|
347
|
+
const path = (0, import_node_path3.join)(rootDir, name);
|
|
348
|
+
if ((0, import_node_fs3.existsSync)(path)) return path;
|
|
181
349
|
}
|
|
182
350
|
return null;
|
|
183
351
|
}
|
|
184
352
|
function readWranglerConfig(path) {
|
|
185
353
|
if (path.endsWith(".toml")) return null;
|
|
186
354
|
try {
|
|
187
|
-
return JSON.parse(stripJsonComments((0,
|
|
355
|
+
return JSON.parse(stripJsonComments((0, import_node_fs3.readFileSync)(path, "utf8")));
|
|
188
356
|
} catch {
|
|
189
357
|
return null;
|
|
190
358
|
}
|
|
@@ -256,10 +424,11 @@ function lintRules(rules, entities, publicRead) {
|
|
|
256
424
|
return warnings;
|
|
257
425
|
}
|
|
258
426
|
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) {
|
|
427
|
+
function trackedSecretFiles(rootDir, exec = defaultExec, localPaths = []) {
|
|
260
428
|
let output;
|
|
261
429
|
try {
|
|
262
|
-
|
|
430
|
+
const custom = localPaths.map((path) => gitignoreEntry(rootDir, path)).filter((path) => !!path);
|
|
431
|
+
output = exec("git", ["ls-files", "--", ".dev.vars", ".odla", ...custom], rootDir);
|
|
263
432
|
} catch {
|
|
264
433
|
return [];
|
|
265
434
|
}
|
|
@@ -281,10 +450,10 @@ function wranglerWarnings(rootDir) {
|
|
|
281
450
|
for (const { label, block } of blocks) {
|
|
282
451
|
const assets = block.assets;
|
|
283
452
|
if (assets?.directory) {
|
|
284
|
-
const dir = (0,
|
|
285
|
-
if (dir === (0,
|
|
453
|
+
const dir = (0, import_node_path4.resolve)(rootDir, assets.directory);
|
|
454
|
+
if (dir === (0, import_node_path4.resolve)(rootDir)) {
|
|
286
455
|
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,
|
|
456
|
+
} else if ((0, import_node_fs4.existsSync)((0, import_node_path4.join)(dir, "node_modules"))) {
|
|
288
457
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
289
458
|
}
|
|
290
459
|
}
|
|
@@ -303,96 +472,57 @@ function wranglerWarnings(rootDir) {
|
|
|
303
472
|
}
|
|
304
473
|
return warnings;
|
|
305
474
|
}
|
|
306
|
-
function
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
475
|
+
function o11yProjectWarnings(rootDir) {
|
|
476
|
+
const warnings = [];
|
|
477
|
+
const pkg = readPackageJson(rootDir);
|
|
478
|
+
const dependencies = pkg?.dependencies;
|
|
479
|
+
const devDependencies = pkg?.devDependencies;
|
|
480
|
+
if (!dependencies?.["@odla-ai/o11y"]) {
|
|
481
|
+
warnings.push(
|
|
482
|
+
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"'
|
|
483
|
+
);
|
|
311
484
|
}
|
|
485
|
+
const configPath = findWranglerConfig(rootDir);
|
|
486
|
+
const config = configPath ? readWranglerConfig(configPath) : null;
|
|
487
|
+
if (!configPath || !config) {
|
|
488
|
+
warnings.push("cannot verify o11y Worker instrumentation \u2014 add a parseable wrangler.jsonc/json config");
|
|
489
|
+
return warnings;
|
|
490
|
+
}
|
|
491
|
+
const main = typeof config.main === "string" ? (0, import_node_path4.resolve)(rootDir, config.main) : null;
|
|
492
|
+
if (!main || !(0, import_node_fs4.existsSync)(main)) {
|
|
493
|
+
warnings.push("cannot verify o11y Worker instrumentation \u2014 wrangler main is missing or unreadable");
|
|
494
|
+
} else {
|
|
495
|
+
let source = "";
|
|
496
|
+
try {
|
|
497
|
+
source = (0, import_node_fs4.readFileSync)(main, "utf8");
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
if (!/\bwithObservability\b/.test(source)) {
|
|
501
|
+
warnings.push(`wrangler entrypoint ${config.main} does not reference withObservability`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const flags = Array.isArray(config.compatibility_flags) ? config.compatibility_flags : [];
|
|
505
|
+
if (!flags.includes("nodejs_compat")) {
|
|
506
|
+
warnings.push('wrangler compatibility_flags is missing "nodejs_compat" required by @odla-ai/o11y');
|
|
507
|
+
}
|
|
508
|
+
return warnings;
|
|
312
509
|
}
|
|
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) {
|
|
510
|
+
function readPackageJson(rootDir) {
|
|
319
511
|
try {
|
|
320
|
-
return JSON.parse((0, import_node_fs4.readFileSync)(
|
|
512
|
+
return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
|
|
321
513
|
} catch {
|
|
322
514
|
return null;
|
|
323
515
|
}
|
|
324
516
|
}
|
|
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
517
|
|
|
389
518
|
// src/doctor.ts
|
|
390
519
|
async function doctor(options) {
|
|
391
520
|
const out = options.stdout ?? console;
|
|
392
521
|
const cfg = await loadProjectConfig(options.configPath);
|
|
393
522
|
const plan = buildPlan(cfg);
|
|
394
|
-
const
|
|
395
|
-
const
|
|
523
|
+
const hasDb = cfg.services.includes("db");
|
|
524
|
+
const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
|
|
525
|
+
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
396
526
|
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
397
527
|
const entities = serializedEntities(schema);
|
|
398
528
|
out.log(`config: ${cfg.configPath}`);
|
|
@@ -401,7 +531,7 @@ async function doctor(options) {
|
|
|
401
531
|
out.log(`svc: ${plan.services.join(", ")}`);
|
|
402
532
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
403
533
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
404
|
-
out.log(`ai: ${cfg.ai?.provider ?? "not configured"}`);
|
|
534
|
+
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
405
535
|
const warnings = [];
|
|
406
536
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
407
537
|
if (rules) {
|
|
@@ -417,7 +547,9 @@ async function doctor(options) {
|
|
|
417
547
|
}
|
|
418
548
|
}
|
|
419
549
|
}
|
|
420
|
-
if (cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv])
|
|
550
|
+
if (cfg.services.includes("ai") && cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv]) {
|
|
551
|
+
warnings.push(`${cfg.ai.keyEnv} is not set; provision will skip provider key storage`);
|
|
552
|
+
}
|
|
421
553
|
if (cfg.services.includes("o11y")) {
|
|
422
554
|
const credentials = readCredentials(cfg.local.credentialsFile);
|
|
423
555
|
for (const env of cfg.envs) {
|
|
@@ -425,9 +557,14 @@ async function doctor(options) {
|
|
|
425
557
|
warnings.push(`o11y is enabled but env "${env}" has no ingest token \u2014 run "odla-ai provision" to mint one`);
|
|
426
558
|
}
|
|
427
559
|
}
|
|
560
|
+
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
428
561
|
}
|
|
429
562
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
430
|
-
warnings.push(...trackedSecretFiles(cfg.rootDir
|
|
563
|
+
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
564
|
+
cfg.local.tokenFile,
|
|
565
|
+
cfg.local.credentialsFile,
|
|
566
|
+
cfg.local.devVarsFile
|
|
567
|
+
]));
|
|
431
568
|
warnings.push(...wranglerWarnings(cfg.rootDir));
|
|
432
569
|
if (warnings.length) {
|
|
433
570
|
out.log("");
|
|
@@ -451,7 +588,7 @@ function initProject(options) {
|
|
|
451
588
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
|
|
452
589
|
throw new Error("--app-id must be lowercase letters, numbers, and hyphens");
|
|
453
590
|
}
|
|
454
|
-
const envs = options.envs?.length ? options.envs : ["
|
|
591
|
+
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
455
592
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
456
593
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
457
594
|
(0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
|
|
@@ -554,9 +691,8 @@ function relativeDisplay(path, rootDir) {
|
|
|
554
691
|
}
|
|
555
692
|
|
|
556
693
|
// src/provision.ts
|
|
557
|
-
var
|
|
694
|
+
var import_apps2 = require("@odla-ai/apps");
|
|
558
695
|
var import_ai = require("@odla-ai/ai");
|
|
559
|
-
var import_node_path6 = require("path");
|
|
560
696
|
var import_node_process3 = __toESM(require("process"), 1);
|
|
561
697
|
|
|
562
698
|
// src/token.ts
|
|
@@ -569,7 +705,7 @@ var import_node_process = __toESM(require("process"), 1);
|
|
|
569
705
|
async function openUrl(url, options = {}) {
|
|
570
706
|
const command = openerFor(options.platform ?? import_node_process.default.platform);
|
|
571
707
|
const doSpawn = options.spawnImpl ?? import_node_child_process3.spawn;
|
|
572
|
-
await new Promise((
|
|
708
|
+
await new Promise((resolve6, reject) => {
|
|
573
709
|
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
574
710
|
stdio: "ignore",
|
|
575
711
|
detached: true
|
|
@@ -577,7 +713,7 @@ async function openUrl(url, options = {}) {
|
|
|
577
713
|
child.once("error", reject);
|
|
578
714
|
child.once("spawn", () => {
|
|
579
715
|
child.unref();
|
|
580
|
-
|
|
716
|
+
resolve6();
|
|
581
717
|
});
|
|
582
718
|
});
|
|
583
719
|
}
|
|
@@ -631,35 +767,238 @@ function approvalBrowser(options) {
|
|
|
631
767
|
return { open: true, mode: "auto" };
|
|
632
768
|
}
|
|
633
769
|
function handshakeUrl(platformUrl, userCode) {
|
|
634
|
-
const url = new URL("/
|
|
770
|
+
const url = new URL("/studio", platformUrl);
|
|
635
771
|
url.searchParams.set("code", userCode);
|
|
636
772
|
return url.toString();
|
|
637
773
|
}
|
|
638
774
|
|
|
775
|
+
// src/provision-credentials.ts
|
|
776
|
+
var import_apps = require("@odla-ai/apps");
|
|
777
|
+
async function provisionEnvCredentials(opts) {
|
|
778
|
+
const tenantId = (0, import_apps.tenantIdFor)(opts.cfg.app.id, opts.env);
|
|
779
|
+
const prior = opts.credentials?.envs[opts.env];
|
|
780
|
+
let credentials = opts.credentials;
|
|
781
|
+
let dbKey = opts.cfg.services.includes("db") && !opts.rotateDb ? prior?.dbKey : void 0;
|
|
782
|
+
let o11yToken = opts.cfg.services.includes("o11y") && !opts.rotateO11y ? prior?.o11yToken : void 0;
|
|
783
|
+
if (opts.cfg.services.includes("db")) {
|
|
784
|
+
if (dbKey) {
|
|
785
|
+
opts.stdout.log(`${opts.env}: reusing local db key for ${tenantId}`);
|
|
786
|
+
} else {
|
|
787
|
+
dbKey = await mintDbKey(opts, tenantId);
|
|
788
|
+
credentials = save(opts, credentials, tenantId, { dbKey });
|
|
789
|
+
opts.stdout.log(`${opts.env}: minted db key for ${tenantId}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (opts.cfg.services.includes("o11y")) {
|
|
793
|
+
if (o11yToken) {
|
|
794
|
+
opts.stdout.log(`${opts.env}: reusing local o11y ingest token`);
|
|
795
|
+
} else {
|
|
796
|
+
o11yToken = await issueO11yToken(opts);
|
|
797
|
+
credentials = save(opts, credentials, tenantId, { ...dbKey ? { dbKey } : {}, o11yToken });
|
|
798
|
+
opts.stdout.log(`${opts.env}: ${opts.rotateO11y ? "rotated" : "issued"} o11y ingest token`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return save(opts, credentials, tenantId, {
|
|
802
|
+
...dbKey ? { dbKey } : {},
|
|
803
|
+
...o11yToken ? { o11yToken } : {}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
function save(opts, current, tenantId, values) {
|
|
807
|
+
const next = mergeCredential(current, {
|
|
808
|
+
appId: opts.cfg.app.id,
|
|
809
|
+
platformUrl: opts.cfg.platformUrl,
|
|
810
|
+
dbEndpoint: opts.cfg.dbEndpoint,
|
|
811
|
+
env: opts.env,
|
|
812
|
+
tenantId,
|
|
813
|
+
...values
|
|
814
|
+
});
|
|
815
|
+
if (opts.write) writePrivateJson(opts.cfg.local.credentialsFile, next);
|
|
816
|
+
return next;
|
|
817
|
+
}
|
|
818
|
+
async function mintDbKey(opts, tenantId) {
|
|
819
|
+
const headers = { authorization: `Bearer ${opts.developerToken}`, "content-type": "application/json" };
|
|
820
|
+
let res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
821
|
+
method: "POST",
|
|
822
|
+
headers,
|
|
823
|
+
body: "{}"
|
|
824
|
+
});
|
|
825
|
+
if (res.status === 404) {
|
|
826
|
+
const created = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps`, {
|
|
827
|
+
method: "POST",
|
|
828
|
+
headers,
|
|
829
|
+
body: JSON.stringify({
|
|
830
|
+
name: opts.env === "prod" ? opts.cfg.app.name : `${opts.cfg.app.name} (${opts.env})`,
|
|
831
|
+
appId: tenantId
|
|
832
|
+
})
|
|
833
|
+
});
|
|
834
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
|
|
835
|
+
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
836
|
+
method: "POST",
|
|
837
|
+
headers,
|
|
838
|
+
body: "{}"
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
|
|
842
|
+
const body = await res.json();
|
|
843
|
+
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
844
|
+
return body.key;
|
|
845
|
+
}
|
|
846
|
+
async function issueO11yToken(opts) {
|
|
847
|
+
const suffix = opts.rotateO11y ? "/rotate" : "";
|
|
848
|
+
const res = await opts.fetch(
|
|
849
|
+
`${opts.cfg.platformUrl}/o11y/${encodeURIComponent(opts.cfg.app.id)}/token${suffix}?env=${encodeURIComponent(opts.env)}`,
|
|
850
|
+
{ method: "POST", headers: { authorization: `Bearer ${opts.developerToken}` } }
|
|
851
|
+
);
|
|
852
|
+
if (res.status === 409 && !opts.rotateO11y) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`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`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
|
|
858
|
+
const body = await res.json();
|
|
859
|
+
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
860
|
+
return body.token;
|
|
861
|
+
}
|
|
862
|
+
async function safeText(res) {
|
|
863
|
+
try {
|
|
864
|
+
return redactSecrets((await res.text()).slice(0, 500));
|
|
865
|
+
} catch {
|
|
866
|
+
return "";
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/secrets.ts
|
|
871
|
+
var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
872
|
+
async function secretsPush(options) {
|
|
873
|
+
await secretsPushImpl(options, true);
|
|
874
|
+
}
|
|
875
|
+
async function secretsPushAfterPreflight(options) {
|
|
876
|
+
await secretsPushImpl(options, false);
|
|
877
|
+
}
|
|
878
|
+
async function secretsPushImpl(options, preflight) {
|
|
879
|
+
const out = options.stdout ?? console;
|
|
880
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
881
|
+
const env = options.env;
|
|
882
|
+
if (!cfg.envs.includes(env)) {
|
|
883
|
+
throw new Error(`env "${env}" is not in config envs (${cfg.envs.join(", ")})`);
|
|
884
|
+
}
|
|
885
|
+
if (PROD_ENV_NAMES.has(env) && !options.yes) {
|
|
886
|
+
throw new Error(`refusing to push a secret to "${env}" without --yes`);
|
|
887
|
+
}
|
|
888
|
+
const credentialsPath = displayPath(cfg.local.credentialsFile, cfg.rootDir);
|
|
889
|
+
const credentials = readCredentials(cfg.local.credentialsFile);
|
|
890
|
+
if (!credentials) throw new Error(`no credentials at ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
891
|
+
if (credentials.appId !== cfg.app.id) {
|
|
892
|
+
throw new Error(`credentials at ${credentialsPath} are for "${credentials.appId}", not "${cfg.app.id}"`);
|
|
893
|
+
}
|
|
894
|
+
const entry = credentials.envs[env];
|
|
895
|
+
if (!entry) throw new Error(`no credentials for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
896
|
+
const secrets = [];
|
|
897
|
+
if (cfg.services.includes("o11y")) {
|
|
898
|
+
if (!entry.o11yToken) throw new Error(`no o11y token for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
899
|
+
secrets.push({ name: "ODLA_O11Y_TOKEN", value: entry.o11yToken });
|
|
900
|
+
}
|
|
901
|
+
if (cfg.services.includes("db")) {
|
|
902
|
+
if (!entry.dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
903
|
+
secrets.push({ name: "ODLA_API_KEY", value: entry.dbKey });
|
|
904
|
+
}
|
|
905
|
+
if (secrets.length === 0) {
|
|
906
|
+
out.log(`${env}: no configured Worker secrets to push`);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
|
|
910
|
+
const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
|
|
911
|
+
if (options.dryRun) {
|
|
912
|
+
assertWranglerConfig(cfg);
|
|
913
|
+
for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const run = options.runner ?? defaultRunner;
|
|
917
|
+
if (preflight) await preflightLoadedConfig(cfg, run);
|
|
918
|
+
for (const s of secrets) {
|
|
919
|
+
const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
|
|
920
|
+
if (result.code !== 0) {
|
|
921
|
+
throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
|
|
922
|
+
}
|
|
923
|
+
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function preflightSecretsPush(options) {
|
|
927
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
928
|
+
if (!cfg.services.some((service) => service === "db" || service === "o11y")) return;
|
|
929
|
+
await preflightLoadedConfig(cfg, options.runner ?? defaultRunner);
|
|
930
|
+
}
|
|
931
|
+
async function preflightLoadedConfig(cfg, run) {
|
|
932
|
+
assertWranglerConfig(cfg);
|
|
933
|
+
if (!await wranglerLoggedIn(run, cfg.rootDir)) {
|
|
934
|
+
throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function assertWranglerConfig(cfg) {
|
|
938
|
+
if (!findWranglerConfig(cfg.rootDir)) {
|
|
939
|
+
throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
639
943
|
// src/provision.ts
|
|
640
944
|
async function provision(options) {
|
|
641
945
|
const out = options.stdout ?? console;
|
|
642
946
|
const cfg = await loadProjectConfig(options.configPath);
|
|
643
947
|
const plan = buildPlan(cfg);
|
|
948
|
+
const hasDb = cfg.services.includes("db");
|
|
949
|
+
const hasO11y = cfg.services.includes("o11y");
|
|
950
|
+
if (options.rotateO11yToken && !hasO11y) {
|
|
951
|
+
throw new Error("--rotate-o11y-token requires the o11y service in odla.config.mjs");
|
|
952
|
+
}
|
|
953
|
+
if (options.pushSecrets && options.writeCredentials === false) {
|
|
954
|
+
throw new Error("--push-secrets cannot be combined with --no-write-credentials");
|
|
955
|
+
}
|
|
644
956
|
out.log(`odla-ai: ${plan.appName} (${plan.appId})`);
|
|
645
957
|
out.log(` platform: ${plan.platformUrl}`);
|
|
646
958
|
out.log(` db: ${plan.dbEndpoint}`);
|
|
647
959
|
out.log(` envs: ${plan.envs.join(", ")}`);
|
|
648
960
|
out.log(` services: ${plan.services.join(", ")}`);
|
|
649
|
-
const
|
|
650
|
-
|
|
961
|
+
const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
|
|
962
|
+
if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
|
|
963
|
+
throw new Error(
|
|
964
|
+
`refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
|
|
968
|
+
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
651
969
|
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
652
970
|
if (options.dryRun) {
|
|
653
971
|
out.log("dry run: no network calls or file writes");
|
|
654
972
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
655
973
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
656
|
-
out.log(` ai: ${cfg.ai?.provider ?? "not configured"}`);
|
|
974
|
+
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
975
|
+
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
657
976
|
return;
|
|
658
977
|
}
|
|
978
|
+
let credentials = readCredentials(cfg.local.credentialsFile);
|
|
979
|
+
if (credentials && credentials.appId !== cfg.app.id) {
|
|
980
|
+
throw new Error(
|
|
981
|
+
`credentials at ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} are for "${credentials.appId}", not "${cfg.app.id}"`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
const rotatesO11y = !!(options.rotateKeys || options.rotateO11yToken);
|
|
985
|
+
const missingO11y = cfg.envs.some((env) => !credentials?.envs[env]?.o11yToken);
|
|
986
|
+
const missingDb = cfg.envs.some((env) => !credentials?.envs[env]?.dbKey);
|
|
987
|
+
const losesShownOnceCredential = hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y);
|
|
988
|
+
if (options.writeCredentials === false && losesShownOnceCredential) {
|
|
989
|
+
throw new Error("credential issuance/rotation requires the private credentials file; remove --no-write-credentials");
|
|
990
|
+
}
|
|
991
|
+
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
992
|
+
if (cfg.local.gitignore) {
|
|
993
|
+
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
994
|
+
}
|
|
995
|
+
if (options.pushSecrets) {
|
|
996
|
+
await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
|
|
997
|
+
out.log("secrets: Wrangler config and login preflight passed");
|
|
998
|
+
}
|
|
659
999
|
const doFetch = options.fetch ?? fetch;
|
|
660
1000
|
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
661
|
-
const
|
|
662
|
-
const apps = (0, import_apps.createAppsClient)({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
|
|
1001
|
+
const apps = (0, import_apps2.createAppsClient)({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
|
|
663
1002
|
const existing = await readRegistryApp(cfg, token, doFetch);
|
|
664
1003
|
if (existing) {
|
|
665
1004
|
out.log(`app: ${cfg.app.id} already exists`);
|
|
@@ -693,26 +1032,40 @@ async function provision(options) {
|
|
|
693
1032
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
694
1033
|
}
|
|
695
1034
|
}
|
|
696
|
-
let credentials = readCredentials(cfg.local.credentialsFile);
|
|
697
1035
|
for (const env of cfg.envs) {
|
|
698
|
-
const tenantId = (0,
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1036
|
+
const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
|
|
1037
|
+
credentials = await provisionEnvCredentials({
|
|
1038
|
+
cfg,
|
|
1039
|
+
env,
|
|
1040
|
+
developerToken: token,
|
|
1041
|
+
credentials,
|
|
1042
|
+
rotateDb: !!options.rotateKeys,
|
|
1043
|
+
rotateO11y: rotatesO11y,
|
|
1044
|
+
write: options.writeCredentials !== false,
|
|
1045
|
+
fetch: doFetch,
|
|
1046
|
+
stdout: out
|
|
1047
|
+
});
|
|
1048
|
+
const dbKey = credentials.envs[env]?.dbKey;
|
|
1049
|
+
if (options.pushSecrets) {
|
|
1050
|
+
try {
|
|
1051
|
+
await secretsPushAfterPreflight({
|
|
1052
|
+
configPath: cfg.configPath,
|
|
1053
|
+
env,
|
|
1054
|
+
yes: options.yes,
|
|
1055
|
+
runner: options.secretRunner,
|
|
1056
|
+
stdout: out
|
|
1057
|
+
});
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1060
|
+
const consent = env === "prod" || env === "production" ? " --yes" : "";
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
`${message}
|
|
1063
|
+
${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}${consent}" without issuing or rotating again`,
|
|
1064
|
+
{ cause: error }
|
|
1065
|
+
);
|
|
713
1066
|
}
|
|
714
1067
|
}
|
|
715
|
-
if (schema) {
|
|
1068
|
+
if (schema && dbKey) {
|
|
716
1069
|
await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
|
|
717
1070
|
out.log(`${env}: schema pushed`);
|
|
718
1071
|
}
|
|
@@ -720,7 +1073,7 @@ async function provision(options) {
|
|
|
720
1073
|
await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
|
|
721
1074
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
722
1075
|
}
|
|
723
|
-
if (cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1076
|
+
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
724
1077
|
const key = import_node_process3.default.env[cfg.ai.keyEnv];
|
|
725
1078
|
if (key) {
|
|
726
1079
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
@@ -730,36 +1083,17 @@ async function provision(options) {
|
|
|
730
1083
|
out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
|
|
731
1084
|
}
|
|
732
1085
|
}
|
|
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
1086
|
}
|
|
743
|
-
if (cfg.local.gitignore) ensureGitignore(cfg.rootDir);
|
|
744
1087
|
if (options.writeCredentials !== false && credentials) {
|
|
745
|
-
|
|
746
|
-
out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600, gitignored)`);
|
|
1088
|
+
const ignored = cfg.local.gitignore && gitignoreEntry(cfg.rootDir, cfg.local.credentialsFile);
|
|
1089
|
+
out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600${ignored ? ", gitignored" : ""})`);
|
|
747
1090
|
}
|
|
748
|
-
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
749
1091
|
if (devVarsTarget && credentials) {
|
|
750
1092
|
const env = cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
|
|
751
1093
|
writeDevVars(devVarsTarget, credentials, env, o11yDevVars(cfg));
|
|
752
1094
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
753
1095
|
}
|
|
754
1096
|
}
|
|
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
1097
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
764
1098
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
765
1099
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -769,50 +1103,13 @@ async function readRegistryApp(cfg, token, doFetch) {
|
|
|
769
1103
|
const json = await res.json();
|
|
770
1104
|
return json.app ?? null;
|
|
771
1105
|
}
|
|
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
1106
|
async function postJson(doFetch, url, bearer, body) {
|
|
810
1107
|
const res = await doFetch(url, {
|
|
811
1108
|
method: "POST",
|
|
812
1109
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
813
1110
|
body: JSON.stringify(body)
|
|
814
1111
|
});
|
|
815
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
1112
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
|
|
816
1113
|
}
|
|
817
1114
|
function normalizeClerkConfig(value) {
|
|
818
1115
|
if (!value) return null;
|
|
@@ -829,12 +1126,7 @@ function defaultSecretName(provider) {
|
|
|
829
1126
|
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
830
1127
|
return names[provider] ?? `${provider}_api_key`;
|
|
831
1128
|
}
|
|
832
|
-
function
|
|
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) {
|
|
1129
|
+
async function safeText2(res) {
|
|
838
1130
|
try {
|
|
839
1131
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
840
1132
|
} catch {
|
|
@@ -842,106 +1134,253 @@ async function safeText(res) {
|
|
|
842
1134
|
}
|
|
843
1135
|
}
|
|
844
1136
|
|
|
845
|
-
// src/
|
|
846
|
-
var
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1137
|
+
// src/skill.ts
|
|
1138
|
+
var import_node_fs6 = require("fs");
|
|
1139
|
+
var import_node_os = require("os");
|
|
1140
|
+
var import_node_path6 = require("path");
|
|
1141
|
+
var import_node_url2 = require("url");
|
|
1142
|
+
|
|
1143
|
+
// src/skill-adapters.ts
|
|
1144
|
+
var PROJECT_INSTRUCTIONS = `<!-- odla-ai agent setup:start -->
|
|
1145
|
+
## odla agent workflow
|
|
1146
|
+
|
|
1147
|
+
For work that creates an odla app or adds odla services, read and follow
|
|
1148
|
+
\`.agents/skills/odla/SKILL.md\`. It dispatches an existing static site to
|
|
1149
|
+
\`.agents/skills/odla-migrate/SKILL.md\`. For production telemetry triage, use
|
|
1150
|
+
\`.agents/skills/odla-o11y-debug/SKILL.md\`.
|
|
1151
|
+
|
|
1152
|
+
The complete runbooks and their references are installed in this repository.
|
|
1153
|
+
Do not fetch online odla documentation as setup context. Network access is only
|
|
1154
|
+
needed when a runbook deliberately calls the odla service, npm, Cloudflare, or
|
|
1155
|
+
another configured provider.
|
|
1156
|
+
<!-- odla-ai agent setup:end -->`;
|
|
1157
|
+
var CURSOR_RULE = `---
|
|
1158
|
+
description: Build, migrate, provision, secure, or debug an odla app using the locally installed odla runbooks
|
|
1159
|
+
globs:
|
|
1160
|
+
alwaysApply: false
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
${PROJECT_INSTRUCTIONS}
|
|
1164
|
+
`;
|
|
1165
|
+
function claudeAdapter(skill, canonical) {
|
|
1166
|
+
const match = canonical.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1167
|
+
if (!match) throw new Error(`bundled skill ${skill} has no YAML frontmatter`);
|
|
1168
|
+
const lines = match[1].split(/\r?\n/);
|
|
1169
|
+
const frontmatter = [];
|
|
1170
|
+
let keepIndented = false;
|
|
1171
|
+
for (const line of lines) {
|
|
1172
|
+
if (line.startsWith("name:") || line.startsWith("description:")) {
|
|
1173
|
+
frontmatter.push(line);
|
|
1174
|
+
keepIndented = line.startsWith("description:");
|
|
1175
|
+
} else if (keepIndented && /^\s+/.test(line)) {
|
|
1176
|
+
frontmatter.push(line);
|
|
1177
|
+
} else {
|
|
1178
|
+
keepIndented = false;
|
|
886
1179
|
}
|
|
887
|
-
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
888
1180
|
}
|
|
1181
|
+
return `---
|
|
1182
|
+
${frontmatter.join("\n")}
|
|
1183
|
+
---
|
|
1184
|
+
|
|
1185
|
+
# ${skill} (odla adapter)
|
|
1186
|
+
|
|
1187
|
+
Read \`../../../.agents/skills/${skill}/SKILL.md\` completely and follow it. That
|
|
1188
|
+
canonical local skill owns every reference path and contains the full offline
|
|
1189
|
+
runbook. Do not substitute a remote documentation page for the installed copy.
|
|
1190
|
+
`;
|
|
889
1191
|
}
|
|
890
1192
|
|
|
891
1193
|
// src/skill.ts
|
|
892
|
-
var
|
|
893
|
-
var import_node_os = require("os");
|
|
894
|
-
var import_node_path7 = require("path");
|
|
895
|
-
var import_node_url2 = require("url");
|
|
1194
|
+
var AGENT_HARNESSES = ["claude", "codex", "cursor", "copilot", "gemini", "agents"];
|
|
896
1195
|
function installSkill(options = {}) {
|
|
897
1196
|
const out = options.stdout ?? console;
|
|
898
1197
|
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
1198
|
const files = listFiles(sourceDir);
|
|
901
1199
|
if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
|
|
902
|
-
const
|
|
903
|
-
const
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1200
|
+
const harnesses = normalizeHarnesses(options.harnesses, options.global === true);
|
|
1201
|
+
const root = (0, import_node_path6.resolve)(options.dir ?? process.cwd());
|
|
1202
|
+
const home = (0, import_node_path6.resolve)(options.homeDir ?? (0, import_node_os.homedir)());
|
|
1203
|
+
const plans = /* @__PURE__ */ new Map();
|
|
1204
|
+
const targets = /* @__PURE__ */ new Map();
|
|
1205
|
+
const rememberTarget = (harness, target) => {
|
|
1206
|
+
const current = targets.get(harness) ?? /* @__PURE__ */ new Set();
|
|
1207
|
+
current.add(target);
|
|
1208
|
+
targets.set(harness, current);
|
|
1209
|
+
};
|
|
1210
|
+
const plan = (target, content, managedMerge = false, boundary = root) => {
|
|
1211
|
+
const existing = plans.get(target);
|
|
1212
|
+
if (existing && existing.content !== content) throw new Error(`internal agent setup conflict for ${target}`);
|
|
1213
|
+
plans.set(target, { target, content, boundary, managedMerge });
|
|
1214
|
+
};
|
|
1215
|
+
const planSkillTree = (targetDir2, boundary = root) => {
|
|
1216
|
+
for (const rel of files) plan((0, import_node_path6.join)(targetDir2, rel), (0, import_node_fs6.readFileSync)((0, import_node_path6.join)(sourceDir, rel), "utf8"), false, boundary);
|
|
1217
|
+
};
|
|
1218
|
+
let targetDir;
|
|
1219
|
+
if (options.global) {
|
|
1220
|
+
const claudeRoot = (0, import_node_path6.join)(home, ".claude", "skills");
|
|
1221
|
+
const codexRoot = (0, import_node_path6.resolve)(options.codexHomeDir ?? process.env.CODEX_HOME ?? (0, import_node_path6.join)(home, ".codex"), "skills");
|
|
1222
|
+
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
1223
|
+
for (const harness of harnesses) {
|
|
1224
|
+
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
1225
|
+
planSkillTree(skillRoot, harness === "claude" ? home : (0, import_node_path6.dirname)((0, import_node_path6.dirname)(codexRoot)));
|
|
1226
|
+
rememberTarget(harness, skillRoot);
|
|
1227
|
+
}
|
|
1228
|
+
} else {
|
|
1229
|
+
const sharedRoot = (0, import_node_path6.join)(root, ".agents", "skills");
|
|
1230
|
+
planSkillTree(sharedRoot);
|
|
1231
|
+
const claudeRoot = (0, import_node_path6.join)(root, ".claude", "skills");
|
|
1232
|
+
targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
|
|
1233
|
+
for (const harness of harnesses) rememberTarget(harness, sharedRoot);
|
|
1234
|
+
if (harnesses.includes("claude")) {
|
|
1235
|
+
for (const skill of skillNames(files)) {
|
|
1236
|
+
const canonical = (0, import_node_fs6.readFileSync)((0, import_node_path6.join)(sourceDir, skill, "SKILL.md"), "utf8");
|
|
1237
|
+
plan((0, import_node_path6.join)(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
|
|
917
1238
|
}
|
|
1239
|
+
rememberTarget("claude", claudeRoot);
|
|
1240
|
+
}
|
|
1241
|
+
if (harnesses.includes("cursor")) {
|
|
1242
|
+
const cursorRule = (0, import_node_path6.join)(root, ".cursor", "rules", "odla.mdc");
|
|
1243
|
+
plan(cursorRule, CURSOR_RULE);
|
|
1244
|
+
rememberTarget("cursor", cursorRule);
|
|
1245
|
+
}
|
|
1246
|
+
if (harnesses.includes("agents")) {
|
|
1247
|
+
const agentsFile = (0, import_node_path6.join)(root, "AGENTS.md");
|
|
1248
|
+
plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1249
|
+
rememberTarget("agents", agentsFile);
|
|
1250
|
+
}
|
|
1251
|
+
if (harnesses.includes("copilot")) {
|
|
1252
|
+
const copilotFile = (0, import_node_path6.join)(root, ".github", "copilot-instructions.md");
|
|
1253
|
+
plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1254
|
+
rememberTarget("copilot", copilotFile);
|
|
1255
|
+
}
|
|
1256
|
+
if (harnesses.includes("gemini")) {
|
|
1257
|
+
const geminiFile = (0, import_node_path6.join)(root, "GEMINI.md");
|
|
1258
|
+
plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1259
|
+
rememberTarget("gemini", geminiFile);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
const writtenPaths = /* @__PURE__ */ new Set();
|
|
1263
|
+
const unchangedPaths = /* @__PURE__ */ new Set();
|
|
1264
|
+
const conflicts = [];
|
|
1265
|
+
for (const file of plans.values()) {
|
|
1266
|
+
const symlink = symlinkedComponent(file.boundary, file.target);
|
|
1267
|
+
if (symlink) {
|
|
1268
|
+
conflicts.push(`${file.target} (redirected by symbolic link ${symlink})`);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (!(0, import_node_fs6.existsSync)(file.target)) {
|
|
1272
|
+
writtenPaths.add(file.target);
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const current = (0, import_node_fs6.readFileSync)(file.target, "utf8");
|
|
1276
|
+
if (current === file.content) {
|
|
1277
|
+
unchangedPaths.add(file.target);
|
|
1278
|
+
} else if (file.managedMerge || options.force) {
|
|
1279
|
+
writtenPaths.add(file.target);
|
|
1280
|
+
unchangedPaths.delete(file.target);
|
|
1281
|
+
} else {
|
|
1282
|
+
conflicts.push(file.target);
|
|
918
1283
|
}
|
|
919
|
-
written.push(rel);
|
|
920
1284
|
}
|
|
921
1285
|
if (conflicts.length > 0) {
|
|
922
1286
|
throw new Error(
|
|
923
|
-
`
|
|
924
|
-
${conflicts.map((f) => ` - ${
|
|
1287
|
+
`agent setup files modified locally (re-run with --force to replace only odla-managed content):
|
|
1288
|
+
${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
925
1289
|
);
|
|
926
1290
|
}
|
|
927
|
-
for (const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1291
|
+
for (const file of plans.values()) {
|
|
1292
|
+
if (!(0, import_node_fs6.existsSync)(file.target) || (0, import_node_fs6.readFileSync)(file.target, "utf8") !== file.content) {
|
|
1293
|
+
(0, import_node_fs6.mkdirSync)((0, import_node_path6.dirname)(file.target), { recursive: true });
|
|
1294
|
+
(0, import_node_fs6.writeFileSync)(file.target, file.content);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const skills = skillNames(files);
|
|
1298
|
+
const installations = harnesses.map((harness) => ({ harness, targets: [...targets.get(harness) ?? []] }));
|
|
1299
|
+
out.log(`agent harnesses: ${harnesses.join(", ")}`);
|
|
1300
|
+
out.log(`offline skills: ${skills.join(", ")}`);
|
|
1301
|
+
for (const installation of installations) out.log(`${installation.harness}: ${installation.targets.join(", ")}`);
|
|
1302
|
+
out.log(`installed ${writtenPaths.size} managed file(s)${unchangedPaths.size ? ` (${unchangedPaths.size} unchanged)` : ""}`);
|
|
1303
|
+
return {
|
|
1304
|
+
targetDir,
|
|
1305
|
+
written: pathsUnder(targetDir, writtenPaths),
|
|
1306
|
+
unchanged: pathsUnder(targetDir, unchangedPaths),
|
|
1307
|
+
writtenPaths: [...writtenPaths].sort(),
|
|
1308
|
+
unchangedPaths: [...unchangedPaths].sort(),
|
|
1309
|
+
harnesses: installations
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
function pathsUnder(root, paths) {
|
|
1313
|
+
return [...paths].map((path) => (0, import_node_path6.relative)(root, path)).filter((path) => path !== ".." && !path.startsWith(`..${import_node_path6.sep}`) && !(0, import_node_path6.isAbsolute)(path)).sort();
|
|
1314
|
+
}
|
|
1315
|
+
function normalizeHarnesses(values, global) {
|
|
1316
|
+
const requested = values?.length ? values : ["claude"];
|
|
1317
|
+
const allowed = /* @__PURE__ */ new Set([...AGENT_HARNESSES, "all"]);
|
|
1318
|
+
for (const harness of requested) {
|
|
1319
|
+
if (!allowed.has(harness)) {
|
|
1320
|
+
throw new Error(`unknown agent harness "${harness}"; choose all, ${AGENT_HARNESSES.join(", ")}`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
const expanded = requested.includes("all") ? global ? ["claude", "codex"] : [...AGENT_HARNESSES] : [...new Set(requested)];
|
|
1324
|
+
if (global) {
|
|
1325
|
+
const unsupported = expanded.filter((harness) => harness !== "claude" && harness !== "codex");
|
|
1326
|
+
if (unsupported.length) {
|
|
1327
|
+
throw new Error(`--global supports claude and codex only; ${unsupported.join(", ")} require a project-local adapter`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return expanded;
|
|
1331
|
+
}
|
|
1332
|
+
function managedFileContent(path, block, force, boundary) {
|
|
1333
|
+
const symlink = symlinkedComponent(boundary, path);
|
|
1334
|
+
if (symlink) throw new Error(`refusing to manage ${path}: symbolic link component ${symlink}`);
|
|
1335
|
+
if (!(0, import_node_fs6.existsSync)(path)) return `${block}
|
|
1336
|
+
`;
|
|
1337
|
+
const current = (0, import_node_fs6.readFileSync)(path, "utf8");
|
|
1338
|
+
const start = "<!-- odla-ai agent setup:start -->";
|
|
1339
|
+
const end = "<!-- odla-ai agent setup:end -->";
|
|
1340
|
+
const startAt = current.indexOf(start);
|
|
1341
|
+
const endAt = current.indexOf(end);
|
|
1342
|
+
if (startAt === -1 !== (endAt === -1) || startAt !== -1 && endAt < startAt) {
|
|
1343
|
+
throw new Error(`malformed odla-managed section in ${path}`);
|
|
1344
|
+
}
|
|
1345
|
+
if (startAt === -1) {
|
|
1346
|
+
const separator = current.length === 0 || current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
|
|
1347
|
+
return `${current}${separator}${block}
|
|
1348
|
+
`;
|
|
1349
|
+
}
|
|
1350
|
+
const afterEnd = endAt + end.length;
|
|
1351
|
+
const existing = current.slice(startAt, afterEnd);
|
|
1352
|
+
if (existing !== block && !force) {
|
|
1353
|
+
throw new Error(`odla-managed section modified locally in ${path}; re-run with --force to replace that section`);
|
|
1354
|
+
}
|
|
1355
|
+
return `${current.slice(0, startAt)}${block}${current.slice(afterEnd)}`;
|
|
1356
|
+
}
|
|
1357
|
+
function symlinkedComponent(boundary, target) {
|
|
1358
|
+
const rel = (0, import_node_path6.relative)(boundary, target);
|
|
1359
|
+
if (rel === ".." || rel.startsWith(`..${import_node_path6.sep}`) || (0, import_node_path6.isAbsolute)(rel)) {
|
|
1360
|
+
throw new Error(`agent setup target escapes its install root: ${target}`);
|
|
931
1361
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1362
|
+
let current = boundary;
|
|
1363
|
+
for (const part of rel.split(import_node_path6.sep).filter(Boolean)) {
|
|
1364
|
+
current = (0, import_node_path6.join)(current, part);
|
|
1365
|
+
try {
|
|
1366
|
+
if ((0, import_node_fs6.lstatSync)(current).isSymbolicLink()) return current;
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
if (error.code !== "ENOENT") throw error;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return void 0;
|
|
1372
|
+
}
|
|
1373
|
+
function skillNames(files) {
|
|
1374
|
+
return [...new Set(files.filter((file) => /(^|[\\/])SKILL\.md$/.test(file)).map((file) => file.split(/[\\/]/)[0]))].sort();
|
|
936
1375
|
}
|
|
937
1376
|
function listFiles(dir) {
|
|
938
1377
|
if (!(0, import_node_fs6.existsSync)(dir)) return [];
|
|
939
1378
|
const results = [];
|
|
940
1379
|
const walk = (current) => {
|
|
941
1380
|
for (const entry of (0, import_node_fs6.readdirSync)(current, { withFileTypes: true })) {
|
|
942
|
-
const path = (0,
|
|
1381
|
+
const path = (0, import_node_path6.join)(current, entry.name);
|
|
943
1382
|
if (entry.isDirectory()) walk(path);
|
|
944
|
-
else results.push((0,
|
|
1383
|
+
else results.push((0, import_node_path6.relative)(dir, path));
|
|
945
1384
|
}
|
|
946
1385
|
};
|
|
947
1386
|
walk(dir);
|
|
@@ -1004,7 +1443,7 @@ async function getJson(doFetch, url, bearer) {
|
|
|
1004
1443
|
const res = await doFetch(url, {
|
|
1005
1444
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
1006
1445
|
});
|
|
1007
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
1446
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
|
|
1008
1447
|
return res.json();
|
|
1009
1448
|
}
|
|
1010
1449
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -1013,7 +1452,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
1013
1452
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1014
1453
|
body: JSON.stringify(body)
|
|
1015
1454
|
});
|
|
1016
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
1455
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
|
|
1017
1456
|
return res.json();
|
|
1018
1457
|
}
|
|
1019
1458
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -1021,7 +1460,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
1021
1460
|
url.searchParams.set("env", env);
|
|
1022
1461
|
return url.toString();
|
|
1023
1462
|
}
|
|
1024
|
-
async function
|
|
1463
|
+
async function safeText3(res) {
|
|
1025
1464
|
try {
|
|
1026
1465
|
return (await res.text()).slice(0, 500);
|
|
1027
1466
|
} catch {
|
|
@@ -1034,14 +1473,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1034
1473
|
const parsed = parseArgv(argv);
|
|
1035
1474
|
const command = parsed.positionals[0] ?? "help";
|
|
1036
1475
|
if (command === "version" || command === "-v" || parsed.options.version === true) {
|
|
1476
|
+
assertArgs(parsed, ["version"], 1);
|
|
1037
1477
|
console.log(cliVersion());
|
|
1038
1478
|
return;
|
|
1039
1479
|
}
|
|
1040
1480
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
1481
|
+
assertArgs(parsed, ["help"], 1);
|
|
1041
1482
|
printHelp();
|
|
1042
1483
|
return;
|
|
1043
1484
|
}
|
|
1044
1485
|
if (command === "init") {
|
|
1486
|
+
assertArgs(parsed, ["app-id", "name", "config", "env", "services", "ai-provider", "force"], 1);
|
|
1045
1487
|
initProject({
|
|
1046
1488
|
appId: requiredString(parsed.options["app-id"], "--app-id"),
|
|
1047
1489
|
name: requiredString(parsed.options.name, "--name"),
|
|
@@ -1054,15 +1496,39 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1054
1496
|
return;
|
|
1055
1497
|
}
|
|
1056
1498
|
if (command === "doctor") {
|
|
1499
|
+
assertArgs(parsed, ["config"], 1);
|
|
1057
1500
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
1058
1501
|
return;
|
|
1059
1502
|
}
|
|
1503
|
+
if (command === "capabilities") {
|
|
1504
|
+
assertArgs(parsed, ["json"], 1);
|
|
1505
|
+
printCapabilities(parsed.options.json === true);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1060
1508
|
if (command === "provision") {
|
|
1509
|
+
assertArgs(
|
|
1510
|
+
parsed,
|
|
1511
|
+
[
|
|
1512
|
+
"config",
|
|
1513
|
+
"dry-run",
|
|
1514
|
+
"rotate-keys",
|
|
1515
|
+
"rotate-o11y-token",
|
|
1516
|
+
"push-secrets",
|
|
1517
|
+
"write-credentials",
|
|
1518
|
+
"write-dev-vars",
|
|
1519
|
+
"token",
|
|
1520
|
+
"open",
|
|
1521
|
+
"yes"
|
|
1522
|
+
],
|
|
1523
|
+
1
|
|
1524
|
+
);
|
|
1061
1525
|
const writeDevVars2 = parsed.options["write-dev-vars"];
|
|
1062
1526
|
const opts = {
|
|
1063
1527
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1064
1528
|
dryRun: parsed.options["dry-run"] === true,
|
|
1065
1529
|
rotateKeys: parsed.options["rotate-keys"] === true,
|
|
1530
|
+
rotateO11yToken: parsed.options["rotate-o11y-token"] === true,
|
|
1531
|
+
pushSecrets: parsed.options["push-secrets"] === true,
|
|
1066
1532
|
writeCredentials: parsed.options["write-credentials"] !== false,
|
|
1067
1533
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
1068
1534
|
token: stringOpt(parsed.options.token),
|
|
@@ -1073,18 +1539,34 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1073
1539
|
return;
|
|
1074
1540
|
}
|
|
1075
1541
|
if (command === "smoke") {
|
|
1542
|
+
assertArgs(parsed, ["config", "env"], 1);
|
|
1076
1543
|
await smoke({
|
|
1077
1544
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1078
1545
|
env: stringOpt(parsed.options.env)
|
|
1079
1546
|
});
|
|
1080
1547
|
return;
|
|
1081
1548
|
}
|
|
1549
|
+
if (command === "setup") {
|
|
1550
|
+
assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 1);
|
|
1551
|
+
installSkill({
|
|
1552
|
+
dir: stringOpt(parsed.options.dir),
|
|
1553
|
+
global: parsed.options.global === true,
|
|
1554
|
+
harnesses: harnessList(parsed),
|
|
1555
|
+
force: parsed.options.force === true
|
|
1556
|
+
});
|
|
1557
|
+
console.log(
|
|
1558
|
+
"\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."
|
|
1559
|
+
);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1082
1562
|
if (command === "skill") {
|
|
1083
1563
|
const sub = parsed.positionals[1];
|
|
1084
1564
|
if (sub !== "install") throw new Error(`unknown skill subcommand "${sub ?? ""}". Try "odla-ai skill install".`);
|
|
1565
|
+
assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 2);
|
|
1085
1566
|
installSkill({
|
|
1086
1567
|
dir: stringOpt(parsed.options.dir),
|
|
1087
1568
|
global: parsed.options.global === true,
|
|
1569
|
+
harnesses: harnessList(parsed),
|
|
1088
1570
|
force: parsed.options.force === true
|
|
1089
1571
|
});
|
|
1090
1572
|
return;
|
|
@@ -1092,6 +1574,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1092
1574
|
if (command === "secrets") {
|
|
1093
1575
|
const sub = parsed.positionals[1];
|
|
1094
1576
|
if (sub !== "push") throw new Error(`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev".`);
|
|
1577
|
+
assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
|
|
1095
1578
|
await secretsPush({
|
|
1096
1579
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1097
1580
|
env: requiredString(parsed.options.env, "--env"),
|
|
@@ -1102,6 +1585,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1102
1585
|
}
|
|
1103
1586
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
1104
1587
|
}
|
|
1588
|
+
function assertArgs(parsed, allowedOptions, maxPositionals) {
|
|
1589
|
+
const allowed = new Set(allowedOptions);
|
|
1590
|
+
for (const name of Object.keys(parsed.options)) {
|
|
1591
|
+
if (!allowed.has(name)) {
|
|
1592
|
+
throw new Error(`unknown option "--${name}"; run "odla-ai help" for supported options`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (parsed.positionals.length > maxPositionals) {
|
|
1596
|
+
throw new Error(`unexpected argument "${parsed.positionals[maxPositionals]}"; run "odla-ai help"`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1105
1599
|
function parseArgv(argv) {
|
|
1106
1600
|
const positionals = [];
|
|
1107
1601
|
const options = {};
|
|
@@ -1157,6 +1651,21 @@ function listOpt(value) {
|
|
|
1157
1651
|
const values = Array.isArray(value) ? value : [value];
|
|
1158
1652
|
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
1159
1653
|
}
|
|
1654
|
+
function harnessList(parsed) {
|
|
1655
|
+
const selected = [
|
|
1656
|
+
...harnessOption(parsed.options.agent, "--agent"),
|
|
1657
|
+
...harnessOption(parsed.options.harness, "--harness")
|
|
1658
|
+
];
|
|
1659
|
+
return selected.length ? selected : ["all"];
|
|
1660
|
+
}
|
|
1661
|
+
function harnessOption(value, flag) {
|
|
1662
|
+
if (value === void 0) return [];
|
|
1663
|
+
if (typeof value === "boolean") throw new Error(`${flag} requires a harness name`);
|
|
1664
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
1665
|
+
const parts = raw.flatMap((item) => item.split(","));
|
|
1666
|
+
if (parts.some((item) => !item.trim())) throw new Error(`${flag} requires a harness name`);
|
|
1667
|
+
return parts.map((item) => item.trim());
|
|
1668
|
+
}
|
|
1160
1669
|
function cliVersion() {
|
|
1161
1670
|
const pkg = JSON.parse((0, import_node_fs7.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
|
|
1162
1671
|
return pkg.version ?? "unknown";
|
|
@@ -1165,26 +1674,34 @@ function printHelp() {
|
|
|
1165
1674
|
console.log(`odla-ai
|
|
1166
1675
|
|
|
1167
1676
|
Usage:
|
|
1168
|
-
odla-ai
|
|
1677
|
+
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1678
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
1169
1679
|
odla-ai doctor [--config odla.config.mjs]
|
|
1170
|
-
odla-ai
|
|
1680
|
+
odla-ai capabilities [--json]
|
|
1681
|
+
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1171
1682
|
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
1172
|
-
odla-ai skill install [--dir <project>] [--global] [--force]
|
|
1683
|
+
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1173
1684
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1174
1685
|
odla-ai version
|
|
1175
1686
|
|
|
1176
1687
|
Commands:
|
|
1688
|
+
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1177
1689
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1178
1690
|
doctor Validate and summarize the project config without network calls.
|
|
1179
|
-
|
|
1691
|
+
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1692
|
+
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
1180
1693
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
1181
|
-
skill
|
|
1182
|
-
|
|
1694
|
+
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1695
|
+
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1696
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
|
|
1183
1697
|
version Print the CLI version.
|
|
1184
1698
|
|
|
1185
1699
|
Safety:
|
|
1186
|
-
|
|
1187
|
-
|
|
1700
|
+
New projects target dev only. Add prod explicitly to odla.config.mjs and pass
|
|
1701
|
+
--yes to provision it; use --dry-run first to inspect the resolved plan.
|
|
1702
|
+
Provision caches the approved developer token and service credentials under
|
|
1703
|
+
.odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
|
|
1704
|
+
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1188
1705
|
Provision opens the approval page automatically in interactive terminals;
|
|
1189
1706
|
use --open to force browser launch or --no-open to suppress it.
|
|
1190
1707
|
`);
|