@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
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/capabilities.ts
|
|
4
|
+
var CAPABILITIES = {
|
|
5
|
+
cli: [
|
|
6
|
+
"register the app and enable configured services per environment",
|
|
7
|
+
"issue, persist, and explicitly rotate configured service credentials",
|
|
8
|
+
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
9
|
+
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
10
|
+
"validate config offline and smoke-test a provisioned db environment"
|
|
11
|
+
],
|
|
12
|
+
agent: [
|
|
13
|
+
"install and import the selected odla SDKs",
|
|
14
|
+
"wrap the Worker with withObservability and choose useful telemetry",
|
|
15
|
+
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
16
|
+
],
|
|
17
|
+
human: [
|
|
18
|
+
"approve the odla device code and one-off third-party logins",
|
|
19
|
+
"consent to production changes with --yes",
|
|
20
|
+
"request destructive credential rotation explicitly",
|
|
21
|
+
"review application semantics, security findings, releases, and merges"
|
|
22
|
+
],
|
|
23
|
+
studio: [
|
|
24
|
+
"view telemetry and environment state",
|
|
25
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable"
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
function printCapabilities(json = false, out = console) {
|
|
29
|
+
if (json) {
|
|
30
|
+
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
out.log("odla-ai responsibility boundary\n");
|
|
34
|
+
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
35
|
+
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
36
|
+
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
37
|
+
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
38
|
+
}
|
|
39
|
+
function printGroup(out, heading, items) {
|
|
40
|
+
out.log(`${heading}:`);
|
|
41
|
+
for (const item of items) out.log(` - ${item}`);
|
|
42
|
+
out.log("");
|
|
43
|
+
}
|
|
44
|
+
|
|
3
45
|
// src/redact.ts
|
|
4
46
|
var REPLACEMENTS = [
|
|
5
47
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -24,7 +66,7 @@ import { existsSync, readFileSync } from "fs";
|
|
|
24
66
|
import { dirname, isAbsolute, resolve } from "path";
|
|
25
67
|
import { pathToFileURL } from "url";
|
|
26
68
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
27
|
-
var DEFAULT_ENVS = ["
|
|
69
|
+
var DEFAULT_ENVS = ["dev"];
|
|
28
70
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
29
71
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
30
72
|
const resolved = resolve(configPath);
|
|
@@ -126,12 +168,138 @@ function unique(values) {
|
|
|
126
168
|
|
|
127
169
|
// src/doctor-checks.ts
|
|
128
170
|
import { execFileSync } from "child_process";
|
|
129
|
-
import { existsSync as
|
|
130
|
-
import { join as join2, resolve as
|
|
171
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
172
|
+
import { join as join2, resolve as resolve3 } from "path";
|
|
173
|
+
|
|
174
|
+
// src/local.ts
|
|
175
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "fs";
|
|
176
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve as resolve2 } from "path";
|
|
177
|
+
var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
|
|
178
|
+
function readJsonFile(path) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function writePrivateJson(path, value) {
|
|
186
|
+
writePrivateText(path, `${JSON.stringify(value, null, 2)}
|
|
187
|
+
`);
|
|
188
|
+
}
|
|
189
|
+
function readCredentials(path) {
|
|
190
|
+
if (!existsSync2(path)) return null;
|
|
191
|
+
let value;
|
|
192
|
+
try {
|
|
193
|
+
value = JSON.parse(readFileSync2(path, "utf8"));
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
|
|
196
|
+
}
|
|
197
|
+
if (!value || typeof value !== "object" || typeof value.appId !== "string" || !value.envs || typeof value.envs !== "object") {
|
|
198
|
+
throw new Error(`credentials file ${path} has an invalid shape; fix or remove it before provisioning`);
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
function mergeCredential(current, update) {
|
|
203
|
+
const next = current ?? {
|
|
204
|
+
appId: update.appId,
|
|
205
|
+
platformUrl: update.platformUrl,
|
|
206
|
+
dbEndpoint: update.dbEndpoint,
|
|
207
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
208
|
+
envs: {}
|
|
209
|
+
};
|
|
210
|
+
next.appId = update.appId;
|
|
211
|
+
next.platformUrl = update.platformUrl;
|
|
212
|
+
next.dbEndpoint = update.dbEndpoint;
|
|
213
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
214
|
+
next.envs[update.env] = {
|
|
215
|
+
...next.envs[update.env] ?? {},
|
|
216
|
+
tenantId: update.tenantId,
|
|
217
|
+
...update.dbKey ? { dbKey: update.dbKey } : {},
|
|
218
|
+
...update.o11yToken ? { o11yToken: update.o11yToken } : {}
|
|
219
|
+
};
|
|
220
|
+
return next;
|
|
221
|
+
}
|
|
222
|
+
function ensureGitignore(rootDir, localPaths = []) {
|
|
223
|
+
const path = resolve2(rootDir, ".gitignore");
|
|
224
|
+
const existing = existsSync2(path) ? readFileSync2(path, "utf8") : "";
|
|
225
|
+
const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
|
|
226
|
+
const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
|
|
227
|
+
const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
|
|
228
|
+
if (missing.length === 0) return;
|
|
229
|
+
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
230
|
+
writeFileSync(path, `${existing}${prefix}${missing.join("\n")}
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
function o11yDevVars(cfg) {
|
|
234
|
+
if (!cfg.services.includes("o11y")) return void 0;
|
|
235
|
+
return {
|
|
236
|
+
endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
|
|
237
|
+
service: cfg.o11y?.service ?? cfg.app.id,
|
|
238
|
+
...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function resolveWriteDevVarsTarget(cfg, requested) {
|
|
242
|
+
if (!requested) return null;
|
|
243
|
+
if (requested === true) return cfg.local.devVarsFile;
|
|
244
|
+
return resolve2(dirname2(cfg.configPath), requested);
|
|
245
|
+
}
|
|
246
|
+
function writeDevVars(path, credentials, env, o11y) {
|
|
247
|
+
const entry = credentials.envs[env];
|
|
248
|
+
if (!entry) throw new Error(`no credentials for env "${env}" in ${path}`);
|
|
249
|
+
const lines = [`ODLA_PLATFORM="${credentials.platformUrl}"`];
|
|
250
|
+
if (entry.dbKey) lines.push(`ODLA_ENDPOINT="${credentials.dbEndpoint}"`);
|
|
251
|
+
lines.push(`ODLA_APP_ID="${credentials.appId}"`, `ODLA_ENV="${env}"`, `ODLA_TENANT="${entry.tenantId}"`);
|
|
252
|
+
if (entry.dbKey) lines.push(`ODLA_API_KEY="${entry.dbKey}"`);
|
|
253
|
+
if (o11y) {
|
|
254
|
+
lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
|
|
255
|
+
if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
|
|
256
|
+
if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
|
|
257
|
+
}
|
|
258
|
+
const existing = existsSync2(path) ? readFileSync2(path, "utf8") : "";
|
|
259
|
+
const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
|
|
260
|
+
while (retained.at(-1) === "") retained.pop();
|
|
261
|
+
const prefix = retained.length ? `${retained.join("\n")}
|
|
262
|
+
|
|
263
|
+
` : "";
|
|
264
|
+
writePrivateText(path, `${prefix}${lines.join("\n")}
|
|
265
|
+
`);
|
|
266
|
+
}
|
|
267
|
+
var MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
|
|
268
|
+
"ODLA_PLATFORM",
|
|
269
|
+
"ODLA_ENDPOINT",
|
|
270
|
+
"ODLA_APP_ID",
|
|
271
|
+
"ODLA_ENV",
|
|
272
|
+
"ODLA_TENANT",
|
|
273
|
+
"ODLA_API_KEY",
|
|
274
|
+
"ODLA_O11Y_ENDPOINT",
|
|
275
|
+
"ODLA_O11Y_SERVICE",
|
|
276
|
+
"ODLA_O11Y_VERSION",
|
|
277
|
+
"ODLA_O11Y_TOKEN"
|
|
278
|
+
]);
|
|
279
|
+
function isManagedDevVar(line) {
|
|
280
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
|
|
281
|
+
return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
|
|
282
|
+
}
|
|
283
|
+
function writePrivateText(path, text) {
|
|
284
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
285
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
286
|
+
writeFileSync(temporary, text, { mode: 384 });
|
|
287
|
+
chmodSync(temporary, 384);
|
|
288
|
+
renameSync(temporary, path);
|
|
289
|
+
}
|
|
290
|
+
function gitignoreEntry(rootDir, path) {
|
|
291
|
+
const rel = relative(resolve2(rootDir), resolve2(path));
|
|
292
|
+
if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute2(rel)) return null;
|
|
293
|
+
return rel.replaceAll("\\", "/");
|
|
294
|
+
}
|
|
295
|
+
function displayPath(path, rootDir = process.cwd()) {
|
|
296
|
+
const rel = relative(rootDir, path);
|
|
297
|
+
return rel && !rel.startsWith("..") ? rel : path;
|
|
298
|
+
}
|
|
131
299
|
|
|
132
300
|
// src/wrangler.ts
|
|
133
301
|
import { spawn } from "child_process";
|
|
134
|
-
import { existsSync as
|
|
302
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
135
303
|
import { join } from "path";
|
|
136
304
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
137
305
|
const child = spawn(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
@@ -147,14 +315,14 @@ var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
|
|
|
147
315
|
function findWranglerConfig(rootDir) {
|
|
148
316
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
149
317
|
const path = join(rootDir, name);
|
|
150
|
-
if (
|
|
318
|
+
if (existsSync3(path)) return path;
|
|
151
319
|
}
|
|
152
320
|
return null;
|
|
153
321
|
}
|
|
154
322
|
function readWranglerConfig(path) {
|
|
155
323
|
if (path.endsWith(".toml")) return null;
|
|
156
324
|
try {
|
|
157
|
-
return JSON.parse(stripJsonComments(
|
|
325
|
+
return JSON.parse(stripJsonComments(readFileSync3(path, "utf8")));
|
|
158
326
|
} catch {
|
|
159
327
|
return null;
|
|
160
328
|
}
|
|
@@ -226,10 +394,11 @@ function lintRules(rules, entities, publicRead) {
|
|
|
226
394
|
return warnings;
|
|
227
395
|
}
|
|
228
396
|
var defaultExec = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
229
|
-
function trackedSecretFiles(rootDir, exec = defaultExec) {
|
|
397
|
+
function trackedSecretFiles(rootDir, exec = defaultExec, localPaths = []) {
|
|
230
398
|
let output;
|
|
231
399
|
try {
|
|
232
|
-
|
|
400
|
+
const custom = localPaths.map((path) => gitignoreEntry(rootDir, path)).filter((path) => !!path);
|
|
401
|
+
output = exec("git", ["ls-files", "--", ".dev.vars", ".odla", ...custom], rootDir);
|
|
233
402
|
} catch {
|
|
234
403
|
return [];
|
|
235
404
|
}
|
|
@@ -251,10 +420,10 @@ function wranglerWarnings(rootDir) {
|
|
|
251
420
|
for (const { label, block } of blocks) {
|
|
252
421
|
const assets = block.assets;
|
|
253
422
|
if (assets?.directory) {
|
|
254
|
-
const dir =
|
|
255
|
-
if (dir ===
|
|
423
|
+
const dir = resolve3(rootDir, assets.directory);
|
|
424
|
+
if (dir === resolve3(rootDir)) {
|
|
256
425
|
warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
|
|
257
|
-
} else if (
|
|
426
|
+
} else if (existsSync4(join2(dir, "node_modules"))) {
|
|
258
427
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
259
428
|
}
|
|
260
429
|
}
|
|
@@ -273,96 +442,57 @@ function wranglerWarnings(rootDir) {
|
|
|
273
442
|
}
|
|
274
443
|
return warnings;
|
|
275
444
|
}
|
|
276
|
-
function
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
445
|
+
function o11yProjectWarnings(rootDir) {
|
|
446
|
+
const warnings = [];
|
|
447
|
+
const pkg = readPackageJson(rootDir);
|
|
448
|
+
const dependencies = pkg?.dependencies;
|
|
449
|
+
const devDependencies = pkg?.devDependencies;
|
|
450
|
+
if (!dependencies?.["@odla-ai/o11y"]) {
|
|
451
|
+
warnings.push(
|
|
452
|
+
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"'
|
|
453
|
+
);
|
|
281
454
|
}
|
|
455
|
+
const configPath = findWranglerConfig(rootDir);
|
|
456
|
+
const config = configPath ? readWranglerConfig(configPath) : null;
|
|
457
|
+
if (!configPath || !config) {
|
|
458
|
+
warnings.push("cannot verify o11y Worker instrumentation \u2014 add a parseable wrangler.jsonc/json config");
|
|
459
|
+
return warnings;
|
|
460
|
+
}
|
|
461
|
+
const main = typeof config.main === "string" ? resolve3(rootDir, config.main) : null;
|
|
462
|
+
if (!main || !existsSync4(main)) {
|
|
463
|
+
warnings.push("cannot verify o11y Worker instrumentation \u2014 wrangler main is missing or unreadable");
|
|
464
|
+
} else {
|
|
465
|
+
let source = "";
|
|
466
|
+
try {
|
|
467
|
+
source = readFileSync4(main, "utf8");
|
|
468
|
+
} catch {
|
|
469
|
+
}
|
|
470
|
+
if (!/\bwithObservability\b/.test(source)) {
|
|
471
|
+
warnings.push(`wrangler entrypoint ${config.main} does not reference withObservability`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const flags = Array.isArray(config.compatibility_flags) ? config.compatibility_flags : [];
|
|
475
|
+
if (!flags.includes("nodejs_compat")) {
|
|
476
|
+
warnings.push('wrangler compatibility_flags is missing "nodejs_compat" required by @odla-ai/o11y');
|
|
477
|
+
}
|
|
478
|
+
return warnings;
|
|
282
479
|
}
|
|
283
|
-
|
|
284
|
-
// src/local.ts
|
|
285
|
-
import { chmodSync, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
|
|
286
|
-
import { dirname as dirname2, relative, resolve as resolve3 } from "path";
|
|
287
|
-
var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
|
|
288
|
-
function readJsonFile(path) {
|
|
480
|
+
function readPackageJson(rootDir) {
|
|
289
481
|
try {
|
|
290
|
-
return JSON.parse(readFileSync4(
|
|
482
|
+
return JSON.parse(readFileSync4(join2(rootDir, "package.json"), "utf8"));
|
|
291
483
|
} catch {
|
|
292
484
|
return null;
|
|
293
485
|
}
|
|
294
486
|
}
|
|
295
|
-
function writePrivateJson(path, value) {
|
|
296
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
297
|
-
writeFileSync(path, `${JSON.stringify(value, null, 2)}
|
|
298
|
-
`);
|
|
299
|
-
chmodSync(path, 384);
|
|
300
|
-
}
|
|
301
|
-
function readCredentials(path) {
|
|
302
|
-
return readJsonFile(path);
|
|
303
|
-
}
|
|
304
|
-
function mergeCredential(current, update) {
|
|
305
|
-
const next = current ?? {
|
|
306
|
-
appId: update.appId,
|
|
307
|
-
platformUrl: update.platformUrl,
|
|
308
|
-
dbEndpoint: update.dbEndpoint,
|
|
309
|
-
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
310
|
-
envs: {}
|
|
311
|
-
};
|
|
312
|
-
next.appId = update.appId;
|
|
313
|
-
next.platformUrl = update.platformUrl;
|
|
314
|
-
next.dbEndpoint = update.dbEndpoint;
|
|
315
|
-
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
316
|
-
next.envs[update.env] = {
|
|
317
|
-
...next.envs[update.env] ?? {},
|
|
318
|
-
tenantId: update.tenantId,
|
|
319
|
-
...update.dbKey ? { dbKey: update.dbKey } : {},
|
|
320
|
-
...update.o11yToken ? { o11yToken: update.o11yToken } : {}
|
|
321
|
-
};
|
|
322
|
-
return next;
|
|
323
|
-
}
|
|
324
|
-
function ensureGitignore(rootDir) {
|
|
325
|
-
const path = resolve3(rootDir, ".gitignore");
|
|
326
|
-
const existing = existsSync4(path) ? readFileSync4(path, "utf8") : "";
|
|
327
|
-
const missing = GITIGNORE_LINES.filter((line) => !existing.split(/\r?\n/).includes(line));
|
|
328
|
-
if (missing.length === 0) return;
|
|
329
|
-
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
330
|
-
writeFileSync(path, `${existing}${prefix}${missing.join("\n")}
|
|
331
|
-
`);
|
|
332
|
-
}
|
|
333
|
-
function writeDevVars(path, credentials, env, o11y) {
|
|
334
|
-
const entry = credentials.envs[env];
|
|
335
|
-
if (!entry?.dbKey) throw new Error(`no db key for env "${env}" in ${path}`);
|
|
336
|
-
const lines = [
|
|
337
|
-
`ODLA_PLATFORM="${credentials.platformUrl}"`,
|
|
338
|
-
`ODLA_ENDPOINT="${credentials.dbEndpoint}"`,
|
|
339
|
-
`ODLA_APP_ID="${credentials.appId}"`,
|
|
340
|
-
`ODLA_ENV="${env}"`,
|
|
341
|
-
`ODLA_TENANT="${entry.tenantId}"`,
|
|
342
|
-
`ODLA_API_KEY="${entry.dbKey}"`
|
|
343
|
-
];
|
|
344
|
-
if (o11y) {
|
|
345
|
-
lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
|
|
346
|
-
if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
|
|
347
|
-
if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
|
|
348
|
-
}
|
|
349
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
350
|
-
writeFileSync(path, `${lines.join("\n")}
|
|
351
|
-
`);
|
|
352
|
-
chmodSync(path, 384);
|
|
353
|
-
}
|
|
354
|
-
function displayPath(path, rootDir = process.cwd()) {
|
|
355
|
-
const rel = relative(rootDir, path);
|
|
356
|
-
return rel && !rel.startsWith("..") ? rel : path;
|
|
357
|
-
}
|
|
358
487
|
|
|
359
488
|
// src/doctor.ts
|
|
360
489
|
async function doctor(options) {
|
|
361
490
|
const out = options.stdout ?? console;
|
|
362
491
|
const cfg = await loadProjectConfig(options.configPath);
|
|
363
492
|
const plan = buildPlan(cfg);
|
|
364
|
-
const
|
|
365
|
-
const
|
|
493
|
+
const hasDb = cfg.services.includes("db");
|
|
494
|
+
const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
|
|
495
|
+
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
366
496
|
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
367
497
|
const entities = serializedEntities(schema);
|
|
368
498
|
out.log(`config: ${cfg.configPath}`);
|
|
@@ -371,7 +501,7 @@ async function doctor(options) {
|
|
|
371
501
|
out.log(`svc: ${plan.services.join(", ")}`);
|
|
372
502
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
373
503
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
374
|
-
out.log(`ai: ${cfg.ai?.provider ?? "not configured"}`);
|
|
504
|
+
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
375
505
|
const warnings = [];
|
|
376
506
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
377
507
|
if (rules) {
|
|
@@ -387,7 +517,9 @@ async function doctor(options) {
|
|
|
387
517
|
}
|
|
388
518
|
}
|
|
389
519
|
}
|
|
390
|
-
if (cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv])
|
|
520
|
+
if (cfg.services.includes("ai") && cfg.ai?.keyEnv && !process.env[cfg.ai.keyEnv]) {
|
|
521
|
+
warnings.push(`${cfg.ai.keyEnv} is not set; provision will skip provider key storage`);
|
|
522
|
+
}
|
|
391
523
|
if (cfg.services.includes("o11y")) {
|
|
392
524
|
const credentials = readCredentials(cfg.local.credentialsFile);
|
|
393
525
|
for (const env of cfg.envs) {
|
|
@@ -395,9 +527,14 @@ async function doctor(options) {
|
|
|
395
527
|
warnings.push(`o11y is enabled but env "${env}" has no ingest token \u2014 run "odla-ai provision" to mint one`);
|
|
396
528
|
}
|
|
397
529
|
}
|
|
530
|
+
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
398
531
|
}
|
|
399
532
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
400
|
-
warnings.push(...trackedSecretFiles(cfg.rootDir
|
|
533
|
+
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
534
|
+
cfg.local.tokenFile,
|
|
535
|
+
cfg.local.credentialsFile,
|
|
536
|
+
cfg.local.devVarsFile
|
|
537
|
+
]));
|
|
401
538
|
warnings.push(...wranglerWarnings(cfg.rootDir));
|
|
402
539
|
if (warnings.length) {
|
|
403
540
|
out.log("");
|
|
@@ -421,7 +558,7 @@ function initProject(options) {
|
|
|
421
558
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
|
|
422
559
|
throw new Error("--app-id must be lowercase letters, numbers, and hyphens");
|
|
423
560
|
}
|
|
424
|
-
const envs = options.envs?.length ? options.envs : ["
|
|
561
|
+
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
425
562
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
426
563
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
427
564
|
mkdirSync2(dirname3(configPath), { recursive: true });
|
|
@@ -523,10 +660,82 @@ function relativeDisplay(path, rootDir) {
|
|
|
523
660
|
return path.startsWith(rootDir) ? path.slice(rootDir.length + 1) : path;
|
|
524
661
|
}
|
|
525
662
|
|
|
663
|
+
// src/secrets.ts
|
|
664
|
+
var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
665
|
+
async function secretsPush(options) {
|
|
666
|
+
await secretsPushImpl(options, true);
|
|
667
|
+
}
|
|
668
|
+
async function secretsPushAfterPreflight(options) {
|
|
669
|
+
await secretsPushImpl(options, false);
|
|
670
|
+
}
|
|
671
|
+
async function secretsPushImpl(options, preflight) {
|
|
672
|
+
const out = options.stdout ?? console;
|
|
673
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
674
|
+
const env = options.env;
|
|
675
|
+
if (!cfg.envs.includes(env)) {
|
|
676
|
+
throw new Error(`env "${env}" is not in config envs (${cfg.envs.join(", ")})`);
|
|
677
|
+
}
|
|
678
|
+
if (PROD_ENV_NAMES.has(env) && !options.yes) {
|
|
679
|
+
throw new Error(`refusing to push a secret to "${env}" without --yes`);
|
|
680
|
+
}
|
|
681
|
+
const credentialsPath = displayPath(cfg.local.credentialsFile, cfg.rootDir);
|
|
682
|
+
const credentials = readCredentials(cfg.local.credentialsFile);
|
|
683
|
+
if (!credentials) throw new Error(`no credentials at ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
684
|
+
if (credentials.appId !== cfg.app.id) {
|
|
685
|
+
throw new Error(`credentials at ${credentialsPath} are for "${credentials.appId}", not "${cfg.app.id}"`);
|
|
686
|
+
}
|
|
687
|
+
const entry = credentials.envs[env];
|
|
688
|
+
if (!entry) throw new Error(`no credentials for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
689
|
+
const secrets = [];
|
|
690
|
+
if (cfg.services.includes("o11y")) {
|
|
691
|
+
if (!entry.o11yToken) throw new Error(`no o11y token for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
692
|
+
secrets.push({ name: "ODLA_O11Y_TOKEN", value: entry.o11yToken });
|
|
693
|
+
}
|
|
694
|
+
if (cfg.services.includes("db")) {
|
|
695
|
+
if (!entry.dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
696
|
+
secrets.push({ name: "ODLA_API_KEY", value: entry.dbKey });
|
|
697
|
+
}
|
|
698
|
+
if (secrets.length === 0) {
|
|
699
|
+
out.log(`${env}: no configured Worker secrets to push`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
|
|
703
|
+
const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
|
|
704
|
+
if (options.dryRun) {
|
|
705
|
+
assertWranglerConfig(cfg);
|
|
706
|
+
for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const run = options.runner ?? defaultRunner;
|
|
710
|
+
if (preflight) await preflightLoadedConfig(cfg, run);
|
|
711
|
+
for (const s of secrets) {
|
|
712
|
+
const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
|
|
713
|
+
if (result.code !== 0) {
|
|
714
|
+
throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
|
|
715
|
+
}
|
|
716
|
+
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async function preflightSecretsPush(options) {
|
|
720
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
721
|
+
if (!cfg.services.some((service) => service === "db" || service === "o11y")) return;
|
|
722
|
+
await preflightLoadedConfig(cfg, options.runner ?? defaultRunner);
|
|
723
|
+
}
|
|
724
|
+
async function preflightLoadedConfig(cfg, run) {
|
|
725
|
+
assertWranglerConfig(cfg);
|
|
726
|
+
if (!await wranglerLoggedIn(run, cfg.rootDir)) {
|
|
727
|
+
throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function assertWranglerConfig(cfg) {
|
|
731
|
+
if (!findWranglerConfig(cfg.rootDir)) {
|
|
732
|
+
throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
526
736
|
// src/provision.ts
|
|
527
|
-
import { createAppsClient, tenantIdFor } from "@odla-ai/apps";
|
|
737
|
+
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
528
738
|
import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
|
|
529
|
-
import { dirname as dirname4, resolve as resolve5 } from "path";
|
|
530
739
|
import process4 from "process";
|
|
531
740
|
|
|
532
741
|
// src/token.ts
|
|
@@ -539,7 +748,7 @@ import process2 from "process";
|
|
|
539
748
|
async function openUrl(url, options = {}) {
|
|
540
749
|
const command = openerFor(options.platform ?? process2.platform);
|
|
541
750
|
const doSpawn = options.spawnImpl ?? spawn2;
|
|
542
|
-
await new Promise((
|
|
751
|
+
await new Promise((resolve6, reject) => {
|
|
543
752
|
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
544
753
|
stdio: "ignore",
|
|
545
754
|
detached: true
|
|
@@ -547,7 +756,7 @@ async function openUrl(url, options = {}) {
|
|
|
547
756
|
child.once("error", reject);
|
|
548
757
|
child.once("spawn", () => {
|
|
549
758
|
child.unref();
|
|
550
|
-
|
|
759
|
+
resolve6();
|
|
551
760
|
});
|
|
552
761
|
});
|
|
553
762
|
}
|
|
@@ -601,34 +810,164 @@ function approvalBrowser(options) {
|
|
|
601
810
|
return { open: true, mode: "auto" };
|
|
602
811
|
}
|
|
603
812
|
function handshakeUrl(platformUrl, userCode) {
|
|
604
|
-
const url = new URL("/
|
|
813
|
+
const url = new URL("/studio", platformUrl);
|
|
605
814
|
url.searchParams.set("code", userCode);
|
|
606
815
|
return url.toString();
|
|
607
816
|
}
|
|
608
817
|
|
|
818
|
+
// src/provision-credentials.ts
|
|
819
|
+
import { tenantIdFor } from "@odla-ai/apps";
|
|
820
|
+
async function provisionEnvCredentials(opts) {
|
|
821
|
+
const tenantId = tenantIdFor(opts.cfg.app.id, opts.env);
|
|
822
|
+
const prior = opts.credentials?.envs[opts.env];
|
|
823
|
+
let credentials = opts.credentials;
|
|
824
|
+
let dbKey = opts.cfg.services.includes("db") && !opts.rotateDb ? prior?.dbKey : void 0;
|
|
825
|
+
let o11yToken = opts.cfg.services.includes("o11y") && !opts.rotateO11y ? prior?.o11yToken : void 0;
|
|
826
|
+
if (opts.cfg.services.includes("db")) {
|
|
827
|
+
if (dbKey) {
|
|
828
|
+
opts.stdout.log(`${opts.env}: reusing local db key for ${tenantId}`);
|
|
829
|
+
} else {
|
|
830
|
+
dbKey = await mintDbKey(opts, tenantId);
|
|
831
|
+
credentials = save(opts, credentials, tenantId, { dbKey });
|
|
832
|
+
opts.stdout.log(`${opts.env}: minted db key for ${tenantId}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (opts.cfg.services.includes("o11y")) {
|
|
836
|
+
if (o11yToken) {
|
|
837
|
+
opts.stdout.log(`${opts.env}: reusing local o11y ingest token`);
|
|
838
|
+
} else {
|
|
839
|
+
o11yToken = await issueO11yToken(opts);
|
|
840
|
+
credentials = save(opts, credentials, tenantId, { ...dbKey ? { dbKey } : {}, o11yToken });
|
|
841
|
+
opts.stdout.log(`${opts.env}: ${opts.rotateO11y ? "rotated" : "issued"} o11y ingest token`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return save(opts, credentials, tenantId, {
|
|
845
|
+
...dbKey ? { dbKey } : {},
|
|
846
|
+
...o11yToken ? { o11yToken } : {}
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function save(opts, current, tenantId, values) {
|
|
850
|
+
const next = mergeCredential(current, {
|
|
851
|
+
appId: opts.cfg.app.id,
|
|
852
|
+
platformUrl: opts.cfg.platformUrl,
|
|
853
|
+
dbEndpoint: opts.cfg.dbEndpoint,
|
|
854
|
+
env: opts.env,
|
|
855
|
+
tenantId,
|
|
856
|
+
...values
|
|
857
|
+
});
|
|
858
|
+
if (opts.write) writePrivateJson(opts.cfg.local.credentialsFile, next);
|
|
859
|
+
return next;
|
|
860
|
+
}
|
|
861
|
+
async function mintDbKey(opts, tenantId) {
|
|
862
|
+
const headers = { authorization: `Bearer ${opts.developerToken}`, "content-type": "application/json" };
|
|
863
|
+
let res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
864
|
+
method: "POST",
|
|
865
|
+
headers,
|
|
866
|
+
body: "{}"
|
|
867
|
+
});
|
|
868
|
+
if (res.status === 404) {
|
|
869
|
+
const created = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps`, {
|
|
870
|
+
method: "POST",
|
|
871
|
+
headers,
|
|
872
|
+
body: JSON.stringify({
|
|
873
|
+
name: opts.env === "prod" ? opts.cfg.app.name : `${opts.cfg.app.name} (${opts.env})`,
|
|
874
|
+
appId: tenantId
|
|
875
|
+
})
|
|
876
|
+
});
|
|
877
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
|
|
878
|
+
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
879
|
+
method: "POST",
|
|
880
|
+
headers,
|
|
881
|
+
body: "{}"
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
|
|
885
|
+
const body = await res.json();
|
|
886
|
+
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
887
|
+
return body.key;
|
|
888
|
+
}
|
|
889
|
+
async function issueO11yToken(opts) {
|
|
890
|
+
const suffix = opts.rotateO11y ? "/rotate" : "";
|
|
891
|
+
const res = await opts.fetch(
|
|
892
|
+
`${opts.cfg.platformUrl}/o11y/${encodeURIComponent(opts.cfg.app.id)}/token${suffix}?env=${encodeURIComponent(opts.env)}`,
|
|
893
|
+
{ method: "POST", headers: { authorization: `Bearer ${opts.developerToken}` } }
|
|
894
|
+
);
|
|
895
|
+
if (res.status === 409 && !opts.rotateO11y) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`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`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
|
|
901
|
+
const body = await res.json();
|
|
902
|
+
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
903
|
+
return body.token;
|
|
904
|
+
}
|
|
905
|
+
async function safeText(res) {
|
|
906
|
+
try {
|
|
907
|
+
return redactSecrets((await res.text()).slice(0, 500));
|
|
908
|
+
} catch {
|
|
909
|
+
return "";
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
609
913
|
// src/provision.ts
|
|
610
914
|
async function provision(options) {
|
|
611
915
|
const out = options.stdout ?? console;
|
|
612
916
|
const cfg = await loadProjectConfig(options.configPath);
|
|
613
917
|
const plan = buildPlan(cfg);
|
|
918
|
+
const hasDb = cfg.services.includes("db");
|
|
919
|
+
const hasO11y = cfg.services.includes("o11y");
|
|
920
|
+
if (options.rotateO11yToken && !hasO11y) {
|
|
921
|
+
throw new Error("--rotate-o11y-token requires the o11y service in odla.config.mjs");
|
|
922
|
+
}
|
|
923
|
+
if (options.pushSecrets && options.writeCredentials === false) {
|
|
924
|
+
throw new Error("--push-secrets cannot be combined with --no-write-credentials");
|
|
925
|
+
}
|
|
614
926
|
out.log(`odla-ai: ${plan.appName} (${plan.appId})`);
|
|
615
927
|
out.log(` platform: ${plan.platformUrl}`);
|
|
616
928
|
out.log(` db: ${plan.dbEndpoint}`);
|
|
617
929
|
out.log(` envs: ${plan.envs.join(", ")}`);
|
|
618
930
|
out.log(` services: ${plan.services.join(", ")}`);
|
|
619
|
-
const
|
|
620
|
-
|
|
931
|
+
const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
|
|
932
|
+
if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
|
|
938
|
+
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
621
939
|
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
622
940
|
if (options.dryRun) {
|
|
623
941
|
out.log("dry run: no network calls or file writes");
|
|
624
942
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
625
943
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
626
|
-
out.log(` ai: ${cfg.ai?.provider ?? "not configured"}`);
|
|
944
|
+
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
945
|
+
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
627
946
|
return;
|
|
628
947
|
}
|
|
948
|
+
let credentials = readCredentials(cfg.local.credentialsFile);
|
|
949
|
+
if (credentials && credentials.appId !== cfg.app.id) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
`credentials at ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} are for "${credentials.appId}", not "${cfg.app.id}"`
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
const rotatesO11y = !!(options.rotateKeys || options.rotateO11yToken);
|
|
955
|
+
const missingO11y = cfg.envs.some((env) => !credentials?.envs[env]?.o11yToken);
|
|
956
|
+
const missingDb = cfg.envs.some((env) => !credentials?.envs[env]?.dbKey);
|
|
957
|
+
const losesShownOnceCredential = hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y);
|
|
958
|
+
if (options.writeCredentials === false && losesShownOnceCredential) {
|
|
959
|
+
throw new Error("credential issuance/rotation requires the private credentials file; remove --no-write-credentials");
|
|
960
|
+
}
|
|
961
|
+
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
962
|
+
if (cfg.local.gitignore) {
|
|
963
|
+
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
964
|
+
}
|
|
965
|
+
if (options.pushSecrets) {
|
|
966
|
+
await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
|
|
967
|
+
out.log("secrets: Wrangler config and login preflight passed");
|
|
968
|
+
}
|
|
629
969
|
const doFetch = options.fetch ?? fetch;
|
|
630
970
|
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
631
|
-
const auth = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
632
971
|
const apps = createAppsClient({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
|
|
633
972
|
const existing = await readRegistryApp(cfg, token, doFetch);
|
|
634
973
|
if (existing) {
|
|
@@ -663,26 +1002,40 @@ async function provision(options) {
|
|
|
663
1002
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
664
1003
|
}
|
|
665
1004
|
}
|
|
666
|
-
let credentials = readCredentials(cfg.local.credentialsFile);
|
|
667
1005
|
for (const env of cfg.envs) {
|
|
668
|
-
const tenantId =
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1006
|
+
const tenantId = tenantIdFor2(cfg.app.id, env);
|
|
1007
|
+
credentials = await provisionEnvCredentials({
|
|
1008
|
+
cfg,
|
|
1009
|
+
env,
|
|
1010
|
+
developerToken: token,
|
|
1011
|
+
credentials,
|
|
1012
|
+
rotateDb: !!options.rotateKeys,
|
|
1013
|
+
rotateO11y: rotatesO11y,
|
|
1014
|
+
write: options.writeCredentials !== false,
|
|
1015
|
+
fetch: doFetch,
|
|
1016
|
+
stdout: out
|
|
1017
|
+
});
|
|
1018
|
+
const dbKey = credentials.envs[env]?.dbKey;
|
|
1019
|
+
if (options.pushSecrets) {
|
|
1020
|
+
try {
|
|
1021
|
+
await secretsPushAfterPreflight({
|
|
1022
|
+
configPath: cfg.configPath,
|
|
1023
|
+
env,
|
|
1024
|
+
yes: options.yes,
|
|
1025
|
+
runner: options.secretRunner,
|
|
1026
|
+
stdout: out
|
|
1027
|
+
});
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1030
|
+
const consent = env === "prod" || env === "production" ? " --yes" : "";
|
|
1031
|
+
throw new Error(
|
|
1032
|
+
`${message}
|
|
1033
|
+
${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}${consent}" without issuing or rotating again`,
|
|
1034
|
+
{ cause: error }
|
|
1035
|
+
);
|
|
683
1036
|
}
|
|
684
1037
|
}
|
|
685
|
-
if (schema) {
|
|
1038
|
+
if (schema && dbKey) {
|
|
686
1039
|
await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
|
|
687
1040
|
out.log(`${env}: schema pushed`);
|
|
688
1041
|
}
|
|
@@ -690,7 +1043,7 @@ async function provision(options) {
|
|
|
690
1043
|
await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
|
|
691
1044
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
692
1045
|
}
|
|
693
|
-
if (cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1046
|
+
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
694
1047
|
const key = process4.env[cfg.ai.keyEnv];
|
|
695
1048
|
if (key) {
|
|
696
1049
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
@@ -700,36 +1053,17 @@ async function provision(options) {
|
|
|
700
1053
|
out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
|
|
701
1054
|
}
|
|
702
1055
|
}
|
|
703
|
-
credentials = mergeCredential(credentials, {
|
|
704
|
-
appId: cfg.app.id,
|
|
705
|
-
platformUrl: cfg.platformUrl,
|
|
706
|
-
dbEndpoint: cfg.dbEndpoint,
|
|
707
|
-
env,
|
|
708
|
-
tenantId,
|
|
709
|
-
dbKey,
|
|
710
|
-
...o11yToken ? { o11yToken } : {}
|
|
711
|
-
});
|
|
712
1056
|
}
|
|
713
|
-
if (cfg.local.gitignore) ensureGitignore(cfg.rootDir);
|
|
714
1057
|
if (options.writeCredentials !== false && credentials) {
|
|
715
|
-
|
|
716
|
-
out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600, gitignored)`);
|
|
1058
|
+
const ignored = cfg.local.gitignore && gitignoreEntry(cfg.rootDir, cfg.local.credentialsFile);
|
|
1059
|
+
out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600${ignored ? ", gitignored" : ""})`);
|
|
717
1060
|
}
|
|
718
|
-
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
719
1061
|
if (devVarsTarget && credentials) {
|
|
720
1062
|
const env = cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
|
|
721
1063
|
writeDevVars(devVarsTarget, credentials, env, o11yDevVars(cfg));
|
|
722
1064
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
723
1065
|
}
|
|
724
1066
|
}
|
|
725
|
-
function o11yDevVars(cfg) {
|
|
726
|
-
if (!cfg.services.includes("o11y")) return void 0;
|
|
727
|
-
return {
|
|
728
|
-
endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
|
|
729
|
-
service: cfg.o11y?.service ?? cfg.app.id,
|
|
730
|
-
...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
1067
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
734
1068
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
735
1069
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -739,50 +1073,13 @@ async function readRegistryApp(cfg, token, doFetch) {
|
|
|
739
1073
|
const json = await res.json();
|
|
740
1074
|
return json.app ?? null;
|
|
741
1075
|
}
|
|
742
|
-
async function mintDbKey(opts) {
|
|
743
|
-
let res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(opts.tenantId)}/keys`, {
|
|
744
|
-
method: "POST",
|
|
745
|
-
headers: opts.auth,
|
|
746
|
-
body: "{}"
|
|
747
|
-
});
|
|
748
|
-
if (res.status === 404) {
|
|
749
|
-
const created = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps`, {
|
|
750
|
-
method: "POST",
|
|
751
|
-
headers: opts.auth,
|
|
752
|
-
body: JSON.stringify({
|
|
753
|
-
name: opts.env === "prod" ? opts.cfg.app.name : `${opts.cfg.app.name} (${opts.env})`,
|
|
754
|
-
appId: opts.tenantId
|
|
755
|
-
})
|
|
756
|
-
});
|
|
757
|
-
if (!created.ok) throw new Error(`db app create (${opts.tenantId}) failed: ${created.status} ${await safeText(created)}`);
|
|
758
|
-
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(opts.tenantId)}/keys`, {
|
|
759
|
-
method: "POST",
|
|
760
|
-
headers: opts.auth,
|
|
761
|
-
body: "{}"
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
if (!res.ok) throw new Error(`db key mint (${opts.tenantId}) failed: ${res.status} ${await safeText(res)}`);
|
|
765
|
-
const body = await res.json();
|
|
766
|
-
if (!body.key) throw new Error(`db key mint (${opts.tenantId}) returned no key`);
|
|
767
|
-
return body.key;
|
|
768
|
-
}
|
|
769
|
-
async function mintO11yToken(cfg, env, token, doFetch) {
|
|
770
|
-
const res = await doFetch(
|
|
771
|
-
`${cfg.platformUrl}/o11y/${encodeURIComponent(cfg.app.id)}/token?env=${encodeURIComponent(env)}`,
|
|
772
|
-
{ method: "POST", headers: { authorization: `Bearer ${token}` } }
|
|
773
|
-
);
|
|
774
|
-
if (!res.ok) throw new Error(`o11y token mint (${env}) failed: ${res.status} ${await safeText(res)}`);
|
|
775
|
-
const body = await res.json();
|
|
776
|
-
if (!body.token) throw new Error(`o11y token mint (${env}) returned no token`);
|
|
777
|
-
return body.token;
|
|
778
|
-
}
|
|
779
1076
|
async function postJson(doFetch, url, bearer, body) {
|
|
780
1077
|
const res = await doFetch(url, {
|
|
781
1078
|
method: "POST",
|
|
782
1079
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
783
1080
|
body: JSON.stringify(body)
|
|
784
1081
|
});
|
|
785
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
1082
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
|
|
786
1083
|
}
|
|
787
1084
|
function normalizeClerkConfig(value) {
|
|
788
1085
|
if (!value) return null;
|
|
@@ -799,12 +1096,7 @@ function defaultSecretName(provider) {
|
|
|
799
1096
|
const names = DEFAULT_SECRET_NAMES;
|
|
800
1097
|
return names[provider] ?? `${provider}_api_key`;
|
|
801
1098
|
}
|
|
802
|
-
function
|
|
803
|
-
if (!requested) return null;
|
|
804
|
-
if (requested === true) return cfg.local.devVarsFile;
|
|
805
|
-
return resolve5(dirname4(cfg.configPath), requested);
|
|
806
|
-
}
|
|
807
|
-
async function safeText(res) {
|
|
1099
|
+
async function safeText2(res) {
|
|
808
1100
|
try {
|
|
809
1101
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
810
1102
|
} catch {
|
|
@@ -812,97 +1104,244 @@ async function safeText(res) {
|
|
|
812
1104
|
}
|
|
813
1105
|
}
|
|
814
1106
|
|
|
815
|
-
// src/
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1107
|
+
// src/skill.ts
|
|
1108
|
+
import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1109
|
+
import { homedir } from "os";
|
|
1110
|
+
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join3, relative as relative2, resolve as resolve5, sep } from "path";
|
|
1111
|
+
import { fileURLToPath } from "url";
|
|
1112
|
+
|
|
1113
|
+
// src/skill-adapters.ts
|
|
1114
|
+
var PROJECT_INSTRUCTIONS = `<!-- odla-ai agent setup:start -->
|
|
1115
|
+
## odla agent workflow
|
|
1116
|
+
|
|
1117
|
+
For work that creates an odla app or adds odla services, read and follow
|
|
1118
|
+
\`.agents/skills/odla/SKILL.md\`. It dispatches an existing static site to
|
|
1119
|
+
\`.agents/skills/odla-migrate/SKILL.md\`. For production telemetry triage, use
|
|
1120
|
+
\`.agents/skills/odla-o11y-debug/SKILL.md\`.
|
|
1121
|
+
|
|
1122
|
+
The complete runbooks and their references are installed in this repository.
|
|
1123
|
+
Do not fetch online odla documentation as setup context. Network access is only
|
|
1124
|
+
needed when a runbook deliberately calls the odla service, npm, Cloudflare, or
|
|
1125
|
+
another configured provider.
|
|
1126
|
+
<!-- odla-ai agent setup:end -->`;
|
|
1127
|
+
var CURSOR_RULE = `---
|
|
1128
|
+
description: Build, migrate, provision, secure, or debug an odla app using the locally installed odla runbooks
|
|
1129
|
+
globs:
|
|
1130
|
+
alwaysApply: false
|
|
1131
|
+
---
|
|
1132
|
+
|
|
1133
|
+
${PROJECT_INSTRUCTIONS}
|
|
1134
|
+
`;
|
|
1135
|
+
function claudeAdapter(skill, canonical) {
|
|
1136
|
+
const match = canonical.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1137
|
+
if (!match) throw new Error(`bundled skill ${skill} has no YAML frontmatter`);
|
|
1138
|
+
const lines = match[1].split(/\r?\n/);
|
|
1139
|
+
const frontmatter = [];
|
|
1140
|
+
let keepIndented = false;
|
|
1141
|
+
for (const line of lines) {
|
|
1142
|
+
if (line.startsWith("name:") || line.startsWith("description:")) {
|
|
1143
|
+
frontmatter.push(line);
|
|
1144
|
+
keepIndented = line.startsWith("description:");
|
|
1145
|
+
} else if (keepIndented && /^\s+/.test(line)) {
|
|
1146
|
+
frontmatter.push(line);
|
|
1147
|
+
} else {
|
|
1148
|
+
keepIndented = false;
|
|
856
1149
|
}
|
|
857
|
-
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
858
1150
|
}
|
|
1151
|
+
return `---
|
|
1152
|
+
${frontmatter.join("\n")}
|
|
1153
|
+
---
|
|
1154
|
+
|
|
1155
|
+
# ${skill} (odla adapter)
|
|
1156
|
+
|
|
1157
|
+
Read \`../../../.agents/skills/${skill}/SKILL.md\` completely and follow it. That
|
|
1158
|
+
canonical local skill owns every reference path and contains the full offline
|
|
1159
|
+
runbook. Do not substitute a remote documentation page for the installed copy.
|
|
1160
|
+
`;
|
|
859
1161
|
}
|
|
860
1162
|
|
|
861
1163
|
// src/skill.ts
|
|
862
|
-
|
|
863
|
-
import { homedir } from "os";
|
|
864
|
-
import { join as join3, relative as relative2, resolve as resolve6 } from "path";
|
|
865
|
-
import { fileURLToPath } from "url";
|
|
1164
|
+
var AGENT_HARNESSES = ["claude", "codex", "cursor", "copilot", "gemini", "agents"];
|
|
866
1165
|
function installSkill(options = {}) {
|
|
867
1166
|
const out = options.stdout ?? console;
|
|
868
1167
|
const sourceDir = options.sourceDir ?? fileURLToPath(new URL("../skills", import.meta.url));
|
|
869
|
-
const targetDir = options.global ? join3(options.homeDir ?? homedir(), ".claude", "skills") : resolve6(options.dir ?? process.cwd(), ".claude", "skills");
|
|
870
1168
|
const files = listFiles(sourceDir);
|
|
871
1169
|
if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
|
|
872
|
-
const
|
|
873
|
-
const
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1170
|
+
const harnesses = normalizeHarnesses(options.harnesses, options.global === true);
|
|
1171
|
+
const root = resolve5(options.dir ?? process.cwd());
|
|
1172
|
+
const home = resolve5(options.homeDir ?? homedir());
|
|
1173
|
+
const plans = /* @__PURE__ */ new Map();
|
|
1174
|
+
const targets = /* @__PURE__ */ new Map();
|
|
1175
|
+
const rememberTarget = (harness, target) => {
|
|
1176
|
+
const current = targets.get(harness) ?? /* @__PURE__ */ new Set();
|
|
1177
|
+
current.add(target);
|
|
1178
|
+
targets.set(harness, current);
|
|
1179
|
+
};
|
|
1180
|
+
const plan = (target, content, managedMerge = false, boundary = root) => {
|
|
1181
|
+
const existing = plans.get(target);
|
|
1182
|
+
if (existing && existing.content !== content) throw new Error(`internal agent setup conflict for ${target}`);
|
|
1183
|
+
plans.set(target, { target, content, boundary, managedMerge });
|
|
1184
|
+
};
|
|
1185
|
+
const planSkillTree = (targetDir2, boundary = root) => {
|
|
1186
|
+
for (const rel of files) plan(join3(targetDir2, rel), readFileSync5(join3(sourceDir, rel), "utf8"), false, boundary);
|
|
1187
|
+
};
|
|
1188
|
+
let targetDir;
|
|
1189
|
+
if (options.global) {
|
|
1190
|
+
const claudeRoot = join3(home, ".claude", "skills");
|
|
1191
|
+
const codexRoot = resolve5(options.codexHomeDir ?? process.env.CODEX_HOME ?? join3(home, ".codex"), "skills");
|
|
1192
|
+
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
1193
|
+
for (const harness of harnesses) {
|
|
1194
|
+
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
1195
|
+
planSkillTree(skillRoot, harness === "claude" ? home : dirname4(dirname4(codexRoot)));
|
|
1196
|
+
rememberTarget(harness, skillRoot);
|
|
1197
|
+
}
|
|
1198
|
+
} else {
|
|
1199
|
+
const sharedRoot = join3(root, ".agents", "skills");
|
|
1200
|
+
planSkillTree(sharedRoot);
|
|
1201
|
+
const claudeRoot = join3(root, ".claude", "skills");
|
|
1202
|
+
targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
|
|
1203
|
+
for (const harness of harnesses) rememberTarget(harness, sharedRoot);
|
|
1204
|
+
if (harnesses.includes("claude")) {
|
|
1205
|
+
for (const skill of skillNames(files)) {
|
|
1206
|
+
const canonical = readFileSync5(join3(sourceDir, skill, "SKILL.md"), "utf8");
|
|
1207
|
+
plan(join3(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
|
|
887
1208
|
}
|
|
1209
|
+
rememberTarget("claude", claudeRoot);
|
|
1210
|
+
}
|
|
1211
|
+
if (harnesses.includes("cursor")) {
|
|
1212
|
+
const cursorRule = join3(root, ".cursor", "rules", "odla.mdc");
|
|
1213
|
+
plan(cursorRule, CURSOR_RULE);
|
|
1214
|
+
rememberTarget("cursor", cursorRule);
|
|
1215
|
+
}
|
|
1216
|
+
if (harnesses.includes("agents")) {
|
|
1217
|
+
const agentsFile = join3(root, "AGENTS.md");
|
|
1218
|
+
plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1219
|
+
rememberTarget("agents", agentsFile);
|
|
1220
|
+
}
|
|
1221
|
+
if (harnesses.includes("copilot")) {
|
|
1222
|
+
const copilotFile = join3(root, ".github", "copilot-instructions.md");
|
|
1223
|
+
plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1224
|
+
rememberTarget("copilot", copilotFile);
|
|
1225
|
+
}
|
|
1226
|
+
if (harnesses.includes("gemini")) {
|
|
1227
|
+
const geminiFile = join3(root, "GEMINI.md");
|
|
1228
|
+
plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
1229
|
+
rememberTarget("gemini", geminiFile);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const writtenPaths = /* @__PURE__ */ new Set();
|
|
1233
|
+
const unchangedPaths = /* @__PURE__ */ new Set();
|
|
1234
|
+
const conflicts = [];
|
|
1235
|
+
for (const file of plans.values()) {
|
|
1236
|
+
const symlink = symlinkedComponent(file.boundary, file.target);
|
|
1237
|
+
if (symlink) {
|
|
1238
|
+
conflicts.push(`${file.target} (redirected by symbolic link ${symlink})`);
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (!existsSync6(file.target)) {
|
|
1242
|
+
writtenPaths.add(file.target);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const current = readFileSync5(file.target, "utf8");
|
|
1246
|
+
if (current === file.content) {
|
|
1247
|
+
unchangedPaths.add(file.target);
|
|
1248
|
+
} else if (file.managedMerge || options.force) {
|
|
1249
|
+
writtenPaths.add(file.target);
|
|
1250
|
+
unchangedPaths.delete(file.target);
|
|
1251
|
+
} else {
|
|
1252
|
+
conflicts.push(file.target);
|
|
888
1253
|
}
|
|
889
|
-
written.push(rel);
|
|
890
1254
|
}
|
|
891
1255
|
if (conflicts.length > 0) {
|
|
892
1256
|
throw new Error(
|
|
893
|
-
`
|
|
894
|
-
${conflicts.map((f) => ` - ${
|
|
1257
|
+
`agent setup files modified locally (re-run with --force to replace only odla-managed content):
|
|
1258
|
+
${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
895
1259
|
);
|
|
896
1260
|
}
|
|
897
|
-
for (const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1261
|
+
for (const file of plans.values()) {
|
|
1262
|
+
if (!existsSync6(file.target) || readFileSync5(file.target, "utf8") !== file.content) {
|
|
1263
|
+
mkdirSync3(dirname4(file.target), { recursive: true });
|
|
1264
|
+
writeFileSync3(file.target, file.content);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const skills = skillNames(files);
|
|
1268
|
+
const installations = harnesses.map((harness) => ({ harness, targets: [...targets.get(harness) ?? []] }));
|
|
1269
|
+
out.log(`agent harnesses: ${harnesses.join(", ")}`);
|
|
1270
|
+
out.log(`offline skills: ${skills.join(", ")}`);
|
|
1271
|
+
for (const installation of installations) out.log(`${installation.harness}: ${installation.targets.join(", ")}`);
|
|
1272
|
+
out.log(`installed ${writtenPaths.size} managed file(s)${unchangedPaths.size ? ` (${unchangedPaths.size} unchanged)` : ""}`);
|
|
1273
|
+
return {
|
|
1274
|
+
targetDir,
|
|
1275
|
+
written: pathsUnder(targetDir, writtenPaths),
|
|
1276
|
+
unchanged: pathsUnder(targetDir, unchangedPaths),
|
|
1277
|
+
writtenPaths: [...writtenPaths].sort(),
|
|
1278
|
+
unchangedPaths: [...unchangedPaths].sort(),
|
|
1279
|
+
harnesses: installations
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function pathsUnder(root, paths) {
|
|
1283
|
+
return [...paths].map((path) => relative2(root, path)).filter((path) => path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute3(path)).sort();
|
|
1284
|
+
}
|
|
1285
|
+
function normalizeHarnesses(values, global) {
|
|
1286
|
+
const requested = values?.length ? values : ["claude"];
|
|
1287
|
+
const allowed = /* @__PURE__ */ new Set([...AGENT_HARNESSES, "all"]);
|
|
1288
|
+
for (const harness of requested) {
|
|
1289
|
+
if (!allowed.has(harness)) {
|
|
1290
|
+
throw new Error(`unknown agent harness "${harness}"; choose all, ${AGENT_HARNESSES.join(", ")}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const expanded = requested.includes("all") ? global ? ["claude", "codex"] : [...AGENT_HARNESSES] : [...new Set(requested)];
|
|
1294
|
+
if (global) {
|
|
1295
|
+
const unsupported = expanded.filter((harness) => harness !== "claude" && harness !== "codex");
|
|
1296
|
+
if (unsupported.length) {
|
|
1297
|
+
throw new Error(`--global supports claude and codex only; ${unsupported.join(", ")} require a project-local adapter`);
|
|
1298
|
+
}
|
|
901
1299
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1300
|
+
return expanded;
|
|
1301
|
+
}
|
|
1302
|
+
function managedFileContent(path, block, force, boundary) {
|
|
1303
|
+
const symlink = symlinkedComponent(boundary, path);
|
|
1304
|
+
if (symlink) throw new Error(`refusing to manage ${path}: symbolic link component ${symlink}`);
|
|
1305
|
+
if (!existsSync6(path)) return `${block}
|
|
1306
|
+
`;
|
|
1307
|
+
const current = readFileSync5(path, "utf8");
|
|
1308
|
+
const start = "<!-- odla-ai agent setup:start -->";
|
|
1309
|
+
const end = "<!-- odla-ai agent setup:end -->";
|
|
1310
|
+
const startAt = current.indexOf(start);
|
|
1311
|
+
const endAt = current.indexOf(end);
|
|
1312
|
+
if (startAt === -1 !== (endAt === -1) || startAt !== -1 && endAt < startAt) {
|
|
1313
|
+
throw new Error(`malformed odla-managed section in ${path}`);
|
|
1314
|
+
}
|
|
1315
|
+
if (startAt === -1) {
|
|
1316
|
+
const separator = current.length === 0 || current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
|
|
1317
|
+
return `${current}${separator}${block}
|
|
1318
|
+
`;
|
|
1319
|
+
}
|
|
1320
|
+
const afterEnd = endAt + end.length;
|
|
1321
|
+
const existing = current.slice(startAt, afterEnd);
|
|
1322
|
+
if (existing !== block && !force) {
|
|
1323
|
+
throw new Error(`odla-managed section modified locally in ${path}; re-run with --force to replace that section`);
|
|
1324
|
+
}
|
|
1325
|
+
return `${current.slice(0, startAt)}${block}${current.slice(afterEnd)}`;
|
|
1326
|
+
}
|
|
1327
|
+
function symlinkedComponent(boundary, target) {
|
|
1328
|
+
const rel = relative2(boundary, target);
|
|
1329
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute3(rel)) {
|
|
1330
|
+
throw new Error(`agent setup target escapes its install root: ${target}`);
|
|
1331
|
+
}
|
|
1332
|
+
let current = boundary;
|
|
1333
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
1334
|
+
current = join3(current, part);
|
|
1335
|
+
try {
|
|
1336
|
+
if (lstatSync(current).isSymbolicLink()) return current;
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
if (error.code !== "ENOENT") throw error;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return void 0;
|
|
1342
|
+
}
|
|
1343
|
+
function skillNames(files) {
|
|
1344
|
+
return [...new Set(files.filter((file) => /(^|[\\/])SKILL\.md$/.test(file)).map((file) => file.split(/[\\/]/)[0]))].sort();
|
|
906
1345
|
}
|
|
907
1346
|
function listFiles(dir) {
|
|
908
1347
|
if (!existsSync6(dir)) return [];
|
|
@@ -974,7 +1413,7 @@ async function getJson(doFetch, url, bearer) {
|
|
|
974
1413
|
const res = await doFetch(url, {
|
|
975
1414
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
976
1415
|
});
|
|
977
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
1416
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
|
|
978
1417
|
return res.json();
|
|
979
1418
|
}
|
|
980
1419
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -983,7 +1422,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
983
1422
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
984
1423
|
body: JSON.stringify(body)
|
|
985
1424
|
});
|
|
986
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
1425
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
|
|
987
1426
|
return res.json();
|
|
988
1427
|
}
|
|
989
1428
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -991,7 +1430,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
991
1430
|
url.searchParams.set("env", env);
|
|
992
1431
|
return url.toString();
|
|
993
1432
|
}
|
|
994
|
-
async function
|
|
1433
|
+
async function safeText3(res) {
|
|
995
1434
|
try {
|
|
996
1435
|
return (await res.text()).slice(0, 500);
|
|
997
1436
|
} catch {
|
|
@@ -1005,14 +1444,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1005
1444
|
const parsed = parseArgv(argv);
|
|
1006
1445
|
const command = parsed.positionals[0] ?? "help";
|
|
1007
1446
|
if (command === "version" || command === "-v" || parsed.options.version === true) {
|
|
1447
|
+
assertArgs(parsed, ["version"], 1);
|
|
1008
1448
|
console.log(cliVersion());
|
|
1009
1449
|
return;
|
|
1010
1450
|
}
|
|
1011
1451
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
1452
|
+
assertArgs(parsed, ["help"], 1);
|
|
1012
1453
|
printHelp();
|
|
1013
1454
|
return;
|
|
1014
1455
|
}
|
|
1015
1456
|
if (command === "init") {
|
|
1457
|
+
assertArgs(parsed, ["app-id", "name", "config", "env", "services", "ai-provider", "force"], 1);
|
|
1016
1458
|
initProject({
|
|
1017
1459
|
appId: requiredString(parsed.options["app-id"], "--app-id"),
|
|
1018
1460
|
name: requiredString(parsed.options.name, "--name"),
|
|
@@ -1025,15 +1467,39 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1025
1467
|
return;
|
|
1026
1468
|
}
|
|
1027
1469
|
if (command === "doctor") {
|
|
1470
|
+
assertArgs(parsed, ["config"], 1);
|
|
1028
1471
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
1029
1472
|
return;
|
|
1030
1473
|
}
|
|
1474
|
+
if (command === "capabilities") {
|
|
1475
|
+
assertArgs(parsed, ["json"], 1);
|
|
1476
|
+
printCapabilities(parsed.options.json === true);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1031
1479
|
if (command === "provision") {
|
|
1480
|
+
assertArgs(
|
|
1481
|
+
parsed,
|
|
1482
|
+
[
|
|
1483
|
+
"config",
|
|
1484
|
+
"dry-run",
|
|
1485
|
+
"rotate-keys",
|
|
1486
|
+
"rotate-o11y-token",
|
|
1487
|
+
"push-secrets",
|
|
1488
|
+
"write-credentials",
|
|
1489
|
+
"write-dev-vars",
|
|
1490
|
+
"token",
|
|
1491
|
+
"open",
|
|
1492
|
+
"yes"
|
|
1493
|
+
],
|
|
1494
|
+
1
|
|
1495
|
+
);
|
|
1032
1496
|
const writeDevVars2 = parsed.options["write-dev-vars"];
|
|
1033
1497
|
const opts = {
|
|
1034
1498
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1035
1499
|
dryRun: parsed.options["dry-run"] === true,
|
|
1036
1500
|
rotateKeys: parsed.options["rotate-keys"] === true,
|
|
1501
|
+
rotateO11yToken: parsed.options["rotate-o11y-token"] === true,
|
|
1502
|
+
pushSecrets: parsed.options["push-secrets"] === true,
|
|
1037
1503
|
writeCredentials: parsed.options["write-credentials"] !== false,
|
|
1038
1504
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
1039
1505
|
token: stringOpt(parsed.options.token),
|
|
@@ -1044,18 +1510,34 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1044
1510
|
return;
|
|
1045
1511
|
}
|
|
1046
1512
|
if (command === "smoke") {
|
|
1513
|
+
assertArgs(parsed, ["config", "env"], 1);
|
|
1047
1514
|
await smoke({
|
|
1048
1515
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1049
1516
|
env: stringOpt(parsed.options.env)
|
|
1050
1517
|
});
|
|
1051
1518
|
return;
|
|
1052
1519
|
}
|
|
1520
|
+
if (command === "setup") {
|
|
1521
|
+
assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 1);
|
|
1522
|
+
installSkill({
|
|
1523
|
+
dir: stringOpt(parsed.options.dir),
|
|
1524
|
+
global: parsed.options.global === true,
|
|
1525
|
+
harnesses: harnessList(parsed),
|
|
1526
|
+
force: parsed.options.force === true
|
|
1527
|
+
});
|
|
1528
|
+
console.log(
|
|
1529
|
+
"\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."
|
|
1530
|
+
);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1053
1533
|
if (command === "skill") {
|
|
1054
1534
|
const sub = parsed.positionals[1];
|
|
1055
1535
|
if (sub !== "install") throw new Error(`unknown skill subcommand "${sub ?? ""}". Try "odla-ai skill install".`);
|
|
1536
|
+
assertArgs(parsed, ["dir", "global", "force", "agent", "harness"], 2);
|
|
1056
1537
|
installSkill({
|
|
1057
1538
|
dir: stringOpt(parsed.options.dir),
|
|
1058
1539
|
global: parsed.options.global === true,
|
|
1540
|
+
harnesses: harnessList(parsed),
|
|
1059
1541
|
force: parsed.options.force === true
|
|
1060
1542
|
});
|
|
1061
1543
|
return;
|
|
@@ -1063,6 +1545,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1063
1545
|
if (command === "secrets") {
|
|
1064
1546
|
const sub = parsed.positionals[1];
|
|
1065
1547
|
if (sub !== "push") throw new Error(`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev".`);
|
|
1548
|
+
assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
|
|
1066
1549
|
await secretsPush({
|
|
1067
1550
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1068
1551
|
env: requiredString(parsed.options.env, "--env"),
|
|
@@ -1073,6 +1556,17 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1073
1556
|
}
|
|
1074
1557
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
1075
1558
|
}
|
|
1559
|
+
function assertArgs(parsed, allowedOptions, maxPositionals) {
|
|
1560
|
+
const allowed = new Set(allowedOptions);
|
|
1561
|
+
for (const name of Object.keys(parsed.options)) {
|
|
1562
|
+
if (!allowed.has(name)) {
|
|
1563
|
+
throw new Error(`unknown option "--${name}"; run "odla-ai help" for supported options`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
if (parsed.positionals.length > maxPositionals) {
|
|
1567
|
+
throw new Error(`unexpected argument "${parsed.positionals[maxPositionals]}"; run "odla-ai help"`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1076
1570
|
function parseArgv(argv) {
|
|
1077
1571
|
const positionals = [];
|
|
1078
1572
|
const options = {};
|
|
@@ -1128,6 +1622,21 @@ function listOpt(value) {
|
|
|
1128
1622
|
const values = Array.isArray(value) ? value : [value];
|
|
1129
1623
|
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
1130
1624
|
}
|
|
1625
|
+
function harnessList(parsed) {
|
|
1626
|
+
const selected = [
|
|
1627
|
+
...harnessOption(parsed.options.agent, "--agent"),
|
|
1628
|
+
...harnessOption(parsed.options.harness, "--harness")
|
|
1629
|
+
];
|
|
1630
|
+
return selected.length ? selected : ["all"];
|
|
1631
|
+
}
|
|
1632
|
+
function harnessOption(value, flag) {
|
|
1633
|
+
if (value === void 0) return [];
|
|
1634
|
+
if (typeof value === "boolean") throw new Error(`${flag} requires a harness name`);
|
|
1635
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
1636
|
+
const parts = raw.flatMap((item) => item.split(","));
|
|
1637
|
+
if (parts.some((item) => !item.trim())) throw new Error(`${flag} requires a harness name`);
|
|
1638
|
+
return parts.map((item) => item.trim());
|
|
1639
|
+
}
|
|
1131
1640
|
function cliVersion() {
|
|
1132
1641
|
const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
|
|
1133
1642
|
return pkg.version ?? "unknown";
|
|
@@ -1136,39 +1645,50 @@ function printHelp() {
|
|
|
1136
1645
|
console.log(`odla-ai
|
|
1137
1646
|
|
|
1138
1647
|
Usage:
|
|
1139
|
-
odla-ai
|
|
1648
|
+
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1649
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
1140
1650
|
odla-ai doctor [--config odla.config.mjs]
|
|
1141
|
-
odla-ai
|
|
1651
|
+
odla-ai capabilities [--json]
|
|
1652
|
+
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1142
1653
|
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
1143
|
-
odla-ai skill install [--dir <project>] [--global] [--force]
|
|
1654
|
+
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1144
1655
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1145
1656
|
odla-ai version
|
|
1146
1657
|
|
|
1147
1658
|
Commands:
|
|
1659
|
+
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1148
1660
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1149
1661
|
doctor Validate and summarize the project config without network calls.
|
|
1150
|
-
|
|
1662
|
+
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1663
|
+
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
1151
1664
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
1152
|
-
skill
|
|
1153
|
-
|
|
1665
|
+
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1666
|
+
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1667
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
|
|
1154
1668
|
version Print the CLI version.
|
|
1155
1669
|
|
|
1156
1670
|
Safety:
|
|
1157
|
-
|
|
1158
|
-
|
|
1671
|
+
New projects target dev only. Add prod explicitly to odla.config.mjs and pass
|
|
1672
|
+
--yes to provision it; use --dry-run first to inspect the resolved plan.
|
|
1673
|
+
Provision caches the approved developer token and service credentials under
|
|
1674
|
+
.odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
|
|
1675
|
+
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1159
1676
|
Provision opens the approval page automatically in interactive terminals;
|
|
1160
1677
|
use --open to force browser launch or --no-open to suppress it.
|
|
1161
1678
|
`);
|
|
1162
1679
|
}
|
|
1163
1680
|
|
|
1164
1681
|
export {
|
|
1682
|
+
CAPABILITIES,
|
|
1683
|
+
printCapabilities,
|
|
1165
1684
|
redactSecrets,
|
|
1166
1685
|
doctor,
|
|
1167
1686
|
initProject,
|
|
1168
|
-
provision,
|
|
1169
1687
|
secretsPush,
|
|
1688
|
+
provision,
|
|
1689
|
+
AGENT_HARNESSES,
|
|
1170
1690
|
installSkill,
|
|
1171
1691
|
smoke,
|
|
1172
1692
|
runCli
|
|
1173
1693
|
};
|
|
1174
|
-
//# sourceMappingURL=chunk-
|
|
1694
|
+
//# sourceMappingURL=chunk-7WZIZCGA.js.map
|