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