@odla-ai/cli 0.5.0 → 0.7.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 +59 -9
- package/dist/bin.cjs +1096 -402
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-7WZIZCGA.js → chunk-643B2AKG.js} +1100 -393
- package/dist/chunk-643B2AKG.js.map +1 -0
- package/dist/index.cjs +1104 -402
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +130 -5
- package/dist/index.d.ts +130 -5
- package/dist/index.js +9 -1
- package/llms.txt +191 -16
- package/package.json +5 -4
- package/skills/odla/SKILL.md +6 -2
- package/skills/odla/references/build.md +15 -2
- package/skills/odla/references/sdks.md +8 -5
- package/skills/odla-migrate/references/phase-2-db.md +2 -2
- package/skills/odla-migrate/references/phase-5-cutover.md +6 -2
- package/skills/odla-migrate/references/secrets-map.md +5 -0
- package/dist/chunk-7WZIZCGA.js.map +0 -1
|
@@ -1,5 +1,647 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/admin-ai-auth.ts
|
|
4
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5
|
+
import process4 from "process";
|
|
6
|
+
import { requestToken as requestToken2 } from "@odla-ai/db";
|
|
7
|
+
|
|
8
|
+
// src/local.ts
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve } from "path";
|
|
11
|
+
var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
|
|
12
|
+
function readJsonFile(path) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function writePrivateJson(path, value) {
|
|
20
|
+
writePrivateText(path, `${JSON.stringify(value, null, 2)}
|
|
21
|
+
`);
|
|
22
|
+
}
|
|
23
|
+
function readCredentials(path) {
|
|
24
|
+
if (!existsSync(path)) return null;
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value !== "object" || typeof value.appId !== "string" || !value.envs || typeof value.envs !== "object") {
|
|
32
|
+
throw new Error(`credentials file ${path} has an invalid shape; fix or remove it before provisioning`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function mergeCredential(current, update) {
|
|
37
|
+
const next = current ?? {
|
|
38
|
+
appId: update.appId,
|
|
39
|
+
platformUrl: update.platformUrl,
|
|
40
|
+
dbEndpoint: update.dbEndpoint,
|
|
41
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
42
|
+
envs: {}
|
|
43
|
+
};
|
|
44
|
+
next.appId = update.appId;
|
|
45
|
+
next.platformUrl = update.platformUrl;
|
|
46
|
+
next.dbEndpoint = update.dbEndpoint;
|
|
47
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
48
|
+
next.envs[update.env] = {
|
|
49
|
+
...next.envs[update.env] ?? {},
|
|
50
|
+
tenantId: update.tenantId,
|
|
51
|
+
...update.dbKey ? { dbKey: update.dbKey } : {},
|
|
52
|
+
...update.o11yToken ? { o11yToken: update.o11yToken } : {}
|
|
53
|
+
};
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
function ensureGitignore(rootDir, localPaths = []) {
|
|
57
|
+
const path = resolve(rootDir, ".gitignore");
|
|
58
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
59
|
+
const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
|
|
60
|
+
const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
|
|
61
|
+
const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
|
|
62
|
+
if (missing.length === 0) return;
|
|
63
|
+
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
64
|
+
writeFileSync(path, `${existing}${prefix}${missing.join("\n")}
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
function o11yDevVars(cfg) {
|
|
68
|
+
if (!cfg.services.includes("o11y")) return void 0;
|
|
69
|
+
return {
|
|
70
|
+
endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
|
|
71
|
+
service: cfg.o11y?.service ?? cfg.app.id,
|
|
72
|
+
...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function resolveWriteDevVarsTarget(cfg, requested) {
|
|
76
|
+
if (!requested) return null;
|
|
77
|
+
if (requested === true) return cfg.local.devVarsFile;
|
|
78
|
+
return resolve(dirname(cfg.configPath), requested);
|
|
79
|
+
}
|
|
80
|
+
function writeDevVars(path, credentials, env, o11y) {
|
|
81
|
+
const entry = credentials.envs[env];
|
|
82
|
+
if (!entry) throw new Error(`no credentials for env "${env}" in ${path}`);
|
|
83
|
+
const lines = [`ODLA_PLATFORM="${credentials.platformUrl}"`];
|
|
84
|
+
if (entry.dbKey) lines.push(`ODLA_ENDPOINT="${credentials.dbEndpoint}"`);
|
|
85
|
+
lines.push(`ODLA_APP_ID="${credentials.appId}"`, `ODLA_ENV="${env}"`, `ODLA_TENANT="${entry.tenantId}"`);
|
|
86
|
+
if (entry.dbKey) lines.push(`ODLA_API_KEY="${entry.dbKey}"`);
|
|
87
|
+
if (o11y) {
|
|
88
|
+
lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
|
|
89
|
+
if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
|
|
90
|
+
if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
|
|
91
|
+
}
|
|
92
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
93
|
+
const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
|
|
94
|
+
while (retained.at(-1) === "") retained.pop();
|
|
95
|
+
const prefix = retained.length ? `${retained.join("\n")}
|
|
96
|
+
|
|
97
|
+
` : "";
|
|
98
|
+
writePrivateText(path, `${prefix}${lines.join("\n")}
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
var MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
|
|
102
|
+
"ODLA_PLATFORM",
|
|
103
|
+
"ODLA_ENDPOINT",
|
|
104
|
+
"ODLA_APP_ID",
|
|
105
|
+
"ODLA_ENV",
|
|
106
|
+
"ODLA_TENANT",
|
|
107
|
+
"ODLA_API_KEY",
|
|
108
|
+
"ODLA_O11Y_ENDPOINT",
|
|
109
|
+
"ODLA_O11Y_SERVICE",
|
|
110
|
+
"ODLA_O11Y_VERSION",
|
|
111
|
+
"ODLA_O11Y_TOKEN"
|
|
112
|
+
]);
|
|
113
|
+
function isManagedDevVar(line) {
|
|
114
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
|
|
115
|
+
return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
|
|
116
|
+
}
|
|
117
|
+
function writePrivateText(path, text) {
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
120
|
+
writeFileSync(temporary, text, { mode: 384 });
|
|
121
|
+
chmodSync(temporary, 384);
|
|
122
|
+
renameSync(temporary, path);
|
|
123
|
+
}
|
|
124
|
+
function gitignoreEntry(rootDir, path) {
|
|
125
|
+
const rel = relative(resolve(rootDir), resolve(path));
|
|
126
|
+
if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) return null;
|
|
127
|
+
return rel.replaceAll("\\", "/");
|
|
128
|
+
}
|
|
129
|
+
function displayPath(path, rootDir = process.cwd()) {
|
|
130
|
+
const rel = relative(rootDir, path);
|
|
131
|
+
return rel && !rel.startsWith("..") ? rel : path;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/open.ts
|
|
135
|
+
import { spawn } from "child_process";
|
|
136
|
+
import process2 from "process";
|
|
137
|
+
async function openUrl(url, options = {}) {
|
|
138
|
+
const command = openerFor(options.platform ?? process2.platform);
|
|
139
|
+
const doSpawn = options.spawnImpl ?? spawn;
|
|
140
|
+
await new Promise((resolve7, reject) => {
|
|
141
|
+
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
142
|
+
stdio: "ignore",
|
|
143
|
+
detached: true
|
|
144
|
+
});
|
|
145
|
+
child.once("error", reject);
|
|
146
|
+
child.once("spawn", () => {
|
|
147
|
+
child.unref();
|
|
148
|
+
resolve7();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function openerFor(platform) {
|
|
153
|
+
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
154
|
+
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
155
|
+
return { cmd: "xdg-open", args: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/token.ts
|
|
159
|
+
import { requestToken } from "@odla-ai/db";
|
|
160
|
+
import process3 from "process";
|
|
161
|
+
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
162
|
+
if (options.token) return options.token;
|
|
163
|
+
const audience = platformAudience(cfg.platformUrl);
|
|
164
|
+
if (process3.env.ODLA_DEV_TOKEN) {
|
|
165
|
+
const declared = process3.env.ODLA_DEV_TOKEN_AUDIENCE;
|
|
166
|
+
if (declared) {
|
|
167
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
|
|
168
|
+
} else if (audience !== "https://odla.ai") {
|
|
169
|
+
throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
|
|
170
|
+
}
|
|
171
|
+
return process3.env.ODLA_DEV_TOKEN;
|
|
172
|
+
}
|
|
173
|
+
const cached = readJsonFile(cfg.local.tokenFile);
|
|
174
|
+
if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
175
|
+
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
176
|
+
return cached.token;
|
|
177
|
+
}
|
|
178
|
+
const browser = approvalBrowser(options);
|
|
179
|
+
const { token, expiresAt } = await requestToken({
|
|
180
|
+
endpoint: cfg.platformUrl,
|
|
181
|
+
label: `${cfg.app.id} provisioner`,
|
|
182
|
+
fetch: doFetch,
|
|
183
|
+
onCode: async ({ userCode, expiresIn }) => {
|
|
184
|
+
const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
|
|
185
|
+
out.log("");
|
|
186
|
+
out.log(`Approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
187
|
+
if (browser.open) {
|
|
188
|
+
try {
|
|
189
|
+
await (options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
190
|
+
out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
193
|
+
}
|
|
194
|
+
} else if (browser.reason) {
|
|
195
|
+
out.log(`auth: browser launch skipped (${browser.reason})`);
|
|
196
|
+
}
|
|
197
|
+
out.log("");
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
|
|
201
|
+
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
202
|
+
return token;
|
|
203
|
+
}
|
|
204
|
+
function approvalBrowser(options) {
|
|
205
|
+
if (options.open === true) return { open: true, mode: "forced" };
|
|
206
|
+
if (options.open === false) return { open: false, reason: "disabled by --no-open" };
|
|
207
|
+
if (process3.env.CI) return { open: false, reason: "CI environment" };
|
|
208
|
+
const interactive = options.interactive ?? Boolean(process3.stdin.isTTY && process3.stdout.isTTY);
|
|
209
|
+
if (!interactive) return { open: false, reason: "non-interactive shell; pass --open to force" };
|
|
210
|
+
return { open: true, mode: "auto" };
|
|
211
|
+
}
|
|
212
|
+
function handshakeUrl(platformUrl, userCode) {
|
|
213
|
+
const url = new URL("/studio", platformUrl);
|
|
214
|
+
url.searchParams.set("code", userCode);
|
|
215
|
+
return url.toString();
|
|
216
|
+
}
|
|
217
|
+
function platformAudience(value) {
|
|
218
|
+
let url;
|
|
219
|
+
try {
|
|
220
|
+
url = new URL(value);
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error("platform must be an absolute URL");
|
|
223
|
+
}
|
|
224
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
225
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
226
|
+
throw new Error("platform credentials require HTTPS except for loopback development");
|
|
227
|
+
}
|
|
228
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
229
|
+
throw new Error("platform URL must not contain credentials, query, or fragment");
|
|
230
|
+
}
|
|
231
|
+
return url.origin;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/admin-ai-auth.ts
|
|
235
|
+
async function getScopedPlatformToken(options) {
|
|
236
|
+
return resolveAdminPlatformToken(options);
|
|
237
|
+
}
|
|
238
|
+
async function resolveAdminPlatformToken(options) {
|
|
239
|
+
const audience = platformAudience(options.platform);
|
|
240
|
+
if (options.token) return options.token;
|
|
241
|
+
const fromEnv = process4.env.ODLA_ADMIN_TOKEN;
|
|
242
|
+
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
243
|
+
return scopedToken(
|
|
244
|
+
audience,
|
|
245
|
+
options.scope,
|
|
246
|
+
options,
|
|
247
|
+
options.fetch ?? fetch,
|
|
248
|
+
options.stdout ?? console
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
function audienceBoundEnvToken(token, platform) {
|
|
252
|
+
const audience = platformAudience(platform);
|
|
253
|
+
const declared = process4.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
254
|
+
if (declared) {
|
|
255
|
+
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
256
|
+
} else if (audience !== "https://odla.ai") {
|
|
257
|
+
throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
|
|
258
|
+
}
|
|
259
|
+
return token;
|
|
260
|
+
}
|
|
261
|
+
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
262
|
+
const audience = platformAudience(platform);
|
|
263
|
+
const tokenFile = options.tokenFile ?? `${process4.cwd()}/.odla/admin-token.local.json`;
|
|
264
|
+
const cache = readJsonFile(tokenFile);
|
|
265
|
+
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
266
|
+
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
267
|
+
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
268
|
+
return cached.token;
|
|
269
|
+
}
|
|
270
|
+
const { token, expiresAt } = await requestToken2({
|
|
271
|
+
endpoint: audience,
|
|
272
|
+
label: `odla CLI admin AI (${scope})`,
|
|
273
|
+
scopes: [scope],
|
|
274
|
+
fetch: doFetch,
|
|
275
|
+
onCode: async ({ userCode, expiresIn }) => {
|
|
276
|
+
const approvalUrl = handshakeUrl(audience, userCode);
|
|
277
|
+
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
278
|
+
const shouldOpen = options.open === true || options.open !== false && !process4.env.CI && Boolean(process4.stdin.isTTY && process4.stdout.isTTY);
|
|
279
|
+
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
283
|
+
tokens[scope] = { token, expiresAt };
|
|
284
|
+
if (existsSync2(`${process4.cwd()}/.git`)) ensureGitignore(process4.cwd(), [tokenFile]);
|
|
285
|
+
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
286
|
+
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
287
|
+
return token;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/admin-ai-policy.ts
|
|
291
|
+
var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
|
|
292
|
+
function requireSystemAiPurpose(value) {
|
|
293
|
+
if (!SYSTEM_AI_PURPOSES.includes(value)) {
|
|
294
|
+
throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/admin-ai.ts
|
|
300
|
+
import process5 from "process";
|
|
301
|
+
|
|
302
|
+
// src/admin-ai-audit.ts
|
|
303
|
+
function adminAiAuditQuery(filters) {
|
|
304
|
+
if (filters.limit === void 0) return "";
|
|
305
|
+
if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
|
|
306
|
+
throw new Error("audit limit must be an integer from 1 to 200");
|
|
307
|
+
}
|
|
308
|
+
return `?limit=${filters.limit}`;
|
|
309
|
+
}
|
|
310
|
+
async function readAdminAiAudit(request) {
|
|
311
|
+
const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
|
|
312
|
+
headers: request.headers
|
|
313
|
+
});
|
|
314
|
+
const body = await responseBody(response);
|
|
315
|
+
if (!response.ok) throw new Error(apiError(response.status, body));
|
|
316
|
+
if (request.json) {
|
|
317
|
+
request.stdout.log(JSON.stringify(body, null, 2));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
|
|
321
|
+
request.stdout.log("when change target before -> after actor");
|
|
322
|
+
for (const event of events) {
|
|
323
|
+
const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
|
|
324
|
+
const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
|
|
325
|
+
const route = before && after ? `${String(before.provider)}/${String(before.model)}@v${String(before.version)} -> ${String(after.provider)}/${String(after.model)}@v${String(after.version)}` : "value not retained";
|
|
326
|
+
request.stdout.log([
|
|
327
|
+
timestamp(event.createdAt),
|
|
328
|
+
String(event.changeKind ?? ""),
|
|
329
|
+
String(event.purpose ?? event.provider ?? ""),
|
|
330
|
+
route,
|
|
331
|
+
`${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
|
|
332
|
+
].join(" "));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function responseBody(response) {
|
|
336
|
+
const text = await response.text();
|
|
337
|
+
if (!text) return {};
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(text);
|
|
340
|
+
} catch {
|
|
341
|
+
return { message: text.slice(0, 300) };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function apiError(status, body) {
|
|
345
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
|
|
346
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
347
|
+
return `read System AI admin changes failed (${status}): ${message}`;
|
|
348
|
+
}
|
|
349
|
+
function timestamp(value) {
|
|
350
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
351
|
+
const date = new Date(value);
|
|
352
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
353
|
+
}
|
|
354
|
+
function isRecord(value) {
|
|
355
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/admin-ai-usage.ts
|
|
359
|
+
function adminAiUsageQuery(filters) {
|
|
360
|
+
const params = new URLSearchParams();
|
|
361
|
+
if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
|
|
362
|
+
if (filters.env?.trim()) params.set("env", filters.env.trim());
|
|
363
|
+
if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
|
|
364
|
+
if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
|
|
365
|
+
const query = params.toString();
|
|
366
|
+
return query ? `?${query}` : "";
|
|
367
|
+
}
|
|
368
|
+
async function readAdminAiUsage(request) {
|
|
369
|
+
const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
|
|
370
|
+
headers: request.headers
|
|
371
|
+
});
|
|
372
|
+
const body = await responseBody2(res);
|
|
373
|
+
if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
|
|
374
|
+
if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
|
|
375
|
+
else printUsage(body, request.stdout);
|
|
376
|
+
}
|
|
377
|
+
function usageLimit(value) {
|
|
378
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
|
|
379
|
+
throw new Error("usage limit must be an integer from 1 to 500");
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
383
|
+
function printUsage(body, out) {
|
|
384
|
+
const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
|
|
385
|
+
out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
|
|
386
|
+
status calls input tokens output tokens cost unpriced calls`);
|
|
387
|
+
for (const row of aggregates) {
|
|
388
|
+
out.log([
|
|
389
|
+
String(row.status ?? ""),
|
|
390
|
+
String(row.calls ?? ""),
|
|
391
|
+
String(row.input_tokens ?? ""),
|
|
392
|
+
String(row.output_tokens ?? ""),
|
|
393
|
+
formatMicrousd(row.cost_microusd),
|
|
394
|
+
String(row.unpriced_calls ?? "")
|
|
395
|
+
].join(" "));
|
|
396
|
+
}
|
|
397
|
+
const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
|
|
398
|
+
out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
|
|
399
|
+
when app/env run actor purpose/role route / policy tokens cost status`);
|
|
400
|
+
for (const event of events) {
|
|
401
|
+
const input = numeric(event.input_tokens);
|
|
402
|
+
const output = numeric(event.output_tokens);
|
|
403
|
+
const priced = event.priced === true || event.priced === 1;
|
|
404
|
+
out.log([
|
|
405
|
+
timestamp2(event.created_at),
|
|
406
|
+
`${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
|
|
407
|
+
String(event.run_id ?? ""),
|
|
408
|
+
`${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
|
|
409
|
+
`${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
|
|
410
|
+
`${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
|
|
411
|
+
String(input + output),
|
|
412
|
+
priced ? formatMicrousd(event.cost_microusd) : "unpriced",
|
|
413
|
+
`${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
|
|
414
|
+
].join(" "));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function numeric(value) {
|
|
418
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
419
|
+
}
|
|
420
|
+
function formatMicrousd(value) {
|
|
421
|
+
return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
|
|
422
|
+
}
|
|
423
|
+
function timestamp2(value) {
|
|
424
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
|
|
425
|
+
const date = new Date(value);
|
|
426
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
|
|
427
|
+
}
|
|
428
|
+
async function responseBody2(res) {
|
|
429
|
+
const text = await res.text();
|
|
430
|
+
if (!text) return {};
|
|
431
|
+
try {
|
|
432
|
+
return JSON.parse(text);
|
|
433
|
+
} catch {
|
|
434
|
+
return { message: text.slice(0, 300) };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function apiError2(action, status, body) {
|
|
438
|
+
const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
|
|
439
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
440
|
+
return `${action} failed (${status}): ${message}`;
|
|
441
|
+
}
|
|
442
|
+
function isRecord2(value) {
|
|
443
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/admin-ai.ts
|
|
447
|
+
async function adminAi(options) {
|
|
448
|
+
const platform = platformAudience(options.platform ?? process5.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
449
|
+
const doFetch = options.fetch ?? fetch;
|
|
450
|
+
const out = options.stdout ?? console;
|
|
451
|
+
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
452
|
+
const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
|
|
453
|
+
const scope = options.action === "set" ? "platform:ai:policy:write" : options.action === "credential-set" ? "platform:ai:credential:write" : options.action === "credentials" ? "platform:ai:credential:read" : options.action === "usage" ? "platform:ai:usage:read" : options.action === "audit" ? "platform:ai:audit:read" : "platform:ai:policy:read";
|
|
454
|
+
const token = await resolveAdminPlatformToken({
|
|
455
|
+
platform,
|
|
456
|
+
scope,
|
|
457
|
+
token: options.token,
|
|
458
|
+
open: options.open,
|
|
459
|
+
fetch: doFetch,
|
|
460
|
+
stdout: out,
|
|
461
|
+
openApprovalUrl: options.openApprovalUrl,
|
|
462
|
+
tokenFile: options.tokenFile
|
|
463
|
+
});
|
|
464
|
+
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
465
|
+
if (options.action === "usage") {
|
|
466
|
+
await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (options.action === "audit") {
|
|
470
|
+
await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (options.action === "credentials") {
|
|
474
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
|
|
475
|
+
const body2 = await responseBody3(res2);
|
|
476
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
|
|
477
|
+
const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
|
|
478
|
+
if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
|
|
479
|
+
else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
|
|
480
|
+
const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
|
|
481
|
+
out.log(`${provider} ${label}`);
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (options.action === "credential-set") {
|
|
486
|
+
const provider = requireProvider(options.credentialProvider);
|
|
487
|
+
const value = await credentialValue(options);
|
|
488
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
|
|
489
|
+
method: "PUT",
|
|
490
|
+
headers,
|
|
491
|
+
body: JSON.stringify({ value })
|
|
492
|
+
});
|
|
493
|
+
const body2 = await responseBody3(res2);
|
|
494
|
+
if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
|
|
495
|
+
out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (options.action === "show" || options.action === "models") {
|
|
499
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
500
|
+
const body2 = await responseBody3(res2);
|
|
501
|
+
if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
|
|
502
|
+
if (options.action === "models") {
|
|
503
|
+
const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
|
|
504
|
+
if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
|
|
505
|
+
if (options.json) out.log(JSON.stringify({ models }, null, 2));
|
|
506
|
+
else for (const model of models) out.log(`${model.provider} ${model.id}`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const policies = policyArray(body2);
|
|
510
|
+
if (options.json) {
|
|
511
|
+
out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
out.log("purpose enabled provider model max calls max input bytes max output tokens");
|
|
515
|
+
for (const policy2 of policies) {
|
|
516
|
+
out.log([
|
|
517
|
+
policy2.purpose,
|
|
518
|
+
String(policy2.enabled),
|
|
519
|
+
policy2.provider,
|
|
520
|
+
policy2.model,
|
|
521
|
+
String(policy2.maxCallsPerRun ?? ""),
|
|
522
|
+
String(policy2.maxInputBytes ?? ""),
|
|
523
|
+
String(policy2.maxOutputTokens ?? "")
|
|
524
|
+
].join(" "));
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (options.purpose === "security") {
|
|
529
|
+
const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
|
|
530
|
+
const currentBody = await responseBody3(currentResponse);
|
|
531
|
+
if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
|
|
532
|
+
const policies = policyArray(currentBody);
|
|
533
|
+
const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
|
|
534
|
+
const validation = policies.find((policy2) => policy2.purpose === "security.validation");
|
|
535
|
+
if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
|
|
536
|
+
throw new Error("platform returned invalid security AI policy revisions");
|
|
537
|
+
}
|
|
538
|
+
const shared = {
|
|
539
|
+
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
540
|
+
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
541
|
+
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
542
|
+
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
543
|
+
};
|
|
544
|
+
const discoveryUpdate = {
|
|
545
|
+
...shared,
|
|
546
|
+
...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
|
|
547
|
+
...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
|
|
548
|
+
};
|
|
549
|
+
const validationUpdate = {
|
|
550
|
+
...shared,
|
|
551
|
+
...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
|
|
552
|
+
...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
|
|
553
|
+
};
|
|
554
|
+
if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
|
|
555
|
+
throw new Error("security set requires a route, enabled state, or budget change");
|
|
556
|
+
}
|
|
557
|
+
const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
|
|
558
|
+
method: "PUT",
|
|
559
|
+
headers,
|
|
560
|
+
body: JSON.stringify({
|
|
561
|
+
expectedVersions: { discovery: discovery.version, validation: validation.version },
|
|
562
|
+
discovery: discoveryUpdate,
|
|
563
|
+
validation: validationUpdate
|
|
564
|
+
})
|
|
565
|
+
});
|
|
566
|
+
const response2 = await responseBody3(res2);
|
|
567
|
+
if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
|
|
568
|
+
out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const purpose = requireSystemAiPurpose(options.purpose);
|
|
572
|
+
const body = {
|
|
573
|
+
...options.provider?.trim() ? { provider: options.provider.trim() } : {},
|
|
574
|
+
...options.model?.trim() ? { model: options.model.trim() } : {},
|
|
575
|
+
...options.enabled === void 0 ? {} : { enabled: options.enabled },
|
|
576
|
+
...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
|
|
577
|
+
...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
|
|
578
|
+
...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
|
|
579
|
+
};
|
|
580
|
+
if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
|
|
581
|
+
const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
|
|
582
|
+
method: "PUT",
|
|
583
|
+
headers,
|
|
584
|
+
body: JSON.stringify(body)
|
|
585
|
+
});
|
|
586
|
+
const response = await responseBody3(res);
|
|
587
|
+
if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
|
|
588
|
+
const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
|
|
589
|
+
out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
|
|
590
|
+
}
|
|
591
|
+
function requireProvider(value) {
|
|
592
|
+
if (value !== "anthropic" && value !== "openai" && value !== "google") {
|
|
593
|
+
throw new Error("provider must be one of: anthropic, openai, google");
|
|
594
|
+
}
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
597
|
+
async function credentialValue(options) {
|
|
598
|
+
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
599
|
+
let value;
|
|
600
|
+
if (options.fromEnv) value = process5.env[options.fromEnv];
|
|
601
|
+
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
602
|
+
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
603
|
+
value = value?.replace(/[\r\n]+$/, "");
|
|
604
|
+
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
|
|
605
|
+
if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
async function readStdin() {
|
|
609
|
+
let value = "";
|
|
610
|
+
for await (const chunk of process5.stdin) {
|
|
611
|
+
value += String(chunk);
|
|
612
|
+
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
613
|
+
}
|
|
614
|
+
return value;
|
|
615
|
+
}
|
|
616
|
+
function policyArray(body) {
|
|
617
|
+
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
618
|
+
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
619
|
+
throw new Error("platform returned an invalid AI policy response");
|
|
620
|
+
}
|
|
621
|
+
function catalogModels(body) {
|
|
622
|
+
if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
|
|
623
|
+
throw new Error("platform returned an invalid System AI model catalog");
|
|
624
|
+
}
|
|
625
|
+
return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
|
|
626
|
+
}
|
|
627
|
+
async function responseBody3(res) {
|
|
628
|
+
const text = await res.text();
|
|
629
|
+
if (!text) return {};
|
|
630
|
+
try {
|
|
631
|
+
return JSON.parse(text);
|
|
632
|
+
} catch {
|
|
633
|
+
return { message: text.slice(0, 300) };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function apiError3(action, status, body) {
|
|
637
|
+
const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
|
|
638
|
+
const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
|
|
639
|
+
return `${action} failed (${status}): ${message}`;
|
|
640
|
+
}
|
|
641
|
+
function isRecord3(value) {
|
|
642
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
643
|
+
}
|
|
644
|
+
|
|
3
645
|
// src/capabilities.ts
|
|
4
646
|
var CAPABILITIES = {
|
|
5
647
|
cli: [
|
|
@@ -7,7 +649,9 @@ var CAPABILITIES = {
|
|
|
7
649
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
8
650
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
9
651
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
10
|
-
"validate config offline and smoke-test a provisioned db environment"
|
|
652
|
+
"validate config offline and smoke-test a provisioned db environment",
|
|
653
|
+
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
654
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
11
655
|
],
|
|
12
656
|
agent: [
|
|
13
657
|
"install and import the selected odla SDKs",
|
|
@@ -18,11 +662,13 @@ var CAPABILITIES = {
|
|
|
18
662
|
"approve the odla device code and one-off third-party logins",
|
|
19
663
|
"consent to production changes with --yes",
|
|
20
664
|
"request destructive credential rotation explicitly",
|
|
21
|
-
"review application semantics, security findings, releases, and merges"
|
|
665
|
+
"review application semantics, security findings, releases, and merges",
|
|
666
|
+
"approve redacted-source disclosure before hosted security reasoning"
|
|
22
667
|
],
|
|
23
668
|
studio: [
|
|
24
669
|
"view telemetry and environment state",
|
|
25
|
-
"perform manual credential recovery when the CLI's local shown-once copy is unavailable"
|
|
670
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
671
|
+
"configure system AI purposes and view app/environment/run-attributed usage"
|
|
26
672
|
]
|
|
27
673
|
};
|
|
28
674
|
function printCapabilities(json = false, out = console) {
|
|
@@ -62,28 +708,28 @@ function looksSecret(value) {
|
|
|
62
708
|
}
|
|
63
709
|
|
|
64
710
|
// src/config.ts
|
|
65
|
-
import { existsSync, readFileSync } from "fs";
|
|
66
|
-
import { dirname, isAbsolute, resolve } from "path";
|
|
711
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
712
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
67
713
|
import { pathToFileURL } from "url";
|
|
68
714
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
69
715
|
var DEFAULT_ENVS = ["dev"];
|
|
70
716
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
71
717
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
72
|
-
const resolved =
|
|
73
|
-
if (!
|
|
718
|
+
const resolved = resolve2(configPath);
|
|
719
|
+
if (!existsSync3(resolved)) {
|
|
74
720
|
throw new Error(`config not found: ${configPath}. Run "odla-ai init" first or pass --config.`);
|
|
75
721
|
}
|
|
76
722
|
const raw = await loadConfigModule(resolved);
|
|
77
|
-
const rootDir =
|
|
723
|
+
const rootDir = dirname2(resolved);
|
|
78
724
|
validateRawConfig(raw, resolved);
|
|
79
725
|
const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
|
|
80
726
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
81
727
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
82
728
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
83
729
|
const local = {
|
|
84
|
-
tokenFile:
|
|
85
|
-
credentialsFile:
|
|
86
|
-
devVarsFile:
|
|
730
|
+
tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
731
|
+
credentialsFile: resolve2(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
732
|
+
devVarsFile: resolve2(rootDir, raw.local?.devVarsFile ?? ".dev.vars"),
|
|
87
733
|
gitignore: raw.local?.gitignore ?? true
|
|
88
734
|
};
|
|
89
735
|
return {
|
|
@@ -100,9 +746,9 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
100
746
|
async function resolveDataExport(cfg, value, names) {
|
|
101
747
|
if (value === void 0 || value === null || value === false) return void 0;
|
|
102
748
|
if (typeof value !== "string") return value;
|
|
103
|
-
const target =
|
|
749
|
+
const target = isAbsolute2(value) ? value : resolve2(cfg.rootDir, value);
|
|
104
750
|
if (target.endsWith(".json")) {
|
|
105
|
-
return JSON.parse(
|
|
751
|
+
return JSON.parse(readFileSync2(target, "utf8"));
|
|
106
752
|
}
|
|
107
753
|
const mod = await import(pathToFileURL(target).href);
|
|
108
754
|
for (const name of names) {
|
|
@@ -153,7 +799,7 @@ function validId(value) {
|
|
|
153
799
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
154
800
|
}
|
|
155
801
|
async function loadConfigModule(path) {
|
|
156
|
-
if (path.endsWith(".json")) return JSON.parse(
|
|
802
|
+
if (path.endsWith(".json")) return JSON.parse(readFileSync2(path, "utf8"));
|
|
157
803
|
const mod = await import(`${pathToFileURL(path).href}?t=${Date.now()}`);
|
|
158
804
|
const value = mod.default ?? mod.config;
|
|
159
805
|
if (typeof value === "function") return await value();
|
|
@@ -168,141 +814,15 @@ function unique(values) {
|
|
|
168
814
|
|
|
169
815
|
// src/doctor-checks.ts
|
|
170
816
|
import { execFileSync } from "child_process";
|
|
171
|
-
import { existsSync as
|
|
817
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
172
818
|
import { join as join2, resolve as resolve3 } from "path";
|
|
173
819
|
|
|
174
|
-
// src/local.ts
|
|
175
|
-
import { chmodSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "fs";
|
|
176
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve as resolve2 } from "path";
|
|
177
|
-
var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
|
|
178
|
-
function readJsonFile(path) {
|
|
179
|
-
try {
|
|
180
|
-
return JSON.parse(readFileSync2(path, "utf8"));
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
function writePrivateJson(path, value) {
|
|
186
|
-
writePrivateText(path, `${JSON.stringify(value, null, 2)}
|
|
187
|
-
`);
|
|
188
|
-
}
|
|
189
|
-
function readCredentials(path) {
|
|
190
|
-
if (!existsSync2(path)) return null;
|
|
191
|
-
let value;
|
|
192
|
-
try {
|
|
193
|
-
value = JSON.parse(readFileSync2(path, "utf8"));
|
|
194
|
-
} catch {
|
|
195
|
-
throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
|
|
196
|
-
}
|
|
197
|
-
if (!value || typeof value !== "object" || typeof value.appId !== "string" || !value.envs || typeof value.envs !== "object") {
|
|
198
|
-
throw new Error(`credentials file ${path} has an invalid shape; fix or remove it before provisioning`);
|
|
199
|
-
}
|
|
200
|
-
return value;
|
|
201
|
-
}
|
|
202
|
-
function mergeCredential(current, update) {
|
|
203
|
-
const next = current ?? {
|
|
204
|
-
appId: update.appId,
|
|
205
|
-
platformUrl: update.platformUrl,
|
|
206
|
-
dbEndpoint: update.dbEndpoint,
|
|
207
|
-
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
208
|
-
envs: {}
|
|
209
|
-
};
|
|
210
|
-
next.appId = update.appId;
|
|
211
|
-
next.platformUrl = update.platformUrl;
|
|
212
|
-
next.dbEndpoint = update.dbEndpoint;
|
|
213
|
-
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
214
|
-
next.envs[update.env] = {
|
|
215
|
-
...next.envs[update.env] ?? {},
|
|
216
|
-
tenantId: update.tenantId,
|
|
217
|
-
...update.dbKey ? { dbKey: update.dbKey } : {},
|
|
218
|
-
...update.o11yToken ? { o11yToken: update.o11yToken } : {}
|
|
219
|
-
};
|
|
220
|
-
return next;
|
|
221
|
-
}
|
|
222
|
-
function ensureGitignore(rootDir, localPaths = []) {
|
|
223
|
-
const path = resolve2(rootDir, ".gitignore");
|
|
224
|
-
const existing = existsSync2(path) ? readFileSync2(path, "utf8") : "";
|
|
225
|
-
const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
|
|
226
|
-
const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
|
|
227
|
-
const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
|
|
228
|
-
if (missing.length === 0) return;
|
|
229
|
-
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
230
|
-
writeFileSync(path, `${existing}${prefix}${missing.join("\n")}
|
|
231
|
-
`);
|
|
232
|
-
}
|
|
233
|
-
function o11yDevVars(cfg) {
|
|
234
|
-
if (!cfg.services.includes("o11y")) return void 0;
|
|
235
|
-
return {
|
|
236
|
-
endpoint: cfg.o11y?.endpoint ?? "https://o11y.odla.ai",
|
|
237
|
-
service: cfg.o11y?.service ?? cfg.app.id,
|
|
238
|
-
...cfg.o11y?.version ? { version: cfg.o11y.version } : {}
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
function resolveWriteDevVarsTarget(cfg, requested) {
|
|
242
|
-
if (!requested) return null;
|
|
243
|
-
if (requested === true) return cfg.local.devVarsFile;
|
|
244
|
-
return resolve2(dirname2(cfg.configPath), requested);
|
|
245
|
-
}
|
|
246
|
-
function writeDevVars(path, credentials, env, o11y) {
|
|
247
|
-
const entry = credentials.envs[env];
|
|
248
|
-
if (!entry) throw new Error(`no credentials for env "${env}" in ${path}`);
|
|
249
|
-
const lines = [`ODLA_PLATFORM="${credentials.platformUrl}"`];
|
|
250
|
-
if (entry.dbKey) lines.push(`ODLA_ENDPOINT="${credentials.dbEndpoint}"`);
|
|
251
|
-
lines.push(`ODLA_APP_ID="${credentials.appId}"`, `ODLA_ENV="${env}"`, `ODLA_TENANT="${entry.tenantId}"`);
|
|
252
|
-
if (entry.dbKey) lines.push(`ODLA_API_KEY="${entry.dbKey}"`);
|
|
253
|
-
if (o11y) {
|
|
254
|
-
lines.push(`ODLA_O11Y_ENDPOINT="${o11y.endpoint}"`, `ODLA_O11Y_SERVICE="${o11y.service}"`);
|
|
255
|
-
if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
|
|
256
|
-
if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
|
|
257
|
-
}
|
|
258
|
-
const existing = existsSync2(path) ? readFileSync2(path, "utf8") : "";
|
|
259
|
-
const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
|
|
260
|
-
while (retained.at(-1) === "") retained.pop();
|
|
261
|
-
const prefix = retained.length ? `${retained.join("\n")}
|
|
262
|
-
|
|
263
|
-
` : "";
|
|
264
|
-
writePrivateText(path, `${prefix}${lines.join("\n")}
|
|
265
|
-
`);
|
|
266
|
-
}
|
|
267
|
-
var MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
|
|
268
|
-
"ODLA_PLATFORM",
|
|
269
|
-
"ODLA_ENDPOINT",
|
|
270
|
-
"ODLA_APP_ID",
|
|
271
|
-
"ODLA_ENV",
|
|
272
|
-
"ODLA_TENANT",
|
|
273
|
-
"ODLA_API_KEY",
|
|
274
|
-
"ODLA_O11Y_ENDPOINT",
|
|
275
|
-
"ODLA_O11Y_SERVICE",
|
|
276
|
-
"ODLA_O11Y_VERSION",
|
|
277
|
-
"ODLA_O11Y_TOKEN"
|
|
278
|
-
]);
|
|
279
|
-
function isManagedDevVar(line) {
|
|
280
|
-
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
|
|
281
|
-
return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
|
|
282
|
-
}
|
|
283
|
-
function writePrivateText(path, text) {
|
|
284
|
-
mkdirSync(dirname2(path), { recursive: true });
|
|
285
|
-
const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
286
|
-
writeFileSync(temporary, text, { mode: 384 });
|
|
287
|
-
chmodSync(temporary, 384);
|
|
288
|
-
renameSync(temporary, path);
|
|
289
|
-
}
|
|
290
|
-
function gitignoreEntry(rootDir, path) {
|
|
291
|
-
const rel = relative(resolve2(rootDir), resolve2(path));
|
|
292
|
-
if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute2(rel)) return null;
|
|
293
|
-
return rel.replaceAll("\\", "/");
|
|
294
|
-
}
|
|
295
|
-
function displayPath(path, rootDir = process.cwd()) {
|
|
296
|
-
const rel = relative(rootDir, path);
|
|
297
|
-
return rel && !rel.startsWith("..") ? rel : path;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
820
|
// src/wrangler.ts
|
|
301
|
-
import { spawn } from "child_process";
|
|
302
|
-
import { existsSync as
|
|
821
|
+
import { spawn as spawn2 } from "child_process";
|
|
822
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
303
823
|
import { join } from "path";
|
|
304
824
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
305
|
-
const child =
|
|
825
|
+
const child = spawn2(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
306
826
|
let stdout = "";
|
|
307
827
|
let stderr = "";
|
|
308
828
|
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
@@ -315,7 +835,7 @@ var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
|
|
|
315
835
|
function findWranglerConfig(rootDir) {
|
|
316
836
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
317
837
|
const path = join(rootDir, name);
|
|
318
|
-
if (
|
|
838
|
+
if (existsSync4(path)) return path;
|
|
319
839
|
}
|
|
320
840
|
return null;
|
|
321
841
|
}
|
|
@@ -423,7 +943,7 @@ function wranglerWarnings(rootDir) {
|
|
|
423
943
|
const dir = resolve3(rootDir, assets.directory);
|
|
424
944
|
if (dir === resolve3(rootDir)) {
|
|
425
945
|
warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
|
|
426
|
-
} else if (
|
|
946
|
+
} else if (existsSync5(join2(dir, "node_modules"))) {
|
|
427
947
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
428
948
|
}
|
|
429
949
|
}
|
|
@@ -459,7 +979,7 @@ function o11yProjectWarnings(rootDir) {
|
|
|
459
979
|
return warnings;
|
|
460
980
|
}
|
|
461
981
|
const main = typeof config.main === "string" ? resolve3(rootDir, config.main) : null;
|
|
462
|
-
if (!main || !
|
|
982
|
+
if (!main || !existsSync5(main)) {
|
|
463
983
|
warnings.push("cannot verify o11y Worker instrumentation \u2014 wrangler main is missing or unreadable");
|
|
464
984
|
} else {
|
|
465
985
|
let source = "";
|
|
@@ -546,13 +1066,13 @@ async function doctor(options) {
|
|
|
546
1066
|
}
|
|
547
1067
|
|
|
548
1068
|
// src/init.ts
|
|
549
|
-
import { existsSync as
|
|
1069
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
550
1070
|
import { dirname as dirname3, resolve as resolve4 } from "path";
|
|
551
1071
|
function initProject(options) {
|
|
552
1072
|
const out = options.stdout ?? console;
|
|
553
1073
|
const rootDir = resolve4(options.rootDir ?? process.cwd());
|
|
554
1074
|
const configPath = resolve4(rootDir, options.configPath ?? "odla.config.mjs");
|
|
555
|
-
if (
|
|
1075
|
+
if (existsSync6(configPath) && !options.force) {
|
|
556
1076
|
throw new Error(`${configPath} already exists. Pass --force to overwrite.`);
|
|
557
1077
|
}
|
|
558
1078
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
|
|
@@ -573,7 +1093,7 @@ function initProject(options) {
|
|
|
573
1093
|
out.log("updated .gitignore for local odla credentials");
|
|
574
1094
|
}
|
|
575
1095
|
function writeIfMissing(path, text) {
|
|
576
|
-
if (
|
|
1096
|
+
if (existsSync6(path)) return;
|
|
577
1097
|
writeFileSync2(path, text);
|
|
578
1098
|
}
|
|
579
1099
|
function configTemplate(input) {
|
|
@@ -687,134 +1207,57 @@ async function secretsPushImpl(options, preflight) {
|
|
|
687
1207
|
const entry = credentials.envs[env];
|
|
688
1208
|
if (!entry) throw new Error(`no credentials for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
689
1209
|
const secrets = [];
|
|
690
|
-
if (cfg.services.includes("o11y")) {
|
|
691
|
-
if (!entry.o11yToken) throw new Error(`no o11y token for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
692
|
-
secrets.push({ name: "ODLA_O11Y_TOKEN", value: entry.o11yToken });
|
|
693
|
-
}
|
|
694
|
-
if (cfg.services.includes("db")) {
|
|
695
|
-
if (!entry.dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
696
|
-
secrets.push({ name: "ODLA_API_KEY", value: entry.dbKey });
|
|
697
|
-
}
|
|
698
|
-
if (secrets.length === 0) {
|
|
699
|
-
out.log(`${env}: no configured Worker secrets to push`);
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
|
|
703
|
-
const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
|
|
704
|
-
if (options.dryRun) {
|
|
705
|
-
assertWranglerConfig(cfg);
|
|
706
|
-
for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
const run = options.runner ?? defaultRunner;
|
|
710
|
-
if (preflight) await preflightLoadedConfig(cfg, run);
|
|
711
|
-
for (const s of secrets) {
|
|
712
|
-
const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
|
|
713
|
-
if (result.code !== 0) {
|
|
714
|
-
throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
|
|
715
|
-
}
|
|
716
|
-
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
async function preflightSecretsPush(options) {
|
|
720
|
-
const cfg = await loadProjectConfig(options.configPath);
|
|
721
|
-
if (!cfg.services.some((service) => service === "db" || service === "o11y")) return;
|
|
722
|
-
await preflightLoadedConfig(cfg, options.runner ?? defaultRunner);
|
|
723
|
-
}
|
|
724
|
-
async function preflightLoadedConfig(cfg, run) {
|
|
725
|
-
assertWranglerConfig(cfg);
|
|
726
|
-
if (!await wranglerLoggedIn(run, cfg.rootDir)) {
|
|
727
|
-
throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
function assertWranglerConfig(cfg) {
|
|
731
|
-
if (!findWranglerConfig(cfg.rootDir)) {
|
|
732
|
-
throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// src/provision.ts
|
|
737
|
-
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
738
|
-
import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
|
|
739
|
-
import process4 from "process";
|
|
740
|
-
|
|
741
|
-
// src/token.ts
|
|
742
|
-
import { requestToken } from "@odla-ai/db";
|
|
743
|
-
import process3 from "process";
|
|
744
|
-
|
|
745
|
-
// src/open.ts
|
|
746
|
-
import { spawn as spawn2 } from "child_process";
|
|
747
|
-
import process2 from "process";
|
|
748
|
-
async function openUrl(url, options = {}) {
|
|
749
|
-
const command = openerFor(options.platform ?? process2.platform);
|
|
750
|
-
const doSpawn = options.spawnImpl ?? spawn2;
|
|
751
|
-
await new Promise((resolve6, reject) => {
|
|
752
|
-
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
753
|
-
stdio: "ignore",
|
|
754
|
-
detached: true
|
|
755
|
-
});
|
|
756
|
-
child.once("error", reject);
|
|
757
|
-
child.once("spawn", () => {
|
|
758
|
-
child.unref();
|
|
759
|
-
resolve6();
|
|
760
|
-
});
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
function openerFor(platform) {
|
|
764
|
-
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
765
|
-
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
766
|
-
return { cmd: "xdg-open", args: [] };
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// src/token.ts
|
|
770
|
-
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
771
|
-
if (options.token) return options.token;
|
|
772
|
-
if (process3.env.ODLA_DEV_TOKEN) return process3.env.ODLA_DEV_TOKEN;
|
|
773
|
-
const cached = readJsonFile(cfg.local.tokenFile);
|
|
774
|
-
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
775
|
-
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
776
|
-
return cached.token;
|
|
777
|
-
}
|
|
778
|
-
const browser = approvalBrowser(options);
|
|
779
|
-
const { token, expiresAt } = await requestToken({
|
|
780
|
-
endpoint: cfg.platformUrl,
|
|
781
|
-
label: `${cfg.app.id} provisioner`,
|
|
782
|
-
fetch: doFetch,
|
|
783
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
784
|
-
const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
|
|
785
|
-
out.log("");
|
|
786
|
-
out.log(`Approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
787
|
-
if (browser.open) {
|
|
788
|
-
try {
|
|
789
|
-
await (options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
790
|
-
out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
|
|
791
|
-
} catch (err) {
|
|
792
|
-
out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
793
|
-
}
|
|
794
|
-
} else if (browser.reason) {
|
|
795
|
-
out.log(`auth: browser launch skipped (${browser.reason})`);
|
|
796
|
-
}
|
|
797
|
-
out.log("");
|
|
1210
|
+
if (cfg.services.includes("o11y")) {
|
|
1211
|
+
if (!entry.o11yToken) throw new Error(`no o11y token for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
1212
|
+
secrets.push({ name: "ODLA_O11Y_TOKEN", value: entry.o11yToken });
|
|
1213
|
+
}
|
|
1214
|
+
if (cfg.services.includes("db")) {
|
|
1215
|
+
if (!entry.dbKey) throw new Error(`no db key for env "${env}" in ${credentialsPath} \u2014 run "odla-ai provision" first`);
|
|
1216
|
+
secrets.push({ name: "ODLA_API_KEY", value: entry.dbKey });
|
|
1217
|
+
}
|
|
1218
|
+
if (secrets.length === 0) {
|
|
1219
|
+
out.log(`${env}: no configured Worker secrets to push`);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const wranglerEnv = PROD_ENV_NAMES.has(env) ? void 0 : env;
|
|
1223
|
+
const target = wranglerEnv ? `wrangler env "${wranglerEnv}"` : "the top-level (prod) wrangler env";
|
|
1224
|
+
if (options.dryRun) {
|
|
1225
|
+
assertWranglerConfig(cfg);
|
|
1226
|
+
for (const s of secrets) out.log(`dry run: would push ${s.name} (${redactSecrets(s.value)}) to ${target}`);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const run = options.runner ?? defaultRunner;
|
|
1230
|
+
if (preflight) await preflightLoadedConfig(cfg, run);
|
|
1231
|
+
for (const s of secrets) {
|
|
1232
|
+
const result = await wranglerPutSecret(run, { name: s.name, value: s.value, env: wranglerEnv, cwd: cfg.rootDir });
|
|
1233
|
+
if (result.code !== 0) {
|
|
1234
|
+
throw new Error(`wrangler secret put ${s.name} failed (exit ${result.code}): ${redactSecrets(`${result.stderr || result.stdout}`.trim())}`);
|
|
798
1235
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
802
|
-
return token;
|
|
1236
|
+
out.log(`${s.name} pushed to ${target} (value read from ${credentialsPath}, never echoed)`);
|
|
1237
|
+
}
|
|
803
1238
|
}
|
|
804
|
-
function
|
|
805
|
-
|
|
806
|
-
if (
|
|
807
|
-
|
|
808
|
-
const interactive = options.interactive ?? Boolean(process3.stdin.isTTY && process3.stdout.isTTY);
|
|
809
|
-
if (!interactive) return { open: false, reason: "non-interactive shell; pass --open to force" };
|
|
810
|
-
return { open: true, mode: "auto" };
|
|
1239
|
+
async function preflightSecretsPush(options) {
|
|
1240
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1241
|
+
if (!cfg.services.some((service) => service === "db" || service === "o11y")) return;
|
|
1242
|
+
await preflightLoadedConfig(cfg, options.runner ?? defaultRunner);
|
|
811
1243
|
}
|
|
812
|
-
function
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1244
|
+
async function preflightLoadedConfig(cfg, run) {
|
|
1245
|
+
assertWranglerConfig(cfg);
|
|
1246
|
+
if (!await wranglerLoggedIn(run, cfg.rootDir)) {
|
|
1247
|
+
throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function assertWranglerConfig(cfg) {
|
|
1251
|
+
if (!findWranglerConfig(cfg.rootDir)) {
|
|
1252
|
+
throw new Error(`no wrangler config found in ${cfg.rootDir} (wrangler.jsonc, wrangler.json, or wrangler.toml)`);
|
|
1253
|
+
}
|
|
816
1254
|
}
|
|
817
1255
|
|
|
1256
|
+
// src/provision.ts
|
|
1257
|
+
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
1258
|
+
import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
|
|
1259
|
+
import process6 from "process";
|
|
1260
|
+
|
|
818
1261
|
// src/provision-credentials.ts
|
|
819
1262
|
import { tenantIdFor } from "@odla-ai/apps";
|
|
820
1263
|
async function provisionEnvCredentials(opts) {
|
|
@@ -1044,7 +1487,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1044
1487
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1045
1488
|
}
|
|
1046
1489
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1047
|
-
const key =
|
|
1490
|
+
const key = process6.env[cfg.ai.keyEnv];
|
|
1048
1491
|
if (key) {
|
|
1049
1492
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1050
1493
|
await putSecret({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1104,10 +1547,121 @@ async function safeText2(res) {
|
|
|
1104
1547
|
}
|
|
1105
1548
|
}
|
|
1106
1549
|
|
|
1550
|
+
// src/security.ts
|
|
1551
|
+
import { isAbsolute as isAbsolute3, relative as relative2, resolve as resolve5, sep } from "path";
|
|
1552
|
+
import {
|
|
1553
|
+
cloudflareAppProfile,
|
|
1554
|
+
createPlatformSecurityReasoners,
|
|
1555
|
+
createSecurityHarness,
|
|
1556
|
+
genericProfile,
|
|
1557
|
+
odlaProfile,
|
|
1558
|
+
securityFingerprint
|
|
1559
|
+
} from "@odla-ai/security";
|
|
1560
|
+
import { FileRunStore, snapshotDirectory, writeSecurityArtifacts } from "@odla-ai/security/node";
|
|
1561
|
+
async function runHostedSecurity(options) {
|
|
1562
|
+
if (options.sourceDisclosureAck !== "redacted") {
|
|
1563
|
+
throw new Error("Hosted security requires sourceDisclosureAck=redacted before repository source may leave this process");
|
|
1564
|
+
}
|
|
1565
|
+
const selfAudit = options.selfAudit === true;
|
|
1566
|
+
const cfg = selfAudit ? void 0 : await loadProjectConfig(options.configPath ?? "odla.config.mjs");
|
|
1567
|
+
const appId = selfAudit ? "odla-ai" : cfg.app.id;
|
|
1568
|
+
const env = selfAudit ? "prod" : selectEnv(options.env, cfg.envs, cfg.configPath, cfg.rootDir);
|
|
1569
|
+
const platform = options.platform ?? cfg?.platformUrl ?? "https://odla.ai";
|
|
1570
|
+
const target = resolve5(options.target ?? cfg?.rootDir ?? ".");
|
|
1571
|
+
const output = resolve5(options.out ?? resolve5(target, ".odla/security/hosted"));
|
|
1572
|
+
const outputRelative = relative2(target, output).split(sep).join("/");
|
|
1573
|
+
if (!outputRelative) throw new Error("Hosted security output cannot be the repository root");
|
|
1574
|
+
const profile = profileFor(options.profile ?? "odla", options.maxHuntTasks ?? 12);
|
|
1575
|
+
const tokenRequest = {
|
|
1576
|
+
platform,
|
|
1577
|
+
appId,
|
|
1578
|
+
env,
|
|
1579
|
+
selfAudit,
|
|
1580
|
+
scope: selfAudit ? "platform:security:self" : null
|
|
1581
|
+
};
|
|
1582
|
+
const token = await injectedToken(options, tokenRequest);
|
|
1583
|
+
const snapshot = await snapshotDirectory(target, {
|
|
1584
|
+
exclude: !outputRelative.startsWith("../") && !isAbsolute3(outputRelative) ? [outputRelative] : []
|
|
1585
|
+
});
|
|
1586
|
+
const hosted = await createPlatformSecurityReasoners({
|
|
1587
|
+
platform,
|
|
1588
|
+
token,
|
|
1589
|
+
appId,
|
|
1590
|
+
env,
|
|
1591
|
+
repository: snapshot.repository,
|
|
1592
|
+
revision: snapshot.revision,
|
|
1593
|
+
snapshotDigest: snapshot.digest,
|
|
1594
|
+
sourceDisclosure: "redacted",
|
|
1595
|
+
clientRunId: options.runId ?? crypto.randomUUID(),
|
|
1596
|
+
...selfAudit ? { selfAudit: { enabled: true, subject: "service:odla-security-self-audit" } } : {},
|
|
1597
|
+
fetch: options.fetch,
|
|
1598
|
+
signal: options.signal
|
|
1599
|
+
});
|
|
1600
|
+
const harness = createSecurityHarness({
|
|
1601
|
+
profile,
|
|
1602
|
+
store: new FileRunStore(resolve5(output, "state")),
|
|
1603
|
+
discoveryReasoner: hosted.discoveryReasoner,
|
|
1604
|
+
validationReasoner: hosted.validationReasoner,
|
|
1605
|
+
policy: {
|
|
1606
|
+
modelSourceDisclosure: "redacted",
|
|
1607
|
+
active: false,
|
|
1608
|
+
allowNetwork: false
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
const report = await harness.run(snapshot, { runId: hosted.run.runId, signal: options.signal });
|
|
1612
|
+
await writeSecurityArtifacts(output, report);
|
|
1613
|
+
const reportDigest = await securityFingerprint(report);
|
|
1614
|
+
await hosted.complete({
|
|
1615
|
+
reportDigest,
|
|
1616
|
+
coverageStatus: report.coverageStatus,
|
|
1617
|
+
confirmed: report.metrics.confirmed,
|
|
1618
|
+
candidates: report.metrics.candidates
|
|
1619
|
+
}, { signal: options.signal });
|
|
1620
|
+
printSummary(options.stdout ?? console, appId, env, hosted.run, report, output);
|
|
1621
|
+
return Object.freeze({ report, run: hosted.run, output });
|
|
1622
|
+
}
|
|
1623
|
+
function selectEnv(requested, declared, configPath, rootDir) {
|
|
1624
|
+
const env = requested ?? (declared.includes("dev") ? "dev" : declared[0]);
|
|
1625
|
+
if (!env || !declared.includes(env)) {
|
|
1626
|
+
const shown = relative2(rootDir, configPath) || configPath;
|
|
1627
|
+
throw new Error(`env "${env ?? ""}" is not declared in ${shown}`);
|
|
1628
|
+
}
|
|
1629
|
+
return env;
|
|
1630
|
+
}
|
|
1631
|
+
async function injectedToken(options, request) {
|
|
1632
|
+
const value = options.token ?? await options.getToken?.(Object.freeze({ ...request }));
|
|
1633
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
request.selfAudit ? "Self-audit requires an injected, scoped platform security token" : "Hosted security requires an injected app developer token or getToken callback"
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
return value;
|
|
1639
|
+
}
|
|
1640
|
+
function profileFor(name, maxHuntTasks) {
|
|
1641
|
+
const profile = name === "odla" ? odlaProfile() : name === "cloudflare-app" ? cloudflareAppProfile() : name === "generic" ? genericProfile() : void 0;
|
|
1642
|
+
if (!profile) throw new Error(`unknown security profile "${String(name)}"`);
|
|
1643
|
+
if (maxHuntTasks === void 0) return profile;
|
|
1644
|
+
if (!Number.isSafeInteger(maxHuntTasks) || maxHuntTasks < 1) throw new Error("maxHuntTasks must be a positive integer");
|
|
1645
|
+
return { ...profile, maxHuntTasks };
|
|
1646
|
+
}
|
|
1647
|
+
function printSummary(out, appId, env, run, report, output) {
|
|
1648
|
+
const complete = report.coverage.filter((cell) => cell.state === "complete").length;
|
|
1649
|
+
out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
|
|
1650
|
+
out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
|
|
1651
|
+
out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
|
|
1652
|
+
out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells} budget_exhausted=${report.metrics.budgetExhaustedCells}`);
|
|
1653
|
+
if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
|
|
1654
|
+
out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
|
|
1655
|
+
out.log(` report: ${resolve5(output, "REPORT.md")}`);
|
|
1656
|
+
}
|
|
1657
|
+
function formatBudget(usage) {
|
|
1658
|
+
return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1107
1661
|
// src/skill.ts
|
|
1108
|
-
import { existsSync as
|
|
1662
|
+
import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1109
1663
|
import { homedir } from "os";
|
|
1110
|
-
import { dirname as dirname4, isAbsolute as
|
|
1664
|
+
import { dirname as dirname4, isAbsolute as isAbsolute4, join as join3, relative as relative3, resolve as resolve6, sep as sep2 } from "path";
|
|
1111
1665
|
import { fileURLToPath } from "url";
|
|
1112
1666
|
|
|
1113
1667
|
// src/skill-adapters.ts
|
|
@@ -1168,8 +1722,8 @@ function installSkill(options = {}) {
|
|
|
1168
1722
|
const files = listFiles(sourceDir);
|
|
1169
1723
|
if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
|
|
1170
1724
|
const harnesses = normalizeHarnesses(options.harnesses, options.global === true);
|
|
1171
|
-
const root =
|
|
1172
|
-
const home =
|
|
1725
|
+
const root = resolve6(options.dir ?? process.cwd());
|
|
1726
|
+
const home = resolve6(options.homeDir ?? homedir());
|
|
1173
1727
|
const plans = /* @__PURE__ */ new Map();
|
|
1174
1728
|
const targets = /* @__PURE__ */ new Map();
|
|
1175
1729
|
const rememberTarget = (harness, target) => {
|
|
@@ -1188,7 +1742,7 @@ function installSkill(options = {}) {
|
|
|
1188
1742
|
let targetDir;
|
|
1189
1743
|
if (options.global) {
|
|
1190
1744
|
const claudeRoot = join3(home, ".claude", "skills");
|
|
1191
|
-
const codexRoot =
|
|
1745
|
+
const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ?? join3(home, ".codex"), "skills");
|
|
1192
1746
|
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
1193
1747
|
for (const harness of harnesses) {
|
|
1194
1748
|
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
@@ -1238,7 +1792,7 @@ function installSkill(options = {}) {
|
|
|
1238
1792
|
conflicts.push(`${file.target} (redirected by symbolic link ${symlink})`);
|
|
1239
1793
|
continue;
|
|
1240
1794
|
}
|
|
1241
|
-
if (!
|
|
1795
|
+
if (!existsSync7(file.target)) {
|
|
1242
1796
|
writtenPaths.add(file.target);
|
|
1243
1797
|
continue;
|
|
1244
1798
|
}
|
|
@@ -1259,7 +1813,7 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
1259
1813
|
);
|
|
1260
1814
|
}
|
|
1261
1815
|
for (const file of plans.values()) {
|
|
1262
|
-
if (!
|
|
1816
|
+
if (!existsSync7(file.target) || readFileSync5(file.target, "utf8") !== file.content) {
|
|
1263
1817
|
mkdirSync3(dirname4(file.target), { recursive: true });
|
|
1264
1818
|
writeFileSync3(file.target, file.content);
|
|
1265
1819
|
}
|
|
@@ -1280,7 +1834,7 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
1280
1834
|
};
|
|
1281
1835
|
}
|
|
1282
1836
|
function pathsUnder(root, paths) {
|
|
1283
|
-
return [...paths].map((path) =>
|
|
1837
|
+
return [...paths].map((path) => relative3(root, path)).filter((path) => path !== ".." && !path.startsWith(`..${sep2}`) && !isAbsolute4(path)).sort();
|
|
1284
1838
|
}
|
|
1285
1839
|
function normalizeHarnesses(values, global) {
|
|
1286
1840
|
const requested = values?.length ? values : ["claude"];
|
|
@@ -1302,7 +1856,7 @@ function normalizeHarnesses(values, global) {
|
|
|
1302
1856
|
function managedFileContent(path, block, force, boundary) {
|
|
1303
1857
|
const symlink = symlinkedComponent(boundary, path);
|
|
1304
1858
|
if (symlink) throw new Error(`refusing to manage ${path}: symbolic link component ${symlink}`);
|
|
1305
|
-
if (!
|
|
1859
|
+
if (!existsSync7(path)) return `${block}
|
|
1306
1860
|
`;
|
|
1307
1861
|
const current = readFileSync5(path, "utf8");
|
|
1308
1862
|
const start = "<!-- odla-ai agent setup:start -->";
|
|
@@ -1325,12 +1879,12 @@ function managedFileContent(path, block, force, boundary) {
|
|
|
1325
1879
|
return `${current.slice(0, startAt)}${block}${current.slice(afterEnd)}`;
|
|
1326
1880
|
}
|
|
1327
1881
|
function symlinkedComponent(boundary, target) {
|
|
1328
|
-
const rel =
|
|
1329
|
-
if (rel === ".." || rel.startsWith(`..${
|
|
1882
|
+
const rel = relative3(boundary, target);
|
|
1883
|
+
if (rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute4(rel)) {
|
|
1330
1884
|
throw new Error(`agent setup target escapes its install root: ${target}`);
|
|
1331
1885
|
}
|
|
1332
1886
|
let current = boundary;
|
|
1333
|
-
for (const part of rel.split(
|
|
1887
|
+
for (const part of rel.split(sep2).filter(Boolean)) {
|
|
1334
1888
|
current = join3(current, part);
|
|
1335
1889
|
try {
|
|
1336
1890
|
if (lstatSync(current).isSymbolicLink()) return current;
|
|
@@ -1344,13 +1898,13 @@ function skillNames(files) {
|
|
|
1344
1898
|
return [...new Set(files.filter((file) => /(^|[\\/])SKILL\.md$/.test(file)).map((file) => file.split(/[\\/]/)[0]))].sort();
|
|
1345
1899
|
}
|
|
1346
1900
|
function listFiles(dir) {
|
|
1347
|
-
if (!
|
|
1901
|
+
if (!existsSync7(dir)) return [];
|
|
1348
1902
|
const results = [];
|
|
1349
1903
|
const walk = (current) => {
|
|
1350
1904
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
1351
1905
|
const path = join3(current, entry.name);
|
|
1352
1906
|
if (entry.isDirectory()) walk(path);
|
|
1353
|
-
else results.push(
|
|
1907
|
+
else results.push(relative3(dir, path));
|
|
1354
1908
|
}
|
|
1355
1909
|
};
|
|
1356
1910
|
walk(dir);
|
|
@@ -1439,7 +1993,140 @@ async function safeText3(res) {
|
|
|
1439
1993
|
}
|
|
1440
1994
|
|
|
1441
1995
|
// src/cli.ts
|
|
1996
|
+
import { findingsAtOrAbove } from "@odla-ai/security";
|
|
1997
|
+
|
|
1998
|
+
// src/argv.ts
|
|
1999
|
+
function parseArgv(argv) {
|
|
2000
|
+
const positionals = [];
|
|
2001
|
+
const options = {};
|
|
2002
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2003
|
+
const arg = argv[i];
|
|
2004
|
+
if (!arg.startsWith("--")) {
|
|
2005
|
+
positionals.push(arg);
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
const eq = arg.indexOf("=");
|
|
2009
|
+
const rawName = arg.slice(2, eq === -1 ? void 0 : eq);
|
|
2010
|
+
if (!rawName) continue;
|
|
2011
|
+
if (rawName.startsWith("no-")) {
|
|
2012
|
+
options[rawName.slice(3)] = false;
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
if (eq !== -1) {
|
|
2016
|
+
addOption(options, rawName, arg.slice(eq + 1));
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
const value = argv[i + 1];
|
|
2020
|
+
if (value !== void 0 && !value.startsWith("--")) {
|
|
2021
|
+
i++;
|
|
2022
|
+
addOption(options, rawName, value);
|
|
2023
|
+
} else {
|
|
2024
|
+
addOption(options, rawName, true);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return { positionals, options };
|
|
2028
|
+
}
|
|
2029
|
+
function assertArgs(parsed, allowedOptions, maxPositionals) {
|
|
2030
|
+
const allowed = new Set(allowedOptions);
|
|
2031
|
+
for (const name of Object.keys(parsed.options)) {
|
|
2032
|
+
if (!allowed.has(name)) throw new Error(`unknown option "--${name}"; run "odla-ai help" for supported options`);
|
|
2033
|
+
}
|
|
2034
|
+
if (parsed.positionals.length > maxPositionals) {
|
|
2035
|
+
throw new Error(`unexpected argument "${parsed.positionals[maxPositionals]}"; run "odla-ai help"`);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
function requiredString(value, name) {
|
|
2039
|
+
const result = stringOpt(value);
|
|
2040
|
+
if (!result) throw new Error(`${name} is required`);
|
|
2041
|
+
return result;
|
|
2042
|
+
}
|
|
2043
|
+
function stringOpt(value) {
|
|
2044
|
+
if (typeof value === "string") return value;
|
|
2045
|
+
if (Array.isArray(value)) return value[value.length - 1];
|
|
2046
|
+
return void 0;
|
|
2047
|
+
}
|
|
2048
|
+
function listOpt(value) {
|
|
2049
|
+
if (value === void 0 || typeof value === "boolean") return void 0;
|
|
2050
|
+
const values = Array.isArray(value) ? value : [value];
|
|
2051
|
+
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
2052
|
+
}
|
|
2053
|
+
function boolOpt(value) {
|
|
2054
|
+
if (typeof value === "boolean") return value;
|
|
2055
|
+
if (typeof value === "string" && (value === "true" || value === "false")) return value === "true";
|
|
2056
|
+
if (value === void 0) return void 0;
|
|
2057
|
+
throw new Error("boolean option must be true or false");
|
|
2058
|
+
}
|
|
2059
|
+
function numberOpt(value, flag) {
|
|
2060
|
+
if (value === void 0) return void 0;
|
|
2061
|
+
const raw = stringOpt(value);
|
|
2062
|
+
const parsed = raw === void 0 ? Number.NaN : Number(raw);
|
|
2063
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${flag} requires a positive integer`);
|
|
2064
|
+
return parsed;
|
|
2065
|
+
}
|
|
2066
|
+
function addOption(options, name, value) {
|
|
2067
|
+
const current = options[name];
|
|
2068
|
+
if (current === void 0) options[name] = value;
|
|
2069
|
+
else if (Array.isArray(current)) current.push(String(value));
|
|
2070
|
+
else options[name] = [String(current), String(value)];
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// src/help.ts
|
|
1442
2074
|
import { readFileSync as readFileSync6 } from "fs";
|
|
2075
|
+
function cliVersion() {
|
|
2076
|
+
const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
|
|
2077
|
+
return pkg.version ?? "unknown";
|
|
2078
|
+
}
|
|
2079
|
+
function printHelp() {
|
|
2080
|
+
console.log(`odla-ai
|
|
2081
|
+
|
|
2082
|
+
Usage:
|
|
2083
|
+
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2084
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
2085
|
+
odla-ai doctor [--config odla.config.mjs]
|
|
2086
|
+
odla-ai capabilities [--json]
|
|
2087
|
+
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
2088
|
+
odla-ai admin ai models [--provider <id>] [--json]
|
|
2089
|
+
odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
|
|
2090
|
+
odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
|
|
2091
|
+
--validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
|
|
2092
|
+
odla-ai admin ai credentials [--json]
|
|
2093
|
+
odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
|
|
2094
|
+
odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
|
|
2095
|
+
odla-ai admin ai audit [--limit <1-200>] [--json]
|
|
2096
|
+
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
2097
|
+
odla-ai security run [target] --self --ack-redacted-source
|
|
2098
|
+
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
2099
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
2100
|
+
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2101
|
+
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
2102
|
+
odla-ai version
|
|
2103
|
+
|
|
2104
|
+
Commands:
|
|
2105
|
+
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
2106
|
+
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
2107
|
+
doctor Validate and summarize the project config without network calls.
|
|
2108
|
+
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
2109
|
+
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
2110
|
+
security Run hosted discovery + independent validation with app/run attribution.
|
|
2111
|
+
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
2112
|
+
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
2113
|
+
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
2114
|
+
copilot, gemini, or agents (repeatable or comma-separated).
|
|
2115
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
|
|
2116
|
+
version Print the CLI version.
|
|
2117
|
+
|
|
2118
|
+
Safety:
|
|
2119
|
+
New projects target dev only. Add prod explicitly to odla.config.mjs and pass
|
|
2120
|
+
--yes to provision it; use --dry-run first to inspect the resolved plan.
|
|
2121
|
+
Provision caches the approved developer token and service credentials under
|
|
2122
|
+
.odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
|
|
2123
|
+
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
2124
|
+
Provision opens the approval page automatically in interactive terminals;
|
|
2125
|
+
use --open to force browser launch or --no-open to suppress it.
|
|
2126
|
+
`);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/cli.ts
|
|
1443
2130
|
async function runCli(argv = process.argv.slice(2)) {
|
|
1444
2131
|
const parsed = parseArgv(argv);
|
|
1445
2132
|
const command = parsed.positionals[0] ?? "help";
|
|
@@ -1476,6 +2163,119 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1476
2163
|
printCapabilities(parsed.options.json === true);
|
|
1477
2164
|
return;
|
|
1478
2165
|
}
|
|
2166
|
+
if (command === "admin") {
|
|
2167
|
+
const area = parsed.positionals[1];
|
|
2168
|
+
const action = parsed.positionals[2];
|
|
2169
|
+
const credentialSet = action === "credential" && parsed.positionals[3] === "set";
|
|
2170
|
+
const credentials = action === "credentials";
|
|
2171
|
+
const models = action === "models";
|
|
2172
|
+
const usage = action === "usage";
|
|
2173
|
+
const audit = action === "audit";
|
|
2174
|
+
if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
|
|
2175
|
+
throw new Error('unknown admin command. Try "odla-ai admin ai show".');
|
|
2176
|
+
}
|
|
2177
|
+
assertArgs(parsed, [
|
|
2178
|
+
"platform",
|
|
2179
|
+
"json",
|
|
2180
|
+
"open",
|
|
2181
|
+
"provider",
|
|
2182
|
+
"model",
|
|
2183
|
+
"enabled",
|
|
2184
|
+
"max-input-bytes",
|
|
2185
|
+
"max-output-tokens",
|
|
2186
|
+
"max-calls-per-run",
|
|
2187
|
+
"from-env",
|
|
2188
|
+
"stdin",
|
|
2189
|
+
"discovery-provider",
|
|
2190
|
+
"discovery-model",
|
|
2191
|
+
"validation-provider",
|
|
2192
|
+
"validation-model",
|
|
2193
|
+
"app-id",
|
|
2194
|
+
"env",
|
|
2195
|
+
"run-id",
|
|
2196
|
+
"limit"
|
|
2197
|
+
], credentialSet ? 5 : action === "set" ? 4 : 3);
|
|
2198
|
+
await adminAi({
|
|
2199
|
+
action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
|
|
2200
|
+
purpose: action === "set" ? parsed.positionals[3] : void 0,
|
|
2201
|
+
provider: stringOpt(parsed.options.provider),
|
|
2202
|
+
model: stringOpt(parsed.options.model),
|
|
2203
|
+
enabled: boolOpt(parsed.options.enabled),
|
|
2204
|
+
maxInputBytes: numberOpt(parsed.options["max-input-bytes"], "--max-input-bytes"),
|
|
2205
|
+
maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
|
|
2206
|
+
maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
|
|
2207
|
+
platform: stringOpt(parsed.options.platform),
|
|
2208
|
+
json: parsed.options.json === true,
|
|
2209
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
2210
|
+
credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
|
|
2211
|
+
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
2212
|
+
stdin: parsed.options.stdin === true,
|
|
2213
|
+
discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
|
|
2214
|
+
discoveryModel: stringOpt(parsed.options["discovery-model"]),
|
|
2215
|
+
validationProvider: stringOpt(parsed.options["validation-provider"]),
|
|
2216
|
+
validationModel: stringOpt(parsed.options["validation-model"]),
|
|
2217
|
+
appId: stringOpt(parsed.options["app-id"]),
|
|
2218
|
+
env: stringOpt(parsed.options.env),
|
|
2219
|
+
runId: stringOpt(parsed.options["run-id"]),
|
|
2220
|
+
limit: numberOpt(parsed.options.limit, "--limit")
|
|
2221
|
+
});
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (command === "security") {
|
|
2225
|
+
const sub = parsed.positionals[1];
|
|
2226
|
+
if (sub !== "run") throw new Error('unknown security command. Try "odla-ai security run".');
|
|
2227
|
+
assertArgs(parsed, [
|
|
2228
|
+
"config",
|
|
2229
|
+
"env",
|
|
2230
|
+
"out",
|
|
2231
|
+
"profile",
|
|
2232
|
+
"max-hunt-tasks",
|
|
2233
|
+
"run-id",
|
|
2234
|
+
"platform",
|
|
2235
|
+
"self",
|
|
2236
|
+
"ack-redacted-source",
|
|
2237
|
+
"open",
|
|
2238
|
+
"fail-on",
|
|
2239
|
+
"fail-on-candidates",
|
|
2240
|
+
"allow-incomplete"
|
|
2241
|
+
], 3);
|
|
2242
|
+
const configPath = stringOpt(parsed.options.config) ?? "odla.config.mjs";
|
|
2243
|
+
const selfAudit = parsed.options.self === true;
|
|
2244
|
+
const open = parsed.options.open === false ? false : parsed.options.open === true ? true : void 0;
|
|
2245
|
+
const platform = stringOpt(parsed.options.platform);
|
|
2246
|
+
const result = await runHostedSecurity({
|
|
2247
|
+
configPath,
|
|
2248
|
+
selfAudit,
|
|
2249
|
+
target: parsed.positionals[2],
|
|
2250
|
+
out: stringOpt(parsed.options.out),
|
|
2251
|
+
env: stringOpt(parsed.options.env),
|
|
2252
|
+
profile: securityProfile(stringOpt(parsed.options.profile)),
|
|
2253
|
+
maxHuntTasks: numberOpt(parsed.options["max-hunt-tasks"], "--max-hunt-tasks"),
|
|
2254
|
+
runId: stringOpt(parsed.options["run-id"]),
|
|
2255
|
+
platform,
|
|
2256
|
+
sourceDisclosureAck: parsed.options["ack-redacted-source"] === true ? "redacted" : void 0,
|
|
2257
|
+
getToken: async (request) => {
|
|
2258
|
+
if (request.scope === "platform:security:self") {
|
|
2259
|
+
return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
|
|
2260
|
+
}
|
|
2261
|
+
const cfg = await loadProjectConfig(configPath);
|
|
2262
|
+
if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
|
|
2263
|
+
throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
|
|
2264
|
+
}
|
|
2265
|
+
return getDeveloperToken(cfg, { configPath, open }, fetch, console);
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
const failOn = severityOpt(stringOpt(parsed.options["fail-on"]) ?? "high", "--fail-on");
|
|
2269
|
+
const candidateValue = parsed.options["fail-on-candidates"];
|
|
2270
|
+
const failOnCandidates = candidateValue === false ? void 0 : severityOpt(stringOpt(candidateValue) ?? "critical", "--fail-on-candidates");
|
|
2271
|
+
const confirmed = findingsAtOrAbove(result.report, failOn);
|
|
2272
|
+
const leads = failOnCandidates ? findingsAtOrAbove(result.report, failOnCandidates, true).filter((finding) => finding.disposition !== "confirmed") : [];
|
|
2273
|
+
const incomplete = result.report.coverageStatus === "incomplete" && parsed.options["allow-incomplete"] !== true;
|
|
2274
|
+
if (confirmed.length || leads.length || incomplete) {
|
|
2275
|
+
throw new Error(`hosted security gate failed: ${confirmed.length} confirmed >= ${failOn}; ${leads.length} leads >= ${failOnCandidates ?? "disabled"}${incomplete ? "; coverage incomplete" : ""}`);
|
|
2276
|
+
}
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
1479
2279
|
if (command === "provision") {
|
|
1480
2280
|
assertArgs(
|
|
1481
2281
|
parsed,
|
|
@@ -1556,71 +2356,14 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
1556
2356
|
}
|
|
1557
2357
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
1558
2358
|
}
|
|
1559
|
-
function
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
throw new Error(`unknown option "--${name}"; run "odla-ai help" for supported options`);
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
if (parsed.positionals.length > maxPositionals) {
|
|
1567
|
-
throw new Error(`unexpected argument "${parsed.positionals[maxPositionals]}"; run "odla-ai help"`);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
function parseArgv(argv) {
|
|
1571
|
-
const positionals = [];
|
|
1572
|
-
const options = {};
|
|
1573
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1574
|
-
const arg = argv[i];
|
|
1575
|
-
if (!arg.startsWith("--")) {
|
|
1576
|
-
positionals.push(arg);
|
|
1577
|
-
continue;
|
|
1578
|
-
}
|
|
1579
|
-
const eq = arg.indexOf("=");
|
|
1580
|
-
const rawName = arg.slice(2, eq === -1 ? void 0 : eq);
|
|
1581
|
-
if (!rawName) continue;
|
|
1582
|
-
if (rawName.startsWith("no-")) {
|
|
1583
|
-
options[rawName.slice(3)] = false;
|
|
1584
|
-
continue;
|
|
1585
|
-
}
|
|
1586
|
-
if (eq !== -1) {
|
|
1587
|
-
addOption(options, rawName, arg.slice(eq + 1));
|
|
1588
|
-
continue;
|
|
1589
|
-
}
|
|
1590
|
-
const value = argv[i + 1];
|
|
1591
|
-
if (value !== void 0 && !value.startsWith("--")) {
|
|
1592
|
-
i++;
|
|
1593
|
-
addOption(options, rawName, value);
|
|
1594
|
-
} else {
|
|
1595
|
-
addOption(options, rawName, true);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
return { positionals, options };
|
|
1599
|
-
}
|
|
1600
|
-
function addOption(options, name, value) {
|
|
1601
|
-
const cur = options[name];
|
|
1602
|
-
if (cur === void 0) {
|
|
1603
|
-
options[name] = value;
|
|
1604
|
-
} else if (Array.isArray(cur)) {
|
|
1605
|
-
cur.push(String(value));
|
|
1606
|
-
} else {
|
|
1607
|
-
options[name] = [String(cur), String(value)];
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
function requiredString(value, name) {
|
|
1611
|
-
const s = stringOpt(value);
|
|
1612
|
-
if (!s) throw new Error(`${name} is required`);
|
|
1613
|
-
return s;
|
|
1614
|
-
}
|
|
1615
|
-
function stringOpt(value) {
|
|
1616
|
-
if (typeof value === "string") return value;
|
|
1617
|
-
if (Array.isArray(value)) return value[value.length - 1];
|
|
1618
|
-
return void 0;
|
|
2359
|
+
function securityProfile(value) {
|
|
2360
|
+
if (value === void 0) return void 0;
|
|
2361
|
+
if (value === "odla" || value === "cloudflare-app" || value === "generic") return value;
|
|
2362
|
+
throw new Error("--profile must be odla, cloudflare-app, or generic");
|
|
1619
2363
|
}
|
|
1620
|
-
function
|
|
1621
|
-
if (
|
|
1622
|
-
|
|
1623
|
-
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
2364
|
+
function severityOpt(value, flag) {
|
|
2365
|
+
if (["informational", "low", "medium", "high", "critical"].includes(value)) return value;
|
|
2366
|
+
throw new Error(`${flag} must be informational, low, medium, high, or critical`);
|
|
1624
2367
|
}
|
|
1625
2368
|
function harnessList(parsed) {
|
|
1626
2369
|
const selected = [
|
|
@@ -1637,48 +2380,11 @@ function harnessOption(value, flag) {
|
|
|
1637
2380
|
if (parts.some((item) => !item.trim())) throw new Error(`${flag} requires a harness name`);
|
|
1638
2381
|
return parts.map((item) => item.trim());
|
|
1639
2382
|
}
|
|
1640
|
-
function cliVersion() {
|
|
1641
|
-
const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf8"));
|
|
1642
|
-
return pkg.version ?? "unknown";
|
|
1643
|
-
}
|
|
1644
|
-
function printHelp() {
|
|
1645
|
-
console.log(`odla-ai
|
|
1646
|
-
|
|
1647
|
-
Usage:
|
|
1648
|
-
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1649
|
-
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
1650
|
-
odla-ai doctor [--config odla.config.mjs]
|
|
1651
|
-
odla-ai capabilities [--json]
|
|
1652
|
-
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1653
|
-
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
1654
|
-
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1655
|
-
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1656
|
-
odla-ai version
|
|
1657
|
-
|
|
1658
|
-
Commands:
|
|
1659
|
-
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1660
|
-
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1661
|
-
doctor Validate and summarize the project config without network calls.
|
|
1662
|
-
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1663
|
-
provision Register services, configure them, persist credentials, optionally push secrets.
|
|
1664
|
-
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
1665
|
-
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1666
|
-
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1667
|
-
secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
|
|
1668
|
-
version Print the CLI version.
|
|
1669
|
-
|
|
1670
|
-
Safety:
|
|
1671
|
-
New projects target dev only. Add prod explicitly to odla.config.mjs and pass
|
|
1672
|
-
--yes to provision it; use --dry-run first to inspect the resolved plan.
|
|
1673
|
-
Provision caches the approved developer token and service credentials under
|
|
1674
|
-
.odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
|
|
1675
|
-
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1676
|
-
Provision opens the approval page automatically in interactive terminals;
|
|
1677
|
-
use --open to force browser launch or --no-open to suppress it.
|
|
1678
|
-
`);
|
|
1679
|
-
}
|
|
1680
2383
|
|
|
1681
2384
|
export {
|
|
2385
|
+
getScopedPlatformToken,
|
|
2386
|
+
SYSTEM_AI_PURPOSES,
|
|
2387
|
+
adminAi,
|
|
1682
2388
|
CAPABILITIES,
|
|
1683
2389
|
printCapabilities,
|
|
1684
2390
|
redactSecrets,
|
|
@@ -1686,9 +2392,10 @@ export {
|
|
|
1686
2392
|
initProject,
|
|
1687
2393
|
secretsPush,
|
|
1688
2394
|
provision,
|
|
2395
|
+
runHostedSecurity,
|
|
1689
2396
|
AGENT_HARNESSES,
|
|
1690
2397
|
installSkill,
|
|
1691
2398
|
smoke,
|
|
1692
2399
|
runCli
|
|
1693
2400
|
};
|
|
1694
|
-
//# sourceMappingURL=chunk-
|
|
2401
|
+
//# sourceMappingURL=chunk-643B2AKG.js.map
|