@odla-ai/cli 0.11.2 → 0.13.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 +49 -8
- package/REQUIREMENTS.md +11 -3
- package/dist/bin.cjs +874 -382
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +3 -2
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-DC4MIB4X.js → chunk-I7XDJZB6.js} +791 -301
- package/dist/chunk-I7XDJZB6.js.map +1 -0
- package/dist/index.cjs +877 -385
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -62
- package/dist/index.d.ts +114 -62
- package/dist/index.js +5 -5
- package/package.json +1 -1
- package/skills/odla/SKILL.md +23 -13
- package/skills/odla/references/build.md +26 -16
- package/skills/odla/references/sdks.md +54 -17
- package/skills/odla-migrate/SKILL.md +8 -6
- package/skills/odla-migrate/references/phase-2-db.md +2 -2
- package/skills/odla-migrate/references/phase-2b-calendar.md +130 -42
- package/skills/odla-migrate/references/secrets-map.md +3 -0
- package/skills/odla-migrate/references/troubleshooting.md +16 -5
- package/dist/chunk-DC4MIB4X.js.map +0 -1
package/dist/bin.cjs
CHANGED
|
@@ -28,35 +28,16 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
28
28
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
29
29
|
|
|
30
30
|
// src/admin-ai.ts
|
|
31
|
-
var
|
|
31
|
+
var import_node_process6 = __toESM(require("process"), 1);
|
|
32
32
|
|
|
33
33
|
// src/token.ts
|
|
34
34
|
var import_db = require("@odla-ai/db");
|
|
35
|
-
var
|
|
35
|
+
var import_node_process3 = __toESM(require("process"), 1);
|
|
36
36
|
|
|
37
|
-
// src/
|
|
38
|
-
var
|
|
37
|
+
// src/handshake-state.ts
|
|
38
|
+
var import_node_fs2 = require("fs");
|
|
39
|
+
var import_node_path2 = require("path");
|
|
39
40
|
var import_node_process = __toESM(require("process"), 1);
|
|
40
|
-
async function openUrl(url, options = {}) {
|
|
41
|
-
const command = openerFor(options.platform ?? import_node_process.default.platform);
|
|
42
|
-
const doSpawn = options.spawnImpl ?? import_node_child_process.spawn;
|
|
43
|
-
await new Promise((resolve7, reject) => {
|
|
44
|
-
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
45
|
-
stdio: "ignore",
|
|
46
|
-
detached: true
|
|
47
|
-
});
|
|
48
|
-
child.once("error", reject);
|
|
49
|
-
child.once("spawn", () => {
|
|
50
|
-
child.unref();
|
|
51
|
-
resolve7();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
function openerFor(platform) {
|
|
56
|
-
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
57
|
-
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
58
|
-
return { cmd: "xdg-open", args: [] };
|
|
59
|
-
}
|
|
60
41
|
|
|
61
42
|
// src/local.ts
|
|
62
43
|
var import_node_fs = require("fs");
|
|
@@ -184,54 +165,191 @@ function displayPath(path, rootDir = process.cwd()) {
|
|
|
184
165
|
return rel && !rel.startsWith("..") ? rel : path;
|
|
185
166
|
}
|
|
186
167
|
|
|
168
|
+
// src/handshake-state.ts
|
|
169
|
+
function handshakeFile(cfg) {
|
|
170
|
+
return (0, import_node_path2.join)((0, import_node_path2.dirname)(cfg.local.tokenFile), "handshake.local.json");
|
|
171
|
+
}
|
|
172
|
+
var RESUME_MARGIN_MS = 5e3;
|
|
173
|
+
function readPendingHandshake(path, platform, email) {
|
|
174
|
+
const pending = readJsonFile(path);
|
|
175
|
+
if (!pending || pending.platform !== platform || pending.email !== email) return null;
|
|
176
|
+
if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string" || typeof pending.approvalUrl !== "string") return null;
|
|
177
|
+
if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
|
|
178
|
+
return { interval: 3, ...pending };
|
|
179
|
+
}
|
|
180
|
+
function writePendingHandshake(path, pending) {
|
|
181
|
+
writePrivateJson(path, pending);
|
|
182
|
+
}
|
|
183
|
+
function clearPendingHandshake(path) {
|
|
184
|
+
(0, import_node_fs2.rmSync)(path, { force: true });
|
|
185
|
+
}
|
|
186
|
+
function minutesLeft(expiresAt) {
|
|
187
|
+
return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
|
|
188
|
+
}
|
|
189
|
+
function approvalHint(pending) {
|
|
190
|
+
return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
|
|
191
|
+
}
|
|
192
|
+
function approvalReminder(out, pending, periodMs = 3e4) {
|
|
193
|
+
const timer = setInterval(() => out.log(`auth: still waiting \u2014 ${approvalHint(pending)}`), periodMs);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
return () => clearInterval(timer);
|
|
196
|
+
}
|
|
197
|
+
function handshakeWaitMs(waitSeconds, interactive = import_node_process.default.stdout.isTTY === true) {
|
|
198
|
+
if (waitSeconds !== void 0) return waitSeconds * 1e3;
|
|
199
|
+
return interactive ? void 0 : 9e4;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/open.ts
|
|
203
|
+
var import_node_child_process = require("child_process");
|
|
204
|
+
var import_node_process2 = __toESM(require("process"), 1);
|
|
205
|
+
async function openUrl(url, options = {}) {
|
|
206
|
+
const command = openerFor(options.platform ?? import_node_process2.default.platform);
|
|
207
|
+
const doSpawn = options.spawnImpl ?? import_node_child_process.spawn;
|
|
208
|
+
await new Promise((resolve7, reject) => {
|
|
209
|
+
const child = doSpawn(command.cmd, [...command.args, url], {
|
|
210
|
+
stdio: "ignore",
|
|
211
|
+
detached: true
|
|
212
|
+
});
|
|
213
|
+
child.once("error", reject);
|
|
214
|
+
child.once("spawn", () => {
|
|
215
|
+
child.unref();
|
|
216
|
+
resolve7();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function openerFor(platform) {
|
|
221
|
+
if (platform === "darwin") return { cmd: "open", args: [] };
|
|
222
|
+
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
223
|
+
return { cmd: "xdg-open", args: [] };
|
|
224
|
+
}
|
|
225
|
+
|
|
187
226
|
// src/token.ts
|
|
188
227
|
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
189
228
|
if (options.token) return options.token;
|
|
190
229
|
const audience = platformAudience(cfg.platformUrl);
|
|
191
|
-
if (
|
|
192
|
-
const declared =
|
|
230
|
+
if (import_node_process3.default.env.ODLA_DEV_TOKEN) {
|
|
231
|
+
const declared = import_node_process3.default.env.ODLA_DEV_TOKEN_AUDIENCE;
|
|
193
232
|
if (declared) {
|
|
194
233
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
|
|
195
234
|
} else if (audience !== "https://odla.ai") {
|
|
196
235
|
throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
|
|
197
236
|
}
|
|
198
|
-
return
|
|
237
|
+
return import_node_process3.default.env.ODLA_DEV_TOKEN;
|
|
199
238
|
}
|
|
200
239
|
const cached = readJsonFile(cfg.local.tokenFile);
|
|
201
240
|
if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
202
241
|
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
203
242
|
return cached.token;
|
|
204
243
|
}
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
|
|
220
|
-
} catch (err) {
|
|
221
|
-
out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
222
|
-
}
|
|
223
|
-
} else if (browser.reason) {
|
|
224
|
-
out.log(`auth: browser launch skipped (${browser.reason})`);
|
|
225
|
-
}
|
|
226
|
-
out.log("");
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
writePrivateJson(cfg.local.tokenFile, { platform: audience, email, token, expiresAt });
|
|
244
|
+
const ctx = {
|
|
245
|
+
cfg,
|
|
246
|
+
options,
|
|
247
|
+
doFetch,
|
|
248
|
+
out,
|
|
249
|
+
audience,
|
|
250
|
+
browser: approvalBrowser(options),
|
|
251
|
+
email: handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0),
|
|
252
|
+
pendingFile: handshakeFile(cfg)
|
|
253
|
+
};
|
|
254
|
+
const waitMs = handshakeWaitMs(options.wait);
|
|
255
|
+
const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
|
|
256
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
257
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, email: ctx.email, token, expiresAt });
|
|
230
258
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
231
259
|
return token;
|
|
232
260
|
}
|
|
261
|
+
async function resumePendingHandshake(ctx, waitMs) {
|
|
262
|
+
const pending = readPendingHandshake(ctx.pendingFile, ctx.audience, ctx.email);
|
|
263
|
+
if (!pending) return null;
|
|
264
|
+
ctx.out.log("");
|
|
265
|
+
ctx.out.log(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
|
|
266
|
+
await launchApproval(ctx, pending.approvalUrl);
|
|
267
|
+
ctx.out.log("");
|
|
268
|
+
const stopReminder = approvalReminder(ctx.out, pending);
|
|
269
|
+
try {
|
|
270
|
+
return await (0, import_db.collectToken)({
|
|
271
|
+
endpoint: ctx.cfg.platformUrl,
|
|
272
|
+
deviceCode: pending.deviceCode,
|
|
273
|
+
expiresAt: pending.expiresAt,
|
|
274
|
+
interval: pending.interval,
|
|
275
|
+
waitMs,
|
|
276
|
+
fetch: ctx.doFetch
|
|
277
|
+
});
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const code = err instanceof import_db.OdlaError ? err.code : void 0;
|
|
280
|
+
if (code === "handshake_pending") throw stillPending(pending, ctx.email);
|
|
281
|
+
if (code === "handshake_expired" || code === "handshake_timeout") {
|
|
282
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
283
|
+
ctx.out.log("auth: pending handshake lapsed unapproved; starting a fresh one");
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
|
|
287
|
+
throw err;
|
|
288
|
+
} finally {
|
|
289
|
+
stopReminder();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function freshHandshake(ctx, waitMs) {
|
|
293
|
+
let started;
|
|
294
|
+
let stopReminder;
|
|
295
|
+
try {
|
|
296
|
+
return await (0, import_db.requestToken)({
|
|
297
|
+
endpoint: ctx.cfg.platformUrl,
|
|
298
|
+
email: ctx.email,
|
|
299
|
+
label: `${ctx.cfg.app.id} provisioner`,
|
|
300
|
+
fetch: ctx.doFetch,
|
|
301
|
+
waitMs,
|
|
302
|
+
onCode: async ({ userCode, deviceCode, expiresIn, interval, verificationUriComplete }) => {
|
|
303
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(ctx.cfg.platformUrl, userCode);
|
|
304
|
+
started = {
|
|
305
|
+
platform: ctx.audience,
|
|
306
|
+
email: ctx.email,
|
|
307
|
+
userCode,
|
|
308
|
+
deviceCode,
|
|
309
|
+
approvalUrl,
|
|
310
|
+
interval,
|
|
311
|
+
expiresAt: Date.now() + expiresIn * 1e3
|
|
312
|
+
};
|
|
313
|
+
writePendingHandshake(ctx.pendingFile, started);
|
|
314
|
+
ctx.out.log("");
|
|
315
|
+
ctx.out.log(`Review, then ${approvalHint(started)}.`);
|
|
316
|
+
await launchApproval(ctx, approvalUrl);
|
|
317
|
+
ctx.out.log("");
|
|
318
|
+
stopReminder = approvalReminder(ctx.out, started);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
} catch (err) {
|
|
322
|
+
const code = err instanceof import_db.OdlaError ? err.code : void 0;
|
|
323
|
+
if (code === "handshake_pending" && started) throw stillPending(started, ctx.email);
|
|
324
|
+
if (code === "handshake_denied" || code === "handshake_expired" || code === "handshake_timeout") {
|
|
325
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
326
|
+
}
|
|
327
|
+
throw err;
|
|
328
|
+
} finally {
|
|
329
|
+
stopReminder?.();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function launchApproval(ctx, approvalUrl) {
|
|
333
|
+
if (ctx.browser.open) {
|
|
334
|
+
try {
|
|
335
|
+
await (ctx.options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
336
|
+
ctx.out.log(`auth: asked the OS to open a browser${ctx.browser.mode === "auto" ? " (auto)" : ""} \u2014 if no tab appeared, use the URL above`);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
ctx.out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
339
|
+
}
|
|
340
|
+
} else if (ctx.browser.reason) {
|
|
341
|
+
ctx.out.log(`auth: browser launch skipped (${ctx.browser.reason}) \u2014 show the human the URL above`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function stillPending(pending, email) {
|
|
345
|
+
return new import_db.OdlaError(
|
|
346
|
+
"handshake_pending",
|
|
347
|
+
`handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
|
|
348
|
+
{ retryable: true }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
233
351
|
function handshakeEmail(value, cached) {
|
|
234
|
-
const email = (value ??
|
|
352
|
+
const email = (value ?? import_node_process3.default.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
|
|
235
353
|
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
236
354
|
throw new Error("a fresh odla handshake requires --email <account> or ODLA_USER_EMAIL");
|
|
237
355
|
}
|
|
@@ -240,10 +358,11 @@ function handshakeEmail(value, cached) {
|
|
|
240
358
|
function approvalBrowser(options, host = {}) {
|
|
241
359
|
if (options.open === true) return { open: true, mode: "forced" };
|
|
242
360
|
if (options.open === false) return { open: false, reason: "disabled by --no-open" };
|
|
243
|
-
const env = host.env ??
|
|
361
|
+
const env = host.env ?? import_node_process3.default.env;
|
|
362
|
+
if (env.VITEST || env.NODE_ENV === "test") return { open: false, reason: "test environment" };
|
|
244
363
|
if (env.CI) return { open: false, reason: "CI environment" };
|
|
245
364
|
if (env.SSH_CONNECTION || env.SSH_TTY) return { open: false, reason: "SSH session; pass --open to force" };
|
|
246
|
-
const platform = host.platform ??
|
|
365
|
+
const platform = host.platform ?? import_node_process3.default.platform;
|
|
247
366
|
if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
248
367
|
return { open: false, reason: "no graphical display; pass --open to force" };
|
|
249
368
|
}
|
|
@@ -272,12 +391,12 @@ function platformAudience(value) {
|
|
|
272
391
|
}
|
|
273
392
|
|
|
274
393
|
// src/secret-input.ts
|
|
275
|
-
var
|
|
394
|
+
var import_node_process4 = __toESM(require("process"), 1);
|
|
276
395
|
var MAX_BYTES = 64 * 1024;
|
|
277
396
|
async function secretInputValue(options, kind = "credential") {
|
|
278
397
|
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
279
398
|
let value;
|
|
280
|
-
if (options.fromEnv) value =
|
|
399
|
+
if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
|
|
281
400
|
else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
|
|
282
401
|
else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
|
|
283
402
|
value = value?.replace(/[\r\n]+$/, "");
|
|
@@ -285,7 +404,7 @@ async function secretInputValue(options, kind = "credential") {
|
|
|
285
404
|
if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
286
405
|
return value;
|
|
287
406
|
}
|
|
288
|
-
async function readSecretStream(kind, stream =
|
|
407
|
+
async function readSecretStream(kind, stream = import_node_process4.default.stdin) {
|
|
289
408
|
let value = "";
|
|
290
409
|
for await (const chunk of stream) {
|
|
291
410
|
value += String(chunk);
|
|
@@ -295,8 +414,8 @@ async function readSecretStream(kind, stream = import_node_process3.default.stdi
|
|
|
295
414
|
}
|
|
296
415
|
|
|
297
416
|
// src/admin-ai-auth.ts
|
|
298
|
-
var
|
|
299
|
-
var
|
|
417
|
+
var import_node_fs3 = require("fs");
|
|
418
|
+
var import_node_process5 = __toESM(require("process"), 1);
|
|
300
419
|
var import_db2 = require("@odla-ai/db");
|
|
301
420
|
async function getScopedPlatformToken(options) {
|
|
302
421
|
return resolveAdminPlatformToken(options);
|
|
@@ -304,7 +423,7 @@ async function getScopedPlatformToken(options) {
|
|
|
304
423
|
async function resolveAdminPlatformToken(options) {
|
|
305
424
|
const audience = platformAudience(options.platform);
|
|
306
425
|
if (options.token) return options.token;
|
|
307
|
-
const fromEnv =
|
|
426
|
+
const fromEnv = import_node_process5.default.env.ODLA_ADMIN_TOKEN;
|
|
308
427
|
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
309
428
|
return scopedToken(
|
|
310
429
|
audience,
|
|
@@ -316,7 +435,7 @@ async function resolveAdminPlatformToken(options) {
|
|
|
316
435
|
}
|
|
317
436
|
function audienceBoundEnvToken(token, platform) {
|
|
318
437
|
const audience = platformAudience(platform);
|
|
319
|
-
const declared =
|
|
438
|
+
const declared = import_node_process5.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
320
439
|
if (declared) {
|
|
321
440
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
322
441
|
} else if (audience !== "https://odla.ai") {
|
|
@@ -326,7 +445,7 @@ function audienceBoundEnvToken(token, platform) {
|
|
|
326
445
|
}
|
|
327
446
|
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
328
447
|
const audience = platformAudience(platform);
|
|
329
|
-
const tokenFile = options.tokenFile ?? `${
|
|
448
|
+
const tokenFile = options.tokenFile ?? `${import_node_process5.default.cwd()}/.odla/admin-token.local.json`;
|
|
330
449
|
const cache = readJsonFile(tokenFile);
|
|
331
450
|
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
332
451
|
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
@@ -350,7 +469,7 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
350
469
|
});
|
|
351
470
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
352
471
|
tokens[scope] = { token, expiresAt };
|
|
353
|
-
if ((0,
|
|
472
|
+
if ((0, import_node_fs3.existsSync)(`${import_node_process5.default.cwd()}/.git`)) ensureGitignore(import_node_process5.default.cwd(), [tokenFile]);
|
|
354
473
|
writePrivateJson(tokenFile, { platform: audience, email, tokens });
|
|
355
474
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
356
475
|
return token;
|
|
@@ -511,7 +630,7 @@ function isRecord2(value) {
|
|
|
511
630
|
|
|
512
631
|
// src/admin-ai.ts
|
|
513
632
|
async function adminAi(options) {
|
|
514
|
-
const platform = platformAudience(options.platform ??
|
|
633
|
+
const platform = platformAudience(options.platform ?? import_node_process6.default.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
515
634
|
const doFetch = options.fetch ?? fetch;
|
|
516
635
|
const out = options.stdout ?? console;
|
|
517
636
|
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
@@ -826,87 +945,107 @@ async function adminCommand(parsed) {
|
|
|
826
945
|
});
|
|
827
946
|
}
|
|
828
947
|
|
|
829
|
-
// src/
|
|
830
|
-
var
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
948
|
+
// src/app-export.ts
|
|
949
|
+
var import_node_fs5 = require("fs");
|
|
950
|
+
var import_node_stream = require("stream");
|
|
951
|
+
var import_promises = require("stream/promises");
|
|
952
|
+
|
|
953
|
+
// src/config.ts
|
|
954
|
+
var import_node_fs4 = require("fs");
|
|
955
|
+
var import_node_path3 = require("path");
|
|
956
|
+
var import_node_url = require("url");
|
|
957
|
+
|
|
958
|
+
// src/integration-validation.ts
|
|
959
|
+
function validateIntegrations(cfg, path, defaultServices) {
|
|
960
|
+
if (cfg.integrations === void 0) return;
|
|
961
|
+
if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
|
|
962
|
+
const ids = /* @__PURE__ */ new Set();
|
|
963
|
+
for (const [index, integration] of cfg.integrations.entries()) {
|
|
964
|
+
const at = `${path}: integrations[${index}]`;
|
|
965
|
+
if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
|
|
966
|
+
if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
|
|
967
|
+
if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
|
|
968
|
+
ids.add(integration.id);
|
|
969
|
+
if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
|
|
970
|
+
if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
|
|
971
|
+
if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
|
|
972
|
+
throw new Error(`${at}.schema must contain an entities object`);
|
|
973
|
+
}
|
|
974
|
+
if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
|
|
975
|
+
validateSeeds(integration, at);
|
|
976
|
+
validateProbes(integration, at);
|
|
977
|
+
}
|
|
978
|
+
const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
|
|
979
|
+
const services = unique(cfg.services?.length ? cfg.services : defaultServices);
|
|
980
|
+
if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
|
|
981
|
+
}
|
|
982
|
+
function validateSeeds(integration, at) {
|
|
983
|
+
if (integration.seeds === void 0) return;
|
|
984
|
+
if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
|
|
985
|
+
const ids = /* @__PURE__ */ new Set();
|
|
986
|
+
for (const [index, seed] of integration.seeds.entries()) {
|
|
987
|
+
const sat = `${at}.seeds[${index}]`;
|
|
988
|
+
if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
|
|
989
|
+
if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
|
|
990
|
+
ids.add(seed.id);
|
|
991
|
+
if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
|
|
992
|
+
throw new Error(`${sat}.key requires string attr and value`);
|
|
993
|
+
}
|
|
994
|
+
if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
|
|
995
|
+
if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
|
|
996
|
+
throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
|
|
997
|
+
}
|
|
872
998
|
}
|
|
873
|
-
out.log("odla-ai responsibility boundary\n");
|
|
874
|
-
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
875
|
-
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
876
|
-
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
877
|
-
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
878
999
|
}
|
|
879
|
-
function
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1000
|
+
function validateProbes(integration, at) {
|
|
1001
|
+
if (integration.probes === void 0) return;
|
|
1002
|
+
if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
|
|
1003
|
+
for (const [index, probe] of integration.probes.entries()) {
|
|
1004
|
+
const pat = `${at}.probes[${index}]`;
|
|
1005
|
+
if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
|
|
1006
|
+
if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
|
|
1007
|
+
throw new Error(`${pat}.expectedStatus must be an HTTP status`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function isRecord4(value) {
|
|
1012
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1013
|
+
}
|
|
1014
|
+
function safeText(value, max) {
|
|
1015
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1016
|
+
}
|
|
1017
|
+
function safeProbePath(value) {
|
|
1018
|
+
return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
|
|
1019
|
+
}
|
|
1020
|
+
function validId(value) {
|
|
1021
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
1022
|
+
}
|
|
1023
|
+
function unique(values) {
|
|
1024
|
+
return [...new Set(values.filter(Boolean))];
|
|
883
1025
|
}
|
|
884
1026
|
|
|
885
1027
|
// src/config.ts
|
|
886
|
-
var import_node_fs3 = require("fs");
|
|
887
|
-
var import_node_path2 = require("path");
|
|
888
|
-
var import_node_url = require("url");
|
|
889
1028
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
890
1029
|
var DEFAULT_ENVS = ["dev"];
|
|
891
1030
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
892
|
-
var
|
|
1031
|
+
var GOOGLE_CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
|
893
1032
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
894
|
-
const resolved = (0,
|
|
895
|
-
if (!(0,
|
|
1033
|
+
const resolved = (0, import_node_path3.resolve)(configPath);
|
|
1034
|
+
if (!(0, import_node_fs4.existsSync)(resolved)) {
|
|
896
1035
|
throw new Error(`config not found: ${configPath}. Run "odla-ai init" first or pass --config.`);
|
|
897
1036
|
}
|
|
898
1037
|
const raw = await loadConfigModule(resolved);
|
|
899
|
-
const rootDir = (0,
|
|
1038
|
+
const rootDir = (0, import_node_path3.dirname)(resolved);
|
|
900
1039
|
validateRawConfig(raw, resolved);
|
|
901
1040
|
const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
|
|
902
1041
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
903
|
-
const envs =
|
|
904
|
-
const services =
|
|
1042
|
+
const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
1043
|
+
const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
905
1044
|
validateCalendarConfig(raw, envs, services, resolved);
|
|
906
1045
|
const local = {
|
|
907
|
-
tokenFile: (0,
|
|
908
|
-
credentialsFile: (0,
|
|
909
|
-
devVarsFile: (0,
|
|
1046
|
+
tokenFile: (0, import_node_path3.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
1047
|
+
credentialsFile: (0, import_node_path3.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
1048
|
+
devVarsFile: (0, import_node_path3.resolve)(rootDir, raw.local?.devVarsFile ?? ".dev.vars"),
|
|
910
1049
|
gitignore: raw.local?.gitignore ?? true
|
|
911
1050
|
};
|
|
912
1051
|
return {
|
|
@@ -923,9 +1062,9 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
923
1062
|
async function resolveDataExport(cfg, value, names) {
|
|
924
1063
|
if (value === void 0 || value === null || value === false) return void 0;
|
|
925
1064
|
if (typeof value !== "string") return value;
|
|
926
|
-
const target = (0,
|
|
1065
|
+
const target = (0, import_node_path3.isAbsolute)(value) ? value : (0, import_node_path3.resolve)(cfg.rootDir, value);
|
|
927
1066
|
if (target.endsWith(".json")) {
|
|
928
|
-
return JSON.parse((0,
|
|
1067
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(target, "utf8"));
|
|
929
1068
|
}
|
|
930
1069
|
const mod = await import((0, import_node_url.pathToFileURL)(target).href);
|
|
931
1070
|
for (const name of names) {
|
|
@@ -935,6 +1074,8 @@ async function resolveDataExport(cfg, value, names) {
|
|
|
935
1074
|
throw new Error(`${value} did not export ${names.join(", ")} or default`);
|
|
936
1075
|
}
|
|
937
1076
|
function buildPlan(cfg) {
|
|
1077
|
+
const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
|
|
1078
|
+
const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
|
|
938
1079
|
return {
|
|
939
1080
|
appId: cfg.app.id,
|
|
940
1081
|
appName: cfg.app.name,
|
|
@@ -942,8 +1083,9 @@ function buildPlan(cfg) {
|
|
|
942
1083
|
dbEndpoint: cfg.dbEndpoint,
|
|
943
1084
|
envs: cfg.envs,
|
|
944
1085
|
services: cfg.services,
|
|
945
|
-
|
|
946
|
-
|
|
1086
|
+
integrations: (cfg.integrations ?? []).map((integration) => integration.id),
|
|
1087
|
+
hasSchema: !!cfg.db?.schema || integrationSchema,
|
|
1088
|
+
hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
|
|
947
1089
|
aiProvider: cfg.ai?.provider
|
|
948
1090
|
};
|
|
949
1091
|
}
|
|
@@ -952,16 +1094,14 @@ function calendarServiceConfig(cfg, env) {
|
|
|
952
1094
|
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
953
1095
|
const google = cfg.calendar?.google;
|
|
954
1096
|
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
1097
|
+
const availability = unique2(
|
|
1098
|
+
(google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
|
|
1099
|
+
);
|
|
955
1100
|
return {
|
|
956
1101
|
provider: "google",
|
|
957
|
-
access: "
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
organizerSelf: google.match?.organizerSelf ?? true,
|
|
961
|
-
requireAttendees: google.match?.requireAttendees ?? true,
|
|
962
|
-
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
963
|
-
},
|
|
964
|
-
attendeePolicy: google.attendeePolicy ?? "full"
|
|
1102
|
+
access: "book",
|
|
1103
|
+
bookingCalendarId: google.bookingCalendar?.[env]?.trim() ?? availability[0],
|
|
1104
|
+
availabilityCalendars: availability
|
|
965
1105
|
};
|
|
966
1106
|
}
|
|
967
1107
|
function calendarBookingPageUrl(cfg, env) {
|
|
@@ -990,15 +1130,16 @@ function validateRawConfig(raw, path) {
|
|
|
990
1130
|
if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
|
|
991
1131
|
const cfg = raw;
|
|
992
1132
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
993
|
-
if (!
|
|
1133
|
+
if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
994
1134
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
995
1135
|
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
996
1136
|
throw new Error(`${path}: envs must be an array of names`);
|
|
997
1137
|
}
|
|
998
|
-
if (cfg.envs?.some((env) => !
|
|
1138
|
+
if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
999
1139
|
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
1000
1140
|
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
1001
1141
|
}
|
|
1142
|
+
validateIntegrations(cfg, path, DEFAULT_SERVICES);
|
|
1002
1143
|
}
|
|
1003
1144
|
function validateCalendarConfig(cfg, envs, services, path) {
|
|
1004
1145
|
const enabled = services.includes("calendar");
|
|
@@ -1006,28 +1147,47 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
1006
1147
|
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
1007
1148
|
return;
|
|
1008
1149
|
}
|
|
1009
|
-
if (!
|
|
1150
|
+
if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
1010
1151
|
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
1011
|
-
if (!
|
|
1152
|
+
if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
1012
1153
|
const google = cfg.calendar.google;
|
|
1013
|
-
assertOnly(
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1154
|
+
assertOnly(
|
|
1155
|
+
google,
|
|
1156
|
+
["availabilityCalendars", "calendars", "bookingCalendar", "bookingPageUrl"],
|
|
1157
|
+
`${path}: calendar.google`
|
|
1158
|
+
);
|
|
1159
|
+
const availabilityKey = google.availabilityCalendars !== void 0 ? "availabilityCalendars" : google.calendars !== void 0 ? "calendars" : null;
|
|
1160
|
+
if (!availabilityKey || google.availabilityCalendars !== void 0 && google.calendars !== void 0) {
|
|
1161
|
+
throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
|
|
1162
|
+
}
|
|
1163
|
+
const availability = google[availabilityKey];
|
|
1164
|
+
if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
|
|
1165
|
+
const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
|
|
1166
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
|
|
1017
1167
|
for (const env of envs) {
|
|
1018
|
-
const ids =
|
|
1168
|
+
const ids = availability[env];
|
|
1019
1169
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
1020
|
-
throw new Error(`${path}: calendar.google
|
|
1170
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must be a non-empty array`);
|
|
1021
1171
|
}
|
|
1022
1172
|
if (ids.length > 10) {
|
|
1023
|
-
throw new Error(`${path}: calendar.google
|
|
1173
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
|
|
1024
1174
|
}
|
|
1025
|
-
if (ids.some((id) => !
|
|
1026
|
-
throw new Error(`${path}: calendar.google
|
|
1175
|
+
if (ids.some((id) => !safeText2(id, 1024))) {
|
|
1176
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (google.bookingCalendar !== void 0) {
|
|
1180
|
+
if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
|
|
1181
|
+
const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
|
|
1182
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
|
|
1183
|
+
for (const [env, value] of Object.entries(google.bookingCalendar)) {
|
|
1184
|
+
if (!safeText2(value, 1024)) {
|
|
1185
|
+
throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
|
|
1186
|
+
}
|
|
1027
1187
|
}
|
|
1028
1188
|
}
|
|
1029
1189
|
if (google.bookingPageUrl !== void 0) {
|
|
1030
|
-
if (!
|
|
1190
|
+
if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
1031
1191
|
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
1032
1192
|
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
1033
1193
|
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
@@ -1036,31 +1196,16 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
1036
1196
|
}
|
|
1037
1197
|
}
|
|
1038
1198
|
}
|
|
1039
|
-
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
1040
|
-
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
1041
|
-
}
|
|
1042
|
-
if (google.match !== void 0) {
|
|
1043
|
-
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
1044
|
-
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
1045
|
-
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
1046
|
-
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
1047
|
-
}
|
|
1048
|
-
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
1049
|
-
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
1050
|
-
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
1199
|
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
1055
1200
|
}
|
|
1056
1201
|
function assertOnly(value, allowed, label) {
|
|
1057
1202
|
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
1058
1203
|
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
1059
1204
|
}
|
|
1060
|
-
function
|
|
1205
|
+
function isRecord5(value) {
|
|
1061
1206
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1062
1207
|
}
|
|
1063
|
-
function
|
|
1208
|
+
function safeText2(value, max) {
|
|
1064
1209
|
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1065
1210
|
}
|
|
1066
1211
|
function safeHttpsUrl(value) {
|
|
@@ -1072,11 +1217,11 @@ function safeHttpsUrl(value) {
|
|
|
1072
1217
|
return false;
|
|
1073
1218
|
}
|
|
1074
1219
|
}
|
|
1075
|
-
function
|
|
1220
|
+
function validId2(value) {
|
|
1076
1221
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
1077
1222
|
}
|
|
1078
1223
|
async function loadConfigModule(path) {
|
|
1079
|
-
if (path.endsWith(".json")) return JSON.parse((0,
|
|
1224
|
+
if (path.endsWith(".json")) return JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
|
|
1080
1225
|
const mod = await import(`${(0, import_node_url.pathToFileURL)(path).href}?t=${Date.now()}`);
|
|
1081
1226
|
const value = mod.default ?? mod.config;
|
|
1082
1227
|
if (typeof value === "function") return await value();
|
|
@@ -1085,14 +1230,191 @@ async function loadConfigModule(path) {
|
|
|
1085
1230
|
function trimSlash(value) {
|
|
1086
1231
|
return value.replace(/\/+$/, "");
|
|
1087
1232
|
}
|
|
1088
|
-
function
|
|
1233
|
+
function unique2(values) {
|
|
1089
1234
|
return [...new Set(values.filter(Boolean))];
|
|
1090
1235
|
}
|
|
1091
1236
|
|
|
1237
|
+
// src/app-export.ts
|
|
1238
|
+
async function appExport(options) {
|
|
1239
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1240
|
+
const out = options.stdout ?? console;
|
|
1241
|
+
const doFetch = options.fetch ?? fetch;
|
|
1242
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1243
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1244
|
+
const tenant = env === "prod" ? cfg.app.id : `${cfg.app.id}--${env}`;
|
|
1245
|
+
const token = await getDeveloperToken(
|
|
1246
|
+
cfg,
|
|
1247
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
1248
|
+
doFetch,
|
|
1249
|
+
out
|
|
1250
|
+
);
|
|
1251
|
+
const auth = { authorization: `Bearer ${token}` };
|
|
1252
|
+
const base = `${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenant)}`;
|
|
1253
|
+
if (options.fresh) {
|
|
1254
|
+
const res = await doFetch(`${base}/export`, { method: "POST", headers: auth });
|
|
1255
|
+
const body = await res.json().catch(() => ({}));
|
|
1256
|
+
if (!res.ok) throw new Error(`export failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? res.status}`);
|
|
1257
|
+
}
|
|
1258
|
+
const list = await doFetch(`${base}/backups`, { headers: auth });
|
|
1259
|
+
if (!list.ok) throw new Error(`couldn't list backups (${list.status})`);
|
|
1260
|
+
const { backups } = await list.json();
|
|
1261
|
+
const newest = backups[0];
|
|
1262
|
+
if (!newest) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
`${tenant} has no backups yet \u2014 run \`odla-ai app export --fresh\` to take one now (nightly snapshots appear after the first day with writes)`
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
const download = await doFetch(`${base}/backups/${newest.id}/download`, { headers: auth });
|
|
1268
|
+
if (!download.ok || !download.body) throw new Error(`download failed (${download.status})`);
|
|
1269
|
+
const file = options.out ?? `${tenant}-${new Date(newest.created_at).toISOString().slice(0, 10)}-tx${newest.max_tx}.jsonl.gz`;
|
|
1270
|
+
await (0, import_promises.pipeline)(import_node_stream.Readable.fromWeb(download.body), (0, import_node_fs5.createWriteStream)(file));
|
|
1271
|
+
if (options.json) out.log(JSON.stringify({ file, backup: newest }, null, 2));
|
|
1272
|
+
else {
|
|
1273
|
+
out.log(`${tenant}: wrote ${file} (${newest.bytes} bytes, ${newest.kind} snapshot at tx ${newest.max_tx})`);
|
|
1274
|
+
out.log(`sha256 ${download.headers.get("x-odla-sha256") ?? newest.sha256}`);
|
|
1275
|
+
}
|
|
1276
|
+
return { file, backup: newest };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/app-lifecycle.ts
|
|
1280
|
+
async function lifecycleCall(action, options) {
|
|
1281
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1282
|
+
const out = options.stdout ?? console;
|
|
1283
|
+
const doFetch = options.fetch ?? fetch;
|
|
1284
|
+
const token = await getDeveloperToken(
|
|
1285
|
+
cfg,
|
|
1286
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
1287
|
+
doFetch,
|
|
1288
|
+
out
|
|
1289
|
+
);
|
|
1290
|
+
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/${action}`, {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }
|
|
1293
|
+
});
|
|
1294
|
+
const body = await res.json().catch(() => ({}));
|
|
1295
|
+
if (!res.ok || !body.ok) {
|
|
1296
|
+
throw new Error(`${action} failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? `registry returned ${res.status}`}`);
|
|
1297
|
+
}
|
|
1298
|
+
return body;
|
|
1299
|
+
}
|
|
1300
|
+
async function appArchive(options) {
|
|
1301
|
+
if (options.yes !== true) {
|
|
1302
|
+
throw new Error(
|
|
1303
|
+
"app archive suspends EVERY environment: API keys stop working and all services refuse requests until restored. All data is retained. Pass --yes to proceed."
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
const out = options.stdout ?? console;
|
|
1307
|
+
const body = await lifecycleCall("archive", options);
|
|
1308
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
1309
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already archived`);
|
|
1310
|
+
else out.log(`${body.app?.appId}: archived \u2014 data retained; run \`odla-ai app restore\` (or use Studio) to bring it back`);
|
|
1311
|
+
}
|
|
1312
|
+
async function appRestore(options) {
|
|
1313
|
+
const out = options.stdout ?? console;
|
|
1314
|
+
const body = await lifecycleCall("restore", options);
|
|
1315
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
1316
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already active`);
|
|
1317
|
+
else out.log(`${body.app?.appId}: restored \u2014 every service's data plane is live again`);
|
|
1318
|
+
}
|
|
1319
|
+
async function appCommand(parsed, dependencies = {}) {
|
|
1320
|
+
const sub = parsed.positionals[1];
|
|
1321
|
+
if (sub === "export") {
|
|
1322
|
+
assertArgs(parsed, ["config", "env", "token", "email", "fresh", "out", "json"], 2);
|
|
1323
|
+
await appExport({
|
|
1324
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1325
|
+
env: stringOpt(parsed.options.env),
|
|
1326
|
+
token: stringOpt(parsed.options.token),
|
|
1327
|
+
email: stringOpt(parsed.options.email),
|
|
1328
|
+
fresh: parsed.options.fresh === true,
|
|
1329
|
+
out: stringOpt(parsed.options.out),
|
|
1330
|
+
json: parsed.options.json === true,
|
|
1331
|
+
fetch: dependencies.fetch,
|
|
1332
|
+
stdout: dependencies.stdout
|
|
1333
|
+
});
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (sub !== "archive" && sub !== "restore") {
|
|
1337
|
+
throw new Error(
|
|
1338
|
+
`unknown app subcommand "${sub ?? ""}". Try "odla-ai app archive --yes", "odla-ai app restore", or "odla-ai app export". (Permanent deletion has no CLI: it requires a signed-in owner in Studio.)`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
assertArgs(parsed, ["config", "token", "email", "yes", "json"], 2);
|
|
1342
|
+
const options = {
|
|
1343
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
1344
|
+
token: stringOpt(parsed.options.token),
|
|
1345
|
+
email: stringOpt(parsed.options.email),
|
|
1346
|
+
yes: parsed.options.yes === true,
|
|
1347
|
+
json: parsed.options.json === true,
|
|
1348
|
+
fetch: dependencies.fetch,
|
|
1349
|
+
stdout: dependencies.stdout
|
|
1350
|
+
};
|
|
1351
|
+
if (sub === "archive") await appArchive(options);
|
|
1352
|
+
else await appRestore(options);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/capabilities.ts
|
|
1356
|
+
var CAPABILITIES = {
|
|
1357
|
+
cli: [
|
|
1358
|
+
"start an email-bound device request without accepting a password or user session token",
|
|
1359
|
+
"register the app and enable configured services per environment",
|
|
1360
|
+
"issue, persist, and explicitly rotate configured service credentials",
|
|
1361
|
+
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
1362
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
1363
|
+
"compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
|
|
1364
|
+
"apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
|
|
1365
|
+
"validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
|
|
1366
|
+
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
1367
|
+
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
1368
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
1369
|
+
],
|
|
1370
|
+
agent: [
|
|
1371
|
+
"install and import the selected odla SDKs",
|
|
1372
|
+
"wrap the Worker with withObservability and choose useful telemetry",
|
|
1373
|
+
"install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
|
|
1374
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
1375
|
+
],
|
|
1376
|
+
human: [
|
|
1377
|
+
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
1378
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
1379
|
+
"consent to production changes with --yes",
|
|
1380
|
+
"request destructive credential rotation explicitly",
|
|
1381
|
+
"review application semantics, security findings, releases, and merges",
|
|
1382
|
+
"approve redacted-source disclosure before hosted security reasoning",
|
|
1383
|
+
"approve GitHub App repository access separately from redacted-snippet disclosure"
|
|
1384
|
+
],
|
|
1385
|
+
studio: [
|
|
1386
|
+
"view telemetry and environment state",
|
|
1387
|
+
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
1388
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
1389
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
1390
|
+
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
1391
|
+
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
1392
|
+
]
|
|
1393
|
+
};
|
|
1394
|
+
function printCapabilities(json = false, out = console) {
|
|
1395
|
+
if (json) {
|
|
1396
|
+
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
out.log("odla-ai responsibility boundary\n");
|
|
1400
|
+
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
1401
|
+
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
1402
|
+
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
1403
|
+
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
1404
|
+
}
|
|
1405
|
+
function printGroup(out, heading, items) {
|
|
1406
|
+
out.log(`${heading}:`);
|
|
1407
|
+
for (const item of items) out.log(` - ${item}`);
|
|
1408
|
+
out.log("");
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1092
1411
|
// src/calendar-errors.ts
|
|
1093
1412
|
var PLATFORM_NOT_READY_CODES = /* @__PURE__ */ new Set([
|
|
1094
1413
|
"calendar_google_oauth_not_configured",
|
|
1095
1414
|
"calendar_token_vault_not_configured",
|
|
1415
|
+
// Current spelling (booking-era key verifier) plus the retired mirror-era
|
|
1416
|
+
// ingress code, so the CLI stays resumable against either platform version.
|
|
1417
|
+
"calendar_db_verifier_not_configured",
|
|
1096
1418
|
"calendar_db_ingress_not_configured"
|
|
1097
1419
|
]);
|
|
1098
1420
|
var CalendarRequestError = class extends Error {
|
|
@@ -1134,8 +1456,6 @@ function looksSecret(value) {
|
|
|
1134
1456
|
var CALENDAR_STATES = [
|
|
1135
1457
|
"not_connected",
|
|
1136
1458
|
"authorizing",
|
|
1137
|
-
"needs_sync",
|
|
1138
|
-
"initial_sync",
|
|
1139
1459
|
"healthy",
|
|
1140
1460
|
"degraded",
|
|
1141
1461
|
"disconnected",
|
|
@@ -1183,9 +1503,6 @@ async function pollCalendarConnection(ctx, attemptId) {
|
|
|
1183
1503
|
ctx.env
|
|
1184
1504
|
);
|
|
1185
1505
|
}
|
|
1186
|
-
async function requestCalendarResync(ctx) {
|
|
1187
|
-
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1188
|
-
}
|
|
1189
1506
|
async function requestCalendarDisconnect(ctx) {
|
|
1190
1507
|
return parseCalendarStatus(
|
|
1191
1508
|
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
@@ -1196,10 +1513,8 @@ function parseCalendarStatus(raw, env) {
|
|
|
1196
1513
|
const outer = wrapped(raw, "calendar");
|
|
1197
1514
|
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1198
1515
|
const connection = record(value.connection) ?? {};
|
|
1199
|
-
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1200
1516
|
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1201
1517
|
const googleConfig = record(config.google) ?? config;
|
|
1202
|
-
const watches = record(value.watches) ?? {};
|
|
1203
1518
|
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1204
1519
|
if (!stateValue) {
|
|
1205
1520
|
throw new Error("calendar status returned an invalid connection state");
|
|
@@ -1209,31 +1524,29 @@ function parseCalendarStatus(raw, env) {
|
|
|
1209
1524
|
throw new Error("calendar status returned an unsupported provider");
|
|
1210
1525
|
}
|
|
1211
1526
|
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1212
|
-
if (accessValue !== void 0 && accessValue !== "
|
|
1213
|
-
|
|
1214
|
-
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1215
|
-
throw new Error("calendar status returned an invalid attendee policy");
|
|
1527
|
+
if (accessValue !== void 0 && accessValue !== "book" && accessValue !== "read") {
|
|
1528
|
+
throw new Error("calendar status returned unsupported access");
|
|
1216
1529
|
}
|
|
1217
1530
|
const errorValue = record(value.error) ?? record(connection.error);
|
|
1218
1531
|
const errorCode = textField(value.lastErrorCode, 128);
|
|
1219
1532
|
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1533
|
+
const connected = typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["healthy", "degraded"].includes(stateValue);
|
|
1534
|
+
const bookingCalendarId = textField(value.bookingCalendarId ?? config.bookingCalendarId, 1024);
|
|
1220
1535
|
return {
|
|
1221
1536
|
env,
|
|
1222
1537
|
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1223
|
-
connected
|
|
1538
|
+
connected,
|
|
1539
|
+
writable: typeof value.writable === "boolean" ? value.writable : stateValue === "healthy",
|
|
1224
1540
|
provider: providerValue === "google" ? "google" : null,
|
|
1225
1541
|
status: stateValue,
|
|
1226
|
-
...accessValue
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1542
|
+
...accessValue !== void 0 ? { access: "book" } : {},
|
|
1543
|
+
...bookingCalendarId ? { bookingCalendarId } : {},
|
|
1544
|
+
calendars: calendarIds(
|
|
1545
|
+
value.availabilityCalendars ?? config.availabilityCalendars ?? value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars
|
|
1546
|
+
),
|
|
1230
1547
|
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1231
1548
|
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1232
1549
|
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1233
|
-
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1234
|
-
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1235
|
-
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1236
|
-
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1237
1550
|
...errorValue || errorCode ? { error: {
|
|
1238
1551
|
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1239
1552
|
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
@@ -1248,7 +1561,9 @@ function calendarState(value) {
|
|
|
1248
1561
|
disabled: "not_connected",
|
|
1249
1562
|
disconnected: "disconnected",
|
|
1250
1563
|
connecting: "authorizing",
|
|
1251
|
-
syncing: "
|
|
1564
|
+
syncing: "healthy",
|
|
1565
|
+
needs_sync: "healthy",
|
|
1566
|
+
initial_sync: "healthy",
|
|
1252
1567
|
ready: "healthy",
|
|
1253
1568
|
error: "failed"
|
|
1254
1569
|
};
|
|
@@ -1316,13 +1631,6 @@ function optionalText(key, value, max) {
|
|
|
1316
1631
|
const parsed = textField(value, max);
|
|
1317
1632
|
return parsed ? { [key]: parsed } : {};
|
|
1318
1633
|
}
|
|
1319
|
-
function optionalNumber(key, value) {
|
|
1320
|
-
const parsed = timestamp3(value);
|
|
1321
|
-
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1322
|
-
}
|
|
1323
|
-
function optionalInteger(key, value) {
|
|
1324
|
-
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1325
|
-
}
|
|
1326
1634
|
function optionalNullableUrl(key, value) {
|
|
1327
1635
|
if (value === null) return { [key]: null };
|
|
1328
1636
|
const parsed = textField(value, 4096);
|
|
@@ -1374,7 +1682,7 @@ async function calendarConnect(options) {
|
|
|
1374
1682
|
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1375
1683
|
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1376
1684
|
const connectOptions = connectionOptions(options, out);
|
|
1377
|
-
return await continueConnectedCalendar(ctx, applied, connectOptions
|
|
1685
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1378
1686
|
}
|
|
1379
1687
|
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1380
1688
|
if (bookingPageUrl === void 0) return null;
|
|
@@ -1382,25 +1690,18 @@ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
|
1382
1690
|
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1383
1691
|
return status;
|
|
1384
1692
|
}
|
|
1385
|
-
async function calendarResync(options) {
|
|
1386
|
-
const { ctx, out } = await lifecycleContext(options);
|
|
1387
|
-
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1388
|
-
const status = await requestCalendarResync(ctx);
|
|
1389
|
-
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1390
|
-
return status;
|
|
1391
|
-
}
|
|
1392
1693
|
async function calendarDisconnect(options) {
|
|
1393
1694
|
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1394
1695
|
const { ctx, out } = await lifecycleContext(options);
|
|
1395
1696
|
const status = await requestCalendarDisconnect(ctx);
|
|
1396
|
-
out.log(`${ctx.env}: calendar disconnected;
|
|
1697
|
+
out.log(`${ctx.env}: calendar disconnected; no calendar data was stored`);
|
|
1397
1698
|
return status;
|
|
1398
1699
|
}
|
|
1399
1700
|
async function ensureCalendarConnected(ctx, options) {
|
|
1400
1701
|
const out = options.stdout ?? console;
|
|
1401
1702
|
const current = await readCalendarStatus(ctx);
|
|
1402
1703
|
const connectOptions = connectionOptions(options, out);
|
|
1403
|
-
return await continueConnectedCalendar(ctx, current, connectOptions
|
|
1704
|
+
return await continueConnectedCalendar(ctx, current, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1404
1705
|
}
|
|
1405
1706
|
async function lifecycleContext(options) {
|
|
1406
1707
|
const cfg = await loadProjectConfig(options.configPath);
|
|
@@ -1436,25 +1737,20 @@ function connectionOptions(options, out) {
|
|
|
1436
1737
|
signal: options.signal
|
|
1437
1738
|
};
|
|
1438
1739
|
}
|
|
1439
|
-
async function continueConnectedCalendar(ctx, current, options
|
|
1440
|
-
if (current.status === "healthy"
|
|
1441
|
-
options.out.log(`${ctx.env}: calendar
|
|
1740
|
+
async function continueConnectedCalendar(ctx, current, options) {
|
|
1741
|
+
if (current.status === "healthy") {
|
|
1742
|
+
options.out.log(`${ctx.env}: calendar already connected (healthy)`);
|
|
1442
1743
|
return current;
|
|
1443
1744
|
}
|
|
1444
|
-
if (current.status === "degraded")
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1448
|
-
const synced = await requestCalendarResync(ctx);
|
|
1449
|
-
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1450
|
-
return synced;
|
|
1745
|
+
if (current.status === "degraded") {
|
|
1746
|
+
options.out.log(`${ctx.env}: calendar grant predates booking scopes \u2014 Google re-consent required`);
|
|
1747
|
+
return null;
|
|
1451
1748
|
}
|
|
1452
1749
|
if (current.status === "failed" && current.connected) {
|
|
1453
1750
|
const reason = current.error?.message ?? current.error?.code;
|
|
1454
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1751
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1455
1752
|
}
|
|
1456
|
-
|
|
1457
|
-
if (!waitingForExisting) return null;
|
|
1753
|
+
if (current.status !== "authorizing") return null;
|
|
1458
1754
|
const now = options.now ?? Date.now;
|
|
1459
1755
|
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1460
1756
|
const wait = options.wait ?? waitForCalendarPoll;
|
|
@@ -1466,17 +1762,16 @@ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
|
1466
1762
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1467
1763
|
prior = status.status;
|
|
1468
1764
|
}
|
|
1469
|
-
if (status.status === "healthy"
|
|
1765
|
+
if (status.status === "healthy") return status;
|
|
1470
1766
|
if (status.status === "degraded") return null;
|
|
1471
|
-
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1472
1767
|
if (status.status === "failed" && status.connected) {
|
|
1473
1768
|
const reason = status.error?.message ?? status.error?.code;
|
|
1474
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1769
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1475
1770
|
}
|
|
1476
|
-
if (status.status !== "authorizing"
|
|
1771
|
+
if (status.status !== "authorizing") return null;
|
|
1477
1772
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1478
1773
|
}
|
|
1479
|
-
throw new Error('Existing Google Calendar authorization
|
|
1774
|
+
throw new Error('Existing Google Calendar authorization timed out; run "odla-ai calendar status" and retry');
|
|
1480
1775
|
}
|
|
1481
1776
|
async function connectWithContext(ctx, options) {
|
|
1482
1777
|
const attempt = await startCalendarConnection(ctx);
|
|
@@ -1498,14 +1793,17 @@ async function connectWithContext(ctx, options) {
|
|
|
1498
1793
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1499
1794
|
prior = status.status;
|
|
1500
1795
|
}
|
|
1501
|
-
if (status.status === "healthy"
|
|
1796
|
+
if (status.status === "healthy") return status;
|
|
1797
|
+
if (status.status === "degraded") {
|
|
1798
|
+
throw new Error("calendar connected without booking scopes; re-run connect and grant calendar access");
|
|
1799
|
+
}
|
|
1502
1800
|
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1503
1801
|
const reason = status.error?.message ?? status.error?.code;
|
|
1504
1802
|
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1505
1803
|
}
|
|
1506
1804
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1507
1805
|
}
|
|
1508
|
-
throw new Error('Google Calendar consent
|
|
1806
|
+
throw new Error('Google Calendar consent timed out; run "odla-ai calendar status" and retry connect');
|
|
1509
1807
|
}
|
|
1510
1808
|
function trustedCalendarConsentUrl(platform, value) {
|
|
1511
1809
|
const origin = platformAudience(platform);
|
|
@@ -1535,26 +1833,22 @@ function printStatus(status, json, out) {
|
|
|
1535
1833
|
return;
|
|
1536
1834
|
}
|
|
1537
1835
|
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1538
|
-
out.log(`
|
|
1539
|
-
out.log(`
|
|
1540
|
-
|
|
1541
|
-
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1542
|
-
}
|
|
1836
|
+
out.log(` bookable: ${status.writable ? "yes" : status.connected ? "no \u2014 reconnect to grant booking scopes" : "no \u2014 not connected"}`);
|
|
1837
|
+
if (status.bookingCalendarId) out.log(` booking calendar: ${status.bookingCalendarId}`);
|
|
1838
|
+
out.log(` availability calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1543
1839
|
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1544
|
-
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1545
|
-
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1546
1840
|
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1547
1841
|
}
|
|
1548
1842
|
|
|
1549
1843
|
// src/doctor-checks.ts
|
|
1550
1844
|
var import_node_child_process3 = require("child_process");
|
|
1551
|
-
var
|
|
1552
|
-
var
|
|
1845
|
+
var import_node_fs7 = require("fs");
|
|
1846
|
+
var import_node_path5 = require("path");
|
|
1553
1847
|
|
|
1554
1848
|
// src/wrangler.ts
|
|
1555
1849
|
var import_node_child_process2 = require("child_process");
|
|
1556
|
-
var
|
|
1557
|
-
var
|
|
1850
|
+
var import_node_fs6 = require("fs");
|
|
1851
|
+
var import_node_path4 = require("path");
|
|
1558
1852
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
1559
1853
|
const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
1560
1854
|
let stdout = "";
|
|
@@ -1568,15 +1862,15 @@ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) =>
|
|
|
1568
1862
|
var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
1569
1863
|
function findWranglerConfig(rootDir) {
|
|
1570
1864
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
1571
|
-
const path = (0,
|
|
1572
|
-
if ((0,
|
|
1865
|
+
const path = (0, import_node_path4.join)(rootDir, name);
|
|
1866
|
+
if ((0, import_node_fs6.existsSync)(path)) return path;
|
|
1573
1867
|
}
|
|
1574
1868
|
return null;
|
|
1575
1869
|
}
|
|
1576
1870
|
function readWranglerConfig(path) {
|
|
1577
1871
|
if (path.endsWith(".toml")) return null;
|
|
1578
1872
|
try {
|
|
1579
|
-
return JSON.parse(stripJsonComments((0,
|
|
1873
|
+
return JSON.parse(stripJsonComments((0, import_node_fs6.readFileSync)(path, "utf8")));
|
|
1580
1874
|
} catch {
|
|
1581
1875
|
return null;
|
|
1582
1876
|
}
|
|
@@ -1674,10 +1968,10 @@ function wranglerWarnings(rootDir) {
|
|
|
1674
1968
|
for (const { label, block } of blocks) {
|
|
1675
1969
|
const assets = block.assets;
|
|
1676
1970
|
if (assets?.directory) {
|
|
1677
|
-
const dir = (0,
|
|
1678
|
-
if (dir === (0,
|
|
1971
|
+
const dir = (0, import_node_path5.resolve)(rootDir, assets.directory);
|
|
1972
|
+
if (dir === (0, import_node_path5.resolve)(rootDir)) {
|
|
1679
1973
|
warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
|
|
1680
|
-
} else if ((0,
|
|
1974
|
+
} else if ((0, import_node_fs7.existsSync)((0, import_node_path5.join)(dir, "node_modules"))) {
|
|
1681
1975
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
1682
1976
|
}
|
|
1683
1977
|
}
|
|
@@ -1712,13 +2006,13 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1712
2006
|
warnings.push("cannot verify o11y Worker instrumentation \u2014 add a parseable wrangler.jsonc/json config");
|
|
1713
2007
|
return warnings;
|
|
1714
2008
|
}
|
|
1715
|
-
const main = typeof config.main === "string" ? (0,
|
|
1716
|
-
if (!main || !(0,
|
|
2009
|
+
const main = typeof config.main === "string" ? (0, import_node_path5.resolve)(rootDir, config.main) : null;
|
|
2010
|
+
if (!main || !(0, import_node_fs7.existsSync)(main)) {
|
|
1717
2011
|
warnings.push("cannot verify o11y Worker instrumentation \u2014 wrangler main is missing or unreadable");
|
|
1718
2012
|
} else {
|
|
1719
2013
|
let source = "";
|
|
1720
2014
|
try {
|
|
1721
|
-
source = (0,
|
|
2015
|
+
source = (0, import_node_fs7.readFileSync)(main, "utf8");
|
|
1722
2016
|
} catch {
|
|
1723
2017
|
}
|
|
1724
2018
|
if (!/\bwithObservability\b/.test(source)) {
|
|
@@ -1742,32 +2036,136 @@ function calendarProjectWarnings(rootDir) {
|
|
|
1742
2036
|
}
|
|
1743
2037
|
function readPackageJson(rootDir) {
|
|
1744
2038
|
try {
|
|
1745
|
-
return JSON.parse((0,
|
|
2039
|
+
return JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path5.join)(rootDir, "package.json"), "utf8"));
|
|
1746
2040
|
} catch {
|
|
1747
2041
|
return null;
|
|
1748
2042
|
}
|
|
1749
2043
|
}
|
|
1750
2044
|
|
|
2045
|
+
// src/integrations.ts
|
|
2046
|
+
var import_node_util = require("util");
|
|
2047
|
+
async function resolveDatabaseConfig(cfg, options = {}) {
|
|
2048
|
+
const integrations = cfg.integrations ?? [];
|
|
2049
|
+
if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
|
|
2050
|
+
const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
2051
|
+
const schema = mergeSchemas(authoredSchema, integrations);
|
|
2052
|
+
if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
|
|
2053
|
+
const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
|
|
2054
|
+
const explicitRules = mergeRules(configuredRules, integrations);
|
|
2055
|
+
const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
|
|
2056
|
+
return { schema, rules, integrations };
|
|
2057
|
+
}
|
|
2058
|
+
function integrationWarnings(integrations, schema, rules) {
|
|
2059
|
+
const warnings = [];
|
|
2060
|
+
const entities = new Set(serializedEntities(schema));
|
|
2061
|
+
for (const integration of integrations) {
|
|
2062
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {});
|
|
2063
|
+
for (const ns of namespaces) {
|
|
2064
|
+
if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
|
|
2065
|
+
const rule = rules?.[ns];
|
|
2066
|
+
if (!rule) {
|
|
2067
|
+
warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
for (const action of ["view", "create", "update", "delete"]) {
|
|
2071
|
+
if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
for (const seed of integration.seeds ?? []) {
|
|
2075
|
+
if (!entities.has(seed.ns)) {
|
|
2076
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
2077
|
+
} else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
2078
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return warnings;
|
|
2083
|
+
}
|
|
2084
|
+
function mergeSchemas(base, integrations) {
|
|
2085
|
+
const fragments = integrations.filter((integration) => integration.schema);
|
|
2086
|
+
if (fragments.length === 0) return base;
|
|
2087
|
+
const merged = normalizeSchema(base);
|
|
2088
|
+
for (const integration of fragments) {
|
|
2089
|
+
const fragment = normalizeSchema(integration.schema);
|
|
2090
|
+
mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
|
|
2091
|
+
mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
|
|
2092
|
+
}
|
|
2093
|
+
return merged;
|
|
2094
|
+
}
|
|
2095
|
+
function mergeRules(base, integrations) {
|
|
2096
|
+
const fragments = integrations.filter((integration) => integration.rules);
|
|
2097
|
+
if (fragments.length === 0) return base;
|
|
2098
|
+
const merged = { ...base ?? {} };
|
|
2099
|
+
for (const integration of fragments) {
|
|
2100
|
+
mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
|
|
2101
|
+
}
|
|
2102
|
+
return merged;
|
|
2103
|
+
}
|
|
2104
|
+
function assertSeedContracts(integrations, schema) {
|
|
2105
|
+
const entities = new Set(serializedEntities(schema));
|
|
2106
|
+
for (const integration of integrations) {
|
|
2107
|
+
for (const seed of integration.seeds ?? []) {
|
|
2108
|
+
if (!entities.has(seed.ns)) {
|
|
2109
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
2110
|
+
}
|
|
2111
|
+
if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
2112
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
function isUniqueAttr(schema, ns, attr) {
|
|
2118
|
+
if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
|
|
2119
|
+
const entity = schema.entities[ns];
|
|
2120
|
+
if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
|
|
2121
|
+
const definition = entity.attrs[attr];
|
|
2122
|
+
return isRecord6(definition) && definition.unique === true;
|
|
2123
|
+
}
|
|
2124
|
+
function normalizeSchema(value) {
|
|
2125
|
+
if (value === void 0 || value === null) return { entities: {}, links: {} };
|
|
2126
|
+
if (!isRecord6(value) || !isRecord6(value.entities)) {
|
|
2127
|
+
throw new Error("db schema must be a serialized schema object with an entities map");
|
|
2128
|
+
}
|
|
2129
|
+
if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
|
|
2130
|
+
return {
|
|
2131
|
+
entities: { ...value.entities },
|
|
2132
|
+
links: { ...value.links ?? {} }
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
function mergeMap(target, fragment, label) {
|
|
2136
|
+
for (const [name, value] of Object.entries(fragment)) {
|
|
2137
|
+
if (Object.hasOwn(target, name) && !(0, import_node_util.isDeepStrictEqual)(target[name], value)) {
|
|
2138
|
+
throw new Error(`${label} "${name}" conflicts with an existing definition`);
|
|
2139
|
+
}
|
|
2140
|
+
target[name] = value;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function isRecord6(value) {
|
|
2144
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
1751
2147
|
// src/doctor.ts
|
|
1752
2148
|
async function doctor(options) {
|
|
1753
2149
|
const out = options.stdout ?? console;
|
|
1754
2150
|
const cfg = await loadProjectConfig(options.configPath);
|
|
1755
2151
|
const plan = buildPlan(cfg);
|
|
1756
|
-
const
|
|
1757
|
-
const
|
|
1758
|
-
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
1759
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
2152
|
+
const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
|
|
2153
|
+
const { schema, rules } = database;
|
|
1760
2154
|
const entities = serializedEntities(schema);
|
|
1761
2155
|
out.log(`config: ${cfg.configPath}`);
|
|
1762
2156
|
out.log(`app: ${plan.appName} (${plan.appId})`);
|
|
1763
2157
|
out.log(`envs: ${plan.envs.join(", ")}`);
|
|
1764
2158
|
out.log(`svc: ${plan.services.join(", ")}`);
|
|
2159
|
+
out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
1765
2160
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1766
2161
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1767
2162
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1768
2163
|
if (cfg.services.includes("calendar")) {
|
|
1769
|
-
const calendar = cfg.envs.map((env) =>
|
|
1770
|
-
|
|
2164
|
+
const calendar = cfg.envs.map((env) => {
|
|
2165
|
+
const resolved = calendarServiceConfig(cfg, env);
|
|
2166
|
+
return `${env}:${resolved.bookingCalendarId}+${resolved.availabilityCalendars.length}`;
|
|
2167
|
+
}).join(", ");
|
|
2168
|
+
out.log(`calendar: google/booking (${calendar})`);
|
|
1771
2169
|
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1772
2170
|
out.log(`booking pages: ${pages}`);
|
|
1773
2171
|
} else {
|
|
@@ -1780,6 +2178,7 @@ async function doctor(options) {
|
|
|
1780
2178
|
if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
|
|
1781
2179
|
}
|
|
1782
2180
|
}
|
|
2181
|
+
warnings.push(...integrationWarnings(database.integrations, schema, rules));
|
|
1783
2182
|
if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
|
|
1784
2183
|
if (cfg.auth?.clerk) {
|
|
1785
2184
|
for (const [env, value] of Object.entries(cfg.auth.clerk)) {
|
|
@@ -1819,9 +2218,9 @@ async function doctor(options) {
|
|
|
1819
2218
|
}
|
|
1820
2219
|
|
|
1821
2220
|
// src/help.ts
|
|
1822
|
-
var
|
|
2221
|
+
var import_node_fs8 = require("fs");
|
|
1823
2222
|
function cliVersion() {
|
|
1824
|
-
const pkg = JSON.parse((0,
|
|
2223
|
+
const pkg = JSON.parse((0, import_node_fs8.readFileSync)(new URL("../package.json", importMetaUrl), "utf8"));
|
|
1825
2224
|
return pkg.version ?? "unknown";
|
|
1826
2225
|
}
|
|
1827
2226
|
function printHelp() {
|
|
@@ -1834,8 +2233,10 @@ Usage:
|
|
|
1834
2233
|
odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
|
|
1835
2234
|
odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
|
|
1836
2235
|
odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
|
|
1837
|
-
odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
|
|
1838
2236
|
odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
|
|
2237
|
+
odla-ai app archive [--config odla.config.mjs] [--email <odla-account>] [--json] --yes
|
|
2238
|
+
odla-ai app restore [--config odla.config.mjs] [--email <odla-account>] [--json]
|
|
2239
|
+
odla-ai app export [--env dev] [--fresh] [--out <file>] [--email <odla-account>] [--json]
|
|
1839
2240
|
odla-ai capabilities [--json]
|
|
1840
2241
|
odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
|
|
1841
2242
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -1855,7 +2256,7 @@ Usage:
|
|
|
1855
2256
|
odla-ai security report <job-id> [--json]
|
|
1856
2257
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1857
2258
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1858
|
-
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
2259
|
+
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1859
2260
|
odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
|
|
1860
2261
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1861
2262
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
@@ -1867,12 +2268,19 @@ Commands:
|
|
|
1867
2268
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1868
2269
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1869
2270
|
doctor Validate and summarize the project config without network calls.
|
|
1870
|
-
calendar Inspect, connect,
|
|
2271
|
+
calendar Inspect, connect, or disconnect the live Google booking connection.
|
|
2272
|
+
app Archive (suspend, data retained), restore, or export the app.
|
|
2273
|
+
Archiving takes every environment's data plane down until restored;
|
|
2274
|
+
permanent deletion has NO CLI \u2014 it requires a signed-in owner in
|
|
2275
|
+
Studio, and no machine or agent credential can purge. "app export"
|
|
2276
|
+
downloads a portable gzipped-JSONL snapshot of one environment's
|
|
2277
|
+
database (--fresh takes a new snapshot first) \u2014 your data is
|
|
2278
|
+
always yours to take.
|
|
1871
2279
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1872
2280
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1873
2281
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
1874
|
-
provision Register services,
|
|
1875
|
-
smoke Verify
|
|
2282
|
+
provision Register services, compose integrations, persist credentials, optionally push secrets.
|
|
2283
|
+
smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
|
|
1876
2284
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1877
2285
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1878
2286
|
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
@@ -1889,6 +2297,12 @@ Safety:
|
|
|
1889
2297
|
Provision opens the approval page in your browser automatically whenever the
|
|
1890
2298
|
machine can show one, including agent-driven runs; only CI, SSH, and
|
|
1891
2299
|
display-less hosts skip it. Use --open to force or --no-open to suppress.
|
|
2300
|
+
Browser launch is best-effort: the printed approval URL is authoritative and
|
|
2301
|
+
agents must relay it to the human verbatim. A started handshake is persisted
|
|
2302
|
+
under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
|
|
2303
|
+
the same code. Outside an interactive terminal the wait is capped (90s by
|
|
2304
|
+
default, --wait <seconds> to change); a still-pending handshake then exits
|
|
2305
|
+
with code 75: relay the URL, wait for approval, and re-run to collect.
|
|
1892
2306
|
A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
|
|
1893
2307
|
The email is a non-secret identity hint: never provide a password or session
|
|
1894
2308
|
token. The matching account must already exist, be signed in, explicitly
|
|
@@ -1925,13 +2339,13 @@ function harnessOption(value, flag) {
|
|
|
1925
2339
|
}
|
|
1926
2340
|
|
|
1927
2341
|
// src/init.ts
|
|
1928
|
-
var
|
|
1929
|
-
var
|
|
2342
|
+
var import_node_fs9 = require("fs");
|
|
2343
|
+
var import_node_path6 = require("path");
|
|
1930
2344
|
function initProject(options) {
|
|
1931
2345
|
const out = options.stdout ?? console;
|
|
1932
|
-
const rootDir = (0,
|
|
1933
|
-
const configPath = (0,
|
|
1934
|
-
if ((0,
|
|
2346
|
+
const rootDir = (0, import_node_path6.resolve)(options.rootDir ?? process.cwd());
|
|
2347
|
+
const configPath = (0, import_node_path6.resolve)(rootDir, options.configPath ?? "odla.config.mjs");
|
|
2348
|
+
if ((0, import_node_fs9.existsSync)(configPath) && !options.force) {
|
|
1935
2349
|
throw new Error(`${configPath} already exists. Pass --force to overwrite.`);
|
|
1936
2350
|
}
|
|
1937
2351
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(options.appId)) {
|
|
@@ -1941,29 +2355,29 @@ function initProject(options) {
|
|
|
1941
2355
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1942
2356
|
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1943
2357
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1944
|
-
(0,
|
|
1945
|
-
(0,
|
|
1946
|
-
(0,
|
|
1947
|
-
(0,
|
|
1948
|
-
writeIfMissing((0,
|
|
1949
|
-
writeIfMissing((0,
|
|
2358
|
+
(0, import_node_fs9.mkdirSync)((0, import_node_path6.dirname)(configPath), { recursive: true });
|
|
2359
|
+
(0, import_node_fs9.mkdirSync)((0, import_node_path6.resolve)(rootDir, "src/odla"), { recursive: true });
|
|
2360
|
+
(0, import_node_fs9.mkdirSync)((0, import_node_path6.resolve)(rootDir, ".odla"), { recursive: true });
|
|
2361
|
+
(0, import_node_fs9.writeFileSync)(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
|
|
2362
|
+
writeIfMissing((0, import_node_path6.resolve)(rootDir, "src/odla/schema.mjs"), schemaTemplate());
|
|
2363
|
+
writeIfMissing((0, import_node_path6.resolve)(rootDir, "src/odla/rules.mjs"), rulesTemplate());
|
|
1950
2364
|
ensureGitignore(rootDir);
|
|
1951
2365
|
out.log(`created ${relativeDisplay(configPath, rootDir)}`);
|
|
1952
2366
|
out.log("created src/odla/schema.mjs and src/odla/rules.mjs");
|
|
1953
2367
|
out.log("updated .gitignore for local odla credentials");
|
|
1954
2368
|
}
|
|
1955
2369
|
function writeIfMissing(path, text) {
|
|
1956
|
-
if ((0,
|
|
1957
|
-
(0,
|
|
2370
|
+
if ((0, import_node_fs9.existsSync)(path)) return;
|
|
2371
|
+
(0, import_node_fs9.writeFileSync)(path, text);
|
|
1958
2372
|
}
|
|
1959
2373
|
function configTemplate(input) {
|
|
1960
2374
|
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1961
2375
|
google: {
|
|
1962
|
-
|
|
2376
|
+
// Calendars consulted for availability; bookings land on bookingCalendar
|
|
2377
|
+
// (defaults to the first availability calendar). Google consent happens
|
|
2378
|
+
// in the browser during provision; bookings write live through odla.ai.
|
|
2379
|
+
availabilityCalendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1963
2380
|
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1964
|
-
match: { organizerSelf: true, requireAttendees: true },
|
|
1965
|
-
attendeePolicy: "full",
|
|
1966
|
-
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1967
2381
|
},
|
|
1968
2382
|
},
|
|
1969
2383
|
` : "";
|
|
@@ -1976,6 +2390,9 @@ function configTemplate(input) {
|
|
|
1976
2390
|
},
|
|
1977
2391
|
envs: ${JSON.stringify(input.envs)},
|
|
1978
2392
|
services: ${JSON.stringify(input.services)},
|
|
2393
|
+
// App capabilities are npm modules, not hosted services. Import their
|
|
2394
|
+
// data-only descriptor and add it here (for example createCrmIntegration()).
|
|
2395
|
+
integrations: [],
|
|
1979
2396
|
db: {
|
|
1980
2397
|
schema: "./src/odla/schema.mjs",
|
|
1981
2398
|
rules: "./src/odla/rules.mjs",
|
|
@@ -2053,8 +2470,60 @@ function relativeDisplay(path, rootDir) {
|
|
|
2053
2470
|
|
|
2054
2471
|
// src/provision.ts
|
|
2055
2472
|
var import_apps2 = require("@odla-ai/apps");
|
|
2056
|
-
var
|
|
2057
|
-
var
|
|
2473
|
+
var import_ai2 = require("@odla-ai/ai");
|
|
2474
|
+
var import_node_process7 = __toESM(require("process"), 1);
|
|
2475
|
+
|
|
2476
|
+
// src/integration-provision.ts
|
|
2477
|
+
var import_db3 = require("@odla-ai/db");
|
|
2478
|
+
async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
|
|
2479
|
+
const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
|
|
2480
|
+
for (const integration of integrations) {
|
|
2481
|
+
for (const seed of integration.seeds ?? []) {
|
|
2482
|
+
const payload = await postJson(doFetch, `${base}/query`, dbKey, {
|
|
2483
|
+
query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
|
|
2484
|
+
});
|
|
2485
|
+
const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
|
|
2486
|
+
if (!Array.isArray(rows)) {
|
|
2487
|
+
throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
|
|
2488
|
+
}
|
|
2489
|
+
if (rows.length > 0) {
|
|
2490
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
|
|
2491
|
+
continue;
|
|
2492
|
+
}
|
|
2493
|
+
await postJson(doFetch, `${base}/transact`, dbKey, {
|
|
2494
|
+
mutationId: `integration:${integration.id}:seed:${seed.id}`,
|
|
2495
|
+
ops: [{
|
|
2496
|
+
t: "update",
|
|
2497
|
+
ns: seed.ns,
|
|
2498
|
+
// A fresh id plus the declared-unique natural key is create-only: a
|
|
2499
|
+
// concurrent creator gets a unique violation instead of being overwritten.
|
|
2500
|
+
id: (0, import_db3.uuidv7)(),
|
|
2501
|
+
attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
|
|
2502
|
+
}]
|
|
2503
|
+
});
|
|
2504
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
async function postJson(doFetch, url, bearer, body) {
|
|
2509
|
+
const res = await doFetch(url, {
|
|
2510
|
+
method: "POST",
|
|
2511
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2512
|
+
body: JSON.stringify(body)
|
|
2513
|
+
});
|
|
2514
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
|
|
2515
|
+
return res.json().catch(() => ({}));
|
|
2516
|
+
}
|
|
2517
|
+
function isRecord7(value) {
|
|
2518
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2519
|
+
}
|
|
2520
|
+
async function responseText(res) {
|
|
2521
|
+
try {
|
|
2522
|
+
return redactSecrets((await res.text()).slice(0, 500));
|
|
2523
|
+
} catch {
|
|
2524
|
+
return "";
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2058
2527
|
|
|
2059
2528
|
// src/provision-credentials.ts
|
|
2060
2529
|
var import_apps = require("@odla-ai/apps");
|
|
@@ -2115,14 +2584,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
2115
2584
|
appId: tenantId
|
|
2116
2585
|
})
|
|
2117
2586
|
});
|
|
2118
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2587
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
|
|
2119
2588
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
2120
2589
|
method: "POST",
|
|
2121
2590
|
headers,
|
|
2122
2591
|
body: "{}"
|
|
2123
2592
|
});
|
|
2124
2593
|
}
|
|
2125
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2594
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
|
|
2126
2595
|
const body = await res.json();
|
|
2127
2596
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
2128
2597
|
return body.key;
|
|
@@ -2138,12 +2607,12 @@ async function issueO11yToken(opts) {
|
|
|
2138
2607
|
`o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
|
|
2139
2608
|
);
|
|
2140
2609
|
}
|
|
2141
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2610
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
|
|
2142
2611
|
const body = await res.json();
|
|
2143
2612
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
2144
2613
|
return body.token;
|
|
2145
2614
|
}
|
|
2146
|
-
async function
|
|
2615
|
+
async function safeText3(res) {
|
|
2147
2616
|
try {
|
|
2148
2617
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
2149
2618
|
} catch {
|
|
@@ -2151,6 +2620,18 @@ async function safeText2(res) {
|
|
|
2151
2620
|
}
|
|
2152
2621
|
}
|
|
2153
2622
|
|
|
2623
|
+
// src/provision-helpers.ts
|
|
2624
|
+
var import_ai = require("@odla-ai/ai");
|
|
2625
|
+
function serviceRank(service) {
|
|
2626
|
+
if (service === "db") return 0;
|
|
2627
|
+
if (service === "calendar") return 2;
|
|
2628
|
+
return 1;
|
|
2629
|
+
}
|
|
2630
|
+
function defaultSecretName(provider) {
|
|
2631
|
+
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
2632
|
+
return names[provider] ?? `${provider}_api_key`;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2154
2635
|
// src/secrets.ts
|
|
2155
2636
|
var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2156
2637
|
async function secretsPush(options) {
|
|
@@ -2242,24 +2723,28 @@ async function provision(options) {
|
|
|
2242
2723
|
out.log(` db: ${plan.dbEndpoint}`);
|
|
2243
2724
|
out.log(` envs: ${plan.envs.join(", ")}`);
|
|
2244
2725
|
out.log(` services: ${plan.services.join(", ")}`);
|
|
2726
|
+
out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
2245
2727
|
const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
|
|
2246
2728
|
if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
|
|
2247
2729
|
throw new Error(
|
|
2248
2730
|
`refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
|
|
2249
2731
|
);
|
|
2250
2732
|
}
|
|
2251
|
-
const
|
|
2252
|
-
const
|
|
2253
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
2733
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
2734
|
+
const { schema, rules } = database;
|
|
2254
2735
|
if (options.dryRun) {
|
|
2255
2736
|
out.log("dry run: no network calls or file writes");
|
|
2256
2737
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
2257
2738
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
2739
|
+
for (const integration of database.integrations) {
|
|
2740
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
|
|
2741
|
+
out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
|
|
2742
|
+
}
|
|
2258
2743
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
2259
2744
|
if (cfg.services.includes("calendar")) {
|
|
2260
2745
|
for (const env of cfg.envs) {
|
|
2261
2746
|
const calendar = calendarServiceConfig(cfg, env);
|
|
2262
|
-
out.log(` calendar.${env}: google/
|
|
2747
|
+
out.log(` calendar.${env}: google/book \u2192 ${calendar.bookingCalendarId}; availability ${calendar.availabilityCalendars.join(", ")} (${GOOGLE_CALENDAR_EVENTS_SCOPE})`);
|
|
2263
2748
|
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
2264
2749
|
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
2265
2750
|
}
|
|
@@ -2282,7 +2767,7 @@ async function provision(options) {
|
|
|
2282
2767
|
}
|
|
2283
2768
|
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
2284
2769
|
if (cfg.local.gitignore) {
|
|
2285
|
-
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2770
|
+
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, handshakeFile(cfg), cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2286
2771
|
}
|
|
2287
2772
|
if (options.pushSecrets) {
|
|
2288
2773
|
await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
|
|
@@ -2364,18 +2849,21 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2364
2849
|
}
|
|
2365
2850
|
}
|
|
2366
2851
|
if (schema && dbKey) {
|
|
2367
|
-
await
|
|
2852
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
|
|
2368
2853
|
out.log(`${env}: schema pushed`);
|
|
2369
2854
|
}
|
|
2370
2855
|
if (rules) {
|
|
2371
|
-
await
|
|
2856
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
|
|
2372
2857
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
2373
2858
|
}
|
|
2859
|
+
if (dbKey) {
|
|
2860
|
+
await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
|
|
2861
|
+
}
|
|
2374
2862
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
2375
|
-
const key =
|
|
2863
|
+
const key = import_node_process7.default.env[cfg.ai.keyEnv];
|
|
2376
2864
|
if (key) {
|
|
2377
2865
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
2378
|
-
await (0,
|
|
2866
|
+
await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
2379
2867
|
out.log(`${env}: ${cfg.ai.provider} key stored in vault (${secretName})`);
|
|
2380
2868
|
} else {
|
|
2381
2869
|
out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
|
|
@@ -2411,11 +2899,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2411
2899
|
}
|
|
2412
2900
|
}
|
|
2413
2901
|
}
|
|
2414
|
-
function serviceRank(service) {
|
|
2415
|
-
if (service === "db") return 0;
|
|
2416
|
-
if (service === "calendar") return 2;
|
|
2417
|
-
return 1;
|
|
2418
|
-
}
|
|
2419
2902
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
2420
2903
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
2421
2904
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -2425,13 +2908,13 @@ async function readRegistryApp(cfg, token, doFetch) {
|
|
|
2425
2908
|
const json = await res.json();
|
|
2426
2909
|
return json.app ?? null;
|
|
2427
2910
|
}
|
|
2428
|
-
async function
|
|
2911
|
+
async function postJson2(doFetch, url, bearer, body) {
|
|
2429
2912
|
const res = await doFetch(url, {
|
|
2430
2913
|
method: "POST",
|
|
2431
2914
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2432
2915
|
body: JSON.stringify(body)
|
|
2433
2916
|
});
|
|
2434
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2917
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
|
|
2435
2918
|
}
|
|
2436
2919
|
function normalizeClerkConfig(value) {
|
|
2437
2920
|
if (!value) return null;
|
|
@@ -2444,11 +2927,7 @@ function normalizeClerkConfig(value) {
|
|
|
2444
2927
|
const publishableKey = envValue(cfg.publishableKey);
|
|
2445
2928
|
return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
|
|
2446
2929
|
}
|
|
2447
|
-
function
|
|
2448
|
-
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
2449
|
-
return names[provider] ?? `${provider}_api_key`;
|
|
2450
|
-
}
|
|
2451
|
-
async function safeText3(res) {
|
|
2930
|
+
async function safeText4(res) {
|
|
2452
2931
|
try {
|
|
2453
2932
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
2454
2933
|
} catch {
|
|
@@ -2457,7 +2936,7 @@ async function safeText3(res) {
|
|
|
2457
2936
|
}
|
|
2458
2937
|
|
|
2459
2938
|
// src/secrets-set.ts
|
|
2460
|
-
var
|
|
2939
|
+
var import_ai3 = require("@odla-ai/ai");
|
|
2461
2940
|
var import_apps3 = require("@odla-ai/apps");
|
|
2462
2941
|
var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2463
2942
|
async function secretsSet(options) {
|
|
@@ -2469,7 +2948,7 @@ async function secretsSet(options) {
|
|
|
2469
2948
|
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2470
2949
|
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2471
2950
|
try {
|
|
2472
|
-
await (0,
|
|
2951
|
+
await (0, import_ai3.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
|
|
2473
2952
|
} catch (err) {
|
|
2474
2953
|
throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
|
|
2475
2954
|
}
|
|
@@ -2514,7 +2993,7 @@ async function resolveVaultWrite(options) {
|
|
|
2514
2993
|
}
|
|
2515
2994
|
|
|
2516
2995
|
// src/security-command-context.ts
|
|
2517
|
-
var
|
|
2996
|
+
var import_promises2 = require("readline/promises");
|
|
2518
2997
|
async function hostedSecurityContext(parsed, dependencies) {
|
|
2519
2998
|
const configPath = stringOpt(parsed.options.config) ?? "odla.config.mjs";
|
|
2520
2999
|
const cfg = await loadProjectConfig(configPath);
|
|
@@ -2540,7 +3019,7 @@ async function hostedSecurityContext(parsed, dependencies) {
|
|
|
2540
3019
|
async function interactiveConfirmation(message, dependencies) {
|
|
2541
3020
|
if (dependencies.confirm) return dependencies.confirm(message);
|
|
2542
3021
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
2543
|
-
const prompt = (0,
|
|
3022
|
+
const prompt = (0, import_promises2.createInterface)({ input: process.stdin, output: process.stdout });
|
|
2544
3023
|
try {
|
|
2545
3024
|
const answer = await prompt.question(`${message} [y/N] `);
|
|
2546
3025
|
return /^y(?:es)?$/i.test(answer.trim());
|
|
@@ -2670,7 +3149,7 @@ function hostedSeverity(value, flag) {
|
|
|
2670
3149
|
var import_security2 = require("@odla-ai/security");
|
|
2671
3150
|
|
|
2672
3151
|
// src/security.ts
|
|
2673
|
-
var
|
|
3152
|
+
var import_node_path7 = require("path");
|
|
2674
3153
|
var import_security = require("@odla-ai/security");
|
|
2675
3154
|
var import_node = require("@odla-ai/security/node");
|
|
2676
3155
|
async function runHostedSecurity(options) {
|
|
@@ -2682,9 +3161,9 @@ async function runHostedSecurity(options) {
|
|
|
2682
3161
|
const appId = selfAudit ? "odla-ai" : cfg.app.id;
|
|
2683
3162
|
const env = selfAudit ? "prod" : selectEnv(options.env, cfg.envs, cfg.configPath, cfg.rootDir);
|
|
2684
3163
|
const platform = options.platform ?? cfg?.platformUrl ?? "https://odla.ai";
|
|
2685
|
-
const target = (0,
|
|
2686
|
-
const output = (0,
|
|
2687
|
-
const outputRelative = (0,
|
|
3164
|
+
const target = (0, import_node_path7.resolve)(options.target ?? cfg?.rootDir ?? ".");
|
|
3165
|
+
const output = (0, import_node_path7.resolve)(options.out ?? (0, import_node_path7.resolve)(target, ".odla/security/hosted"));
|
|
3166
|
+
const outputRelative = (0, import_node_path7.relative)(target, output).split(import_node_path7.sep).join("/");
|
|
2688
3167
|
if (!outputRelative) throw new Error("Hosted security output cannot be the repository root");
|
|
2689
3168
|
const profile = profileFor(options.profile ?? "odla", options.maxHuntTasks ?? 12);
|
|
2690
3169
|
const tokenRequest = {
|
|
@@ -2696,7 +3175,7 @@ async function runHostedSecurity(options) {
|
|
|
2696
3175
|
};
|
|
2697
3176
|
const token = await injectedToken(options, tokenRequest);
|
|
2698
3177
|
const snapshot = await (0, import_node.snapshotDirectory)(target, {
|
|
2699
|
-
exclude: !outputRelative.startsWith("../") && !(0,
|
|
3178
|
+
exclude: !outputRelative.startsWith("../") && !(0, import_node_path7.isAbsolute)(outputRelative) ? [outputRelative] : []
|
|
2700
3179
|
});
|
|
2701
3180
|
const hosted = await (0, import_security.createPlatformSecurityReasoners)({
|
|
2702
3181
|
platform,
|
|
@@ -2714,7 +3193,7 @@ async function runHostedSecurity(options) {
|
|
|
2714
3193
|
});
|
|
2715
3194
|
const harness = (0, import_security.createSecurityHarness)({
|
|
2716
3195
|
profile,
|
|
2717
|
-
store: new import_node.FileRunStore((0,
|
|
3196
|
+
store: new import_node.FileRunStore((0, import_node_path7.resolve)(output, "state")),
|
|
2718
3197
|
discoveryReasoner: hosted.discoveryReasoner,
|
|
2719
3198
|
validationReasoner: hosted.validationReasoner,
|
|
2720
3199
|
policy: {
|
|
@@ -2738,7 +3217,7 @@ async function runHostedSecurity(options) {
|
|
|
2738
3217
|
function selectEnv(requested, declared, configPath, rootDir) {
|
|
2739
3218
|
const env = requested ?? (declared.includes("dev") ? "dev" : declared[0]);
|
|
2740
3219
|
if (!env || !declared.includes(env)) {
|
|
2741
|
-
const shown = (0,
|
|
3220
|
+
const shown = (0, import_node_path7.relative)(rootDir, configPath) || configPath;
|
|
2742
3221
|
throw new Error(`env "${env ?? ""}" is not declared in ${shown}`);
|
|
2743
3222
|
}
|
|
2744
3223
|
return env;
|
|
@@ -2767,7 +3246,7 @@ function printSummary(out, appId, env, run, report, output) {
|
|
|
2767
3246
|
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}`);
|
|
2768
3247
|
if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
|
|
2769
3248
|
out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
|
|
2770
|
-
out.log(` report: ${(0,
|
|
3249
|
+
out.log(` report: ${(0, import_node_path7.resolve)(output, "REPORT.md")}`);
|
|
2771
3250
|
}
|
|
2772
3251
|
function formatBudget(usage) {
|
|
2773
3252
|
return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
|
|
@@ -2775,7 +3254,7 @@ function formatBudget(usage) {
|
|
|
2775
3254
|
|
|
2776
3255
|
// src/security-hosted-github.ts
|
|
2777
3256
|
var import_node_child_process4 = require("child_process");
|
|
2778
|
-
var
|
|
3257
|
+
var import_node_util2 = require("util");
|
|
2779
3258
|
|
|
2780
3259
|
// src/security-hosted-request.ts
|
|
2781
3260
|
async function requestHostedSecurityJson(options, path, init, action) {
|
|
@@ -2979,7 +3458,7 @@ async function inferGitHubRepository(cwd = process.cwd(), readOrigin = defaultRe
|
|
|
2979
3458
|
return repositoryFromGitRemote(remote);
|
|
2980
3459
|
}
|
|
2981
3460
|
async function defaultReadOrigin(cwd) {
|
|
2982
|
-
const result = await (0,
|
|
3461
|
+
const result = await (0, import_node_util2.promisify)(import_node_child_process4.execFile)(
|
|
2983
3462
|
"git",
|
|
2984
3463
|
["remote", "get-url", "origin"],
|
|
2985
3464
|
{ cwd, encoding: "utf8" }
|
|
@@ -3419,9 +3898,9 @@ async function securityStatus(parsed, dependencies) {
|
|
|
3419
3898
|
}
|
|
3420
3899
|
|
|
3421
3900
|
// src/skill.ts
|
|
3422
|
-
var
|
|
3901
|
+
var import_node_fs10 = require("fs");
|
|
3423
3902
|
var import_node_os = require("os");
|
|
3424
|
-
var
|
|
3903
|
+
var import_node_path8 = require("path");
|
|
3425
3904
|
var import_node_url2 = require("url");
|
|
3426
3905
|
|
|
3427
3906
|
// src/skill-adapters.ts
|
|
@@ -3482,8 +3961,8 @@ function installSkill(options = {}) {
|
|
|
3482
3961
|
const files = listFiles(sourceDir);
|
|
3483
3962
|
if (files.length === 0) throw new Error(`no bundled skills found at ${sourceDir}`);
|
|
3484
3963
|
const harnesses = normalizeHarnesses(options.harnesses, options.global === true);
|
|
3485
|
-
const root = (0,
|
|
3486
|
-
const home = (0,
|
|
3964
|
+
const root = (0, import_node_path8.resolve)(options.dir ?? process.cwd());
|
|
3965
|
+
const home = (0, import_node_path8.resolve)(options.homeDir ?? (0, import_node_os.homedir)());
|
|
3487
3966
|
const plans = /* @__PURE__ */ new Map();
|
|
3488
3967
|
const targets = /* @__PURE__ */ new Map();
|
|
3489
3968
|
const rememberTarget = (harness, target) => {
|
|
@@ -3497,48 +3976,48 @@ function installSkill(options = {}) {
|
|
|
3497
3976
|
plans.set(target, { target, content, boundary, managedMerge });
|
|
3498
3977
|
};
|
|
3499
3978
|
const planSkillTree = (targetDir2, boundary = root) => {
|
|
3500
|
-
for (const rel of files) plan((0,
|
|
3979
|
+
for (const rel of files) plan((0, import_node_path8.join)(targetDir2, rel), (0, import_node_fs10.readFileSync)((0, import_node_path8.join)(sourceDir, rel), "utf8"), false, boundary);
|
|
3501
3980
|
};
|
|
3502
3981
|
let targetDir;
|
|
3503
3982
|
if (options.global) {
|
|
3504
|
-
const claudeRoot = (0,
|
|
3505
|
-
const codexRoot = (0,
|
|
3983
|
+
const claudeRoot = (0, import_node_path8.join)(home, ".claude", "skills");
|
|
3984
|
+
const codexRoot = (0, import_node_path8.resolve)(options.codexHomeDir ?? process.env.CODEX_HOME ?? (0, import_node_path8.join)(home, ".codex"), "skills");
|
|
3506
3985
|
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
3507
3986
|
for (const harness of harnesses) {
|
|
3508
3987
|
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
3509
|
-
planSkillTree(skillRoot, harness === "claude" ? home : (0,
|
|
3988
|
+
planSkillTree(skillRoot, harness === "claude" ? home : (0, import_node_path8.dirname)((0, import_node_path8.dirname)(codexRoot)));
|
|
3510
3989
|
rememberTarget(harness, skillRoot);
|
|
3511
3990
|
}
|
|
3512
3991
|
} else {
|
|
3513
|
-
const sharedRoot = (0,
|
|
3992
|
+
const sharedRoot = (0, import_node_path8.join)(root, ".agents", "skills");
|
|
3514
3993
|
planSkillTree(sharedRoot);
|
|
3515
|
-
const claudeRoot = (0,
|
|
3994
|
+
const claudeRoot = (0, import_node_path8.join)(root, ".claude", "skills");
|
|
3516
3995
|
targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
|
|
3517
3996
|
for (const harness of harnesses) rememberTarget(harness, sharedRoot);
|
|
3518
3997
|
if (harnesses.includes("claude")) {
|
|
3519
3998
|
for (const skill of skillNames(files)) {
|
|
3520
|
-
const canonical = (0,
|
|
3521
|
-
plan((0,
|
|
3999
|
+
const canonical = (0, import_node_fs10.readFileSync)((0, import_node_path8.join)(sourceDir, skill, "SKILL.md"), "utf8");
|
|
4000
|
+
plan((0, import_node_path8.join)(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
|
|
3522
4001
|
}
|
|
3523
4002
|
rememberTarget("claude", claudeRoot);
|
|
3524
4003
|
}
|
|
3525
4004
|
if (harnesses.includes("cursor")) {
|
|
3526
|
-
const cursorRule = (0,
|
|
4005
|
+
const cursorRule = (0, import_node_path8.join)(root, ".cursor", "rules", "odla.mdc");
|
|
3527
4006
|
plan(cursorRule, CURSOR_RULE);
|
|
3528
4007
|
rememberTarget("cursor", cursorRule);
|
|
3529
4008
|
}
|
|
3530
4009
|
if (harnesses.includes("agents")) {
|
|
3531
|
-
const agentsFile = (0,
|
|
4010
|
+
const agentsFile = (0, import_node_path8.join)(root, "AGENTS.md");
|
|
3532
4011
|
plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
3533
4012
|
rememberTarget("agents", agentsFile);
|
|
3534
4013
|
}
|
|
3535
4014
|
if (harnesses.includes("copilot")) {
|
|
3536
|
-
const copilotFile = (0,
|
|
4015
|
+
const copilotFile = (0, import_node_path8.join)(root, ".github", "copilot-instructions.md");
|
|
3537
4016
|
plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
3538
4017
|
rememberTarget("copilot", copilotFile);
|
|
3539
4018
|
}
|
|
3540
4019
|
if (harnesses.includes("gemini")) {
|
|
3541
|
-
const geminiFile = (0,
|
|
4020
|
+
const geminiFile = (0, import_node_path8.join)(root, "GEMINI.md");
|
|
3542
4021
|
plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
3543
4022
|
rememberTarget("gemini", geminiFile);
|
|
3544
4023
|
}
|
|
@@ -3552,11 +4031,11 @@ function installSkill(options = {}) {
|
|
|
3552
4031
|
conflicts.push(`${file.target} (redirected by symbolic link ${symlink})`);
|
|
3553
4032
|
continue;
|
|
3554
4033
|
}
|
|
3555
|
-
if (!(0,
|
|
4034
|
+
if (!(0, import_node_fs10.existsSync)(file.target)) {
|
|
3556
4035
|
writtenPaths.add(file.target);
|
|
3557
4036
|
continue;
|
|
3558
4037
|
}
|
|
3559
|
-
const current = (0,
|
|
4038
|
+
const current = (0, import_node_fs10.readFileSync)(file.target, "utf8");
|
|
3560
4039
|
if (current === file.content) {
|
|
3561
4040
|
unchangedPaths.add(file.target);
|
|
3562
4041
|
} else if (file.managedMerge || options.force) {
|
|
@@ -3573,9 +4052,9 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
3573
4052
|
);
|
|
3574
4053
|
}
|
|
3575
4054
|
for (const file of plans.values()) {
|
|
3576
|
-
if (!(0,
|
|
3577
|
-
(0,
|
|
3578
|
-
(0,
|
|
4055
|
+
if (!(0, import_node_fs10.existsSync)(file.target) || (0, import_node_fs10.readFileSync)(file.target, "utf8") !== file.content) {
|
|
4056
|
+
(0, import_node_fs10.mkdirSync)((0, import_node_path8.dirname)(file.target), { recursive: true });
|
|
4057
|
+
(0, import_node_fs10.writeFileSync)(file.target, file.content);
|
|
3579
4058
|
}
|
|
3580
4059
|
}
|
|
3581
4060
|
const skills = skillNames(files);
|
|
@@ -3594,7 +4073,7 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
3594
4073
|
};
|
|
3595
4074
|
}
|
|
3596
4075
|
function pathsUnder(root, paths) {
|
|
3597
|
-
return [...paths].map((path) => (0,
|
|
4076
|
+
return [...paths].map((path) => (0, import_node_path8.relative)(root, path)).filter((path) => path !== ".." && !path.startsWith(`..${import_node_path8.sep}`) && !(0, import_node_path8.isAbsolute)(path)).sort();
|
|
3598
4077
|
}
|
|
3599
4078
|
function normalizeHarnesses(values, global) {
|
|
3600
4079
|
const requested = values?.length ? values : ["claude"];
|
|
@@ -3616,9 +4095,9 @@ function normalizeHarnesses(values, global) {
|
|
|
3616
4095
|
function managedFileContent(path, block, force, boundary) {
|
|
3617
4096
|
const symlink = symlinkedComponent(boundary, path);
|
|
3618
4097
|
if (symlink) throw new Error(`refusing to manage ${path}: symbolic link component ${symlink}`);
|
|
3619
|
-
if (!(0,
|
|
4098
|
+
if (!(0, import_node_fs10.existsSync)(path)) return `${block}
|
|
3620
4099
|
`;
|
|
3621
|
-
const current = (0,
|
|
4100
|
+
const current = (0, import_node_fs10.readFileSync)(path, "utf8");
|
|
3622
4101
|
const start = "<!-- odla-ai agent setup:start -->";
|
|
3623
4102
|
const end = "<!-- odla-ai agent setup:end -->";
|
|
3624
4103
|
const startAt = current.indexOf(start);
|
|
@@ -3639,15 +4118,15 @@ function managedFileContent(path, block, force, boundary) {
|
|
|
3639
4118
|
return `${current.slice(0, startAt)}${block}${current.slice(afterEnd)}`;
|
|
3640
4119
|
}
|
|
3641
4120
|
function symlinkedComponent(boundary, target) {
|
|
3642
|
-
const rel = (0,
|
|
3643
|
-
if (rel === ".." || rel.startsWith(`..${
|
|
4121
|
+
const rel = (0, import_node_path8.relative)(boundary, target);
|
|
4122
|
+
if (rel === ".." || rel.startsWith(`..${import_node_path8.sep}`) || (0, import_node_path8.isAbsolute)(rel)) {
|
|
3644
4123
|
throw new Error(`agent setup target escapes its install root: ${target}`);
|
|
3645
4124
|
}
|
|
3646
4125
|
let current = boundary;
|
|
3647
|
-
for (const part of rel.split(
|
|
3648
|
-
current = (0,
|
|
4126
|
+
for (const part of rel.split(import_node_path8.sep).filter(Boolean)) {
|
|
4127
|
+
current = (0, import_node_path8.join)(current, part);
|
|
3649
4128
|
try {
|
|
3650
|
-
if ((0,
|
|
4129
|
+
if ((0, import_node_fs10.lstatSync)(current).isSymbolicLink()) return current;
|
|
3651
4130
|
} catch (error) {
|
|
3652
4131
|
if (error.code !== "ENOENT") throw error;
|
|
3653
4132
|
}
|
|
@@ -3658,13 +4137,13 @@ function skillNames(files) {
|
|
|
3658
4137
|
return [...new Set(files.filter((file) => /(^|[\\/])SKILL\.md$/.test(file)).map((file) => file.split(/[\\/]/)[0]))].sort();
|
|
3659
4138
|
}
|
|
3660
4139
|
function listFiles(dir) {
|
|
3661
|
-
if (!(0,
|
|
4140
|
+
if (!(0, import_node_fs10.existsSync)(dir)) return [];
|
|
3662
4141
|
const results = [];
|
|
3663
4142
|
const walk = (current) => {
|
|
3664
|
-
for (const entry of (0,
|
|
3665
|
-
const path = (0,
|
|
4143
|
+
for (const entry of (0, import_node_fs10.readdirSync)(current, { withFileTypes: true })) {
|
|
4144
|
+
const path = (0, import_node_path8.join)(current, entry.name);
|
|
3666
4145
|
if (entry.isDirectory()) walk(path);
|
|
3667
|
-
else results.push((0,
|
|
4146
|
+
else results.push((0, import_node_path8.relative)(dir, path));
|
|
3668
4147
|
}
|
|
3669
4148
|
};
|
|
3670
4149
|
walk(dir);
|
|
@@ -3715,9 +4194,10 @@ async function smoke(options) {
|
|
|
3715
4194
|
);
|
|
3716
4195
|
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3717
4196
|
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3718
|
-
out.log(` calendar: ${status.status},
|
|
4197
|
+
out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
|
|
3719
4198
|
}
|
|
3720
|
-
const
|
|
4199
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
4200
|
+
const expectedSchema = database.schema;
|
|
3721
4201
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
3722
4202
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
3723
4203
|
const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
|
|
@@ -3729,7 +4209,7 @@ async function smoke(options) {
|
|
|
3729
4209
|
out.log(` schema: ${liveEntities.length} entities`);
|
|
3730
4210
|
const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
|
|
3731
4211
|
if (aggregateEntity) {
|
|
3732
|
-
const aggregate = await
|
|
4212
|
+
const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3733
4213
|
ns: aggregateEntity,
|
|
3734
4214
|
aggregate: { count: true }
|
|
3735
4215
|
});
|
|
@@ -3738,13 +4218,20 @@ async function smoke(options) {
|
|
|
3738
4218
|
} else {
|
|
3739
4219
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3740
4220
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
4221
|
+
const probes = database.integrations.flatMap(
|
|
4222
|
+
(integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
|
|
4223
|
+
);
|
|
4224
|
+
if (probes.length) {
|
|
4225
|
+
const link = cfg.links?.[env];
|
|
4226
|
+
if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
|
|
4227
|
+
for (const { integration, probe } of probes) {
|
|
4228
|
+
const probeUrl = new URL(probe.path, link).toString();
|
|
4229
|
+
const res = await doFetch(probeUrl, { redirect: "manual" });
|
|
4230
|
+
if (res.status !== probe.expectedStatus) {
|
|
4231
|
+
throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
|
|
4232
|
+
}
|
|
4233
|
+
out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
|
|
4234
|
+
}
|
|
3748
4235
|
}
|
|
3749
4236
|
out.log("ok");
|
|
3750
4237
|
}
|
|
@@ -3753,29 +4240,26 @@ function assertCalendarHealthy(status, expected) {
|
|
|
3753
4240
|
if (status.status !== "healthy") {
|
|
3754
4241
|
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3755
4242
|
}
|
|
3756
|
-
if (status.
|
|
3757
|
-
const
|
|
3758
|
-
if (!
|
|
3759
|
-
const missing = expected.
|
|
4243
|
+
if (!status.writable) throw new Error('calendar grant does not cover booking writes; run "odla-ai calendar connect" to re-consent');
|
|
4244
|
+
const hasEventsScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_EVENTS_SCOPE);
|
|
4245
|
+
if (!hasEventsScope) throw new Error("calendar connection is missing calendar.events consent");
|
|
4246
|
+
const missing = expected.availabilityCalendars.filter((id) => !status.calendars.includes(id));
|
|
3760
4247
|
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3761
|
-
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3762
|
-
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3763
|
-
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3764
4248
|
}
|
|
3765
4249
|
async function getJson(doFetch, url, bearer) {
|
|
3766
4250
|
const res = await doFetch(url, {
|
|
3767
4251
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
3768
4252
|
});
|
|
3769
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
4253
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
3770
4254
|
return res.json();
|
|
3771
4255
|
}
|
|
3772
|
-
async function
|
|
4256
|
+
async function postJson3(doFetch, url, bearer, body) {
|
|
3773
4257
|
const res = await doFetch(url, {
|
|
3774
4258
|
method: "POST",
|
|
3775
4259
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
3776
4260
|
body: JSON.stringify(body)
|
|
3777
4261
|
});
|
|
3778
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
4262
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
3779
4263
|
return res.json();
|
|
3780
4264
|
}
|
|
3781
4265
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -3783,7 +4267,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
3783
4267
|
url.searchParams.set("env", env);
|
|
3784
4268
|
return url.toString();
|
|
3785
4269
|
}
|
|
3786
|
-
async function
|
|
4270
|
+
async function safeText5(res) {
|
|
3787
4271
|
try {
|
|
3788
4272
|
return (await res.text()).slice(0, 500);
|
|
3789
4273
|
} catch {
|
|
@@ -3792,6 +4276,9 @@ async function safeText4(res) {
|
|
|
3792
4276
|
}
|
|
3793
4277
|
|
|
3794
4278
|
// src/cli.ts
|
|
4279
|
+
function exitCodeFor(err) {
|
|
4280
|
+
return err?.code === "handshake_pending" ? 75 : 1;
|
|
4281
|
+
}
|
|
3795
4282
|
async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
3796
4283
|
const parsed = parseArgv(argv);
|
|
3797
4284
|
const command = parsed.positionals[0] ?? "help";
|
|
@@ -3844,6 +4331,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3844
4331
|
await adminCommand(parsed);
|
|
3845
4332
|
return;
|
|
3846
4333
|
}
|
|
4334
|
+
if (command === "app") {
|
|
4335
|
+
await appCommand(parsed, dependencies);
|
|
4336
|
+
return;
|
|
4337
|
+
}
|
|
3847
4338
|
if (command === "security") {
|
|
3848
4339
|
await securityCommand(parsed, dependencies);
|
|
3849
4340
|
return;
|
|
@@ -3940,6 +4431,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3940
4431
|
"token",
|
|
3941
4432
|
"email",
|
|
3942
4433
|
"open",
|
|
4434
|
+
"wait",
|
|
3943
4435
|
"yes"
|
|
3944
4436
|
], 1);
|
|
3945
4437
|
const writeDevVars2 = parsed.options["write-dev-vars"];
|
|
@@ -3954,6 +4446,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3954
4446
|
token: stringOpt(parsed.options.token),
|
|
3955
4447
|
email: stringOpt(parsed.options.email),
|
|
3956
4448
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
4449
|
+
wait: numberOpt(parsed.options.wait, "--wait"),
|
|
3957
4450
|
yes: parsed.options.yes === true,
|
|
3958
4451
|
fetch: dependencies.fetch,
|
|
3959
4452
|
openApprovalUrl: dependencies.openUrl,
|
|
@@ -3963,7 +4456,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3963
4456
|
}
|
|
3964
4457
|
async function calendarCommand(parsed, dependencies) {
|
|
3965
4458
|
const sub = parsed.positionals[1];
|
|
3966
|
-
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "
|
|
4459
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "disconnect") {
|
|
3967
4460
|
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3968
4461
|
}
|
|
3969
4462
|
assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
|
|
@@ -3984,13 +4477,12 @@ async function calendarCommand(parsed, dependencies) {
|
|
|
3984
4477
|
if (sub === "status") await calendarStatus(options);
|
|
3985
4478
|
else if (sub === "calendars") await calendarCalendars(options);
|
|
3986
4479
|
else if (sub === "connect") await calendarConnect(options);
|
|
3987
|
-
else if (sub === "resync") await calendarResync(options);
|
|
3988
4480
|
else await calendarDisconnect(options);
|
|
3989
4481
|
}
|
|
3990
4482
|
|
|
3991
4483
|
// src/bin.ts
|
|
3992
4484
|
runCli().catch((err) => {
|
|
3993
4485
|
console.error(`odla-ai: ${err instanceof Error ? err.message : String(err)}`);
|
|
3994
|
-
process.exitCode =
|
|
4486
|
+
process.exitCode = exitCodeFor(err);
|
|
3995
4487
|
});
|
|
3996
4488
|
//# sourceMappingURL=bin.cjs.map
|