@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/admin-ai-auth.ts
|
|
4
4
|
import { existsSync as existsSync2 } from "fs";
|
|
5
|
-
import
|
|
5
|
+
import process5 from "process";
|
|
6
6
|
import { requestToken as requestToken2 } from "@odla-ai/db";
|
|
7
7
|
|
|
8
8
|
// src/local.ts
|
|
@@ -156,55 +156,173 @@ function openerFor(platform) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// src/token.ts
|
|
159
|
-
import { requestToken } from "@odla-ai/db";
|
|
159
|
+
import { collectToken, OdlaError, requestToken } from "@odla-ai/db";
|
|
160
|
+
import process4 from "process";
|
|
161
|
+
|
|
162
|
+
// src/handshake-state.ts
|
|
163
|
+
import { rmSync } from "fs";
|
|
164
|
+
import { dirname as dirname2, join } from "path";
|
|
160
165
|
import process3 from "process";
|
|
166
|
+
function handshakeFile(cfg) {
|
|
167
|
+
return join(dirname2(cfg.local.tokenFile), "handshake.local.json");
|
|
168
|
+
}
|
|
169
|
+
var RESUME_MARGIN_MS = 5e3;
|
|
170
|
+
function readPendingHandshake(path, platform, email) {
|
|
171
|
+
const pending = readJsonFile(path);
|
|
172
|
+
if (!pending || pending.platform !== platform || pending.email !== email) return null;
|
|
173
|
+
if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string" || typeof pending.approvalUrl !== "string") return null;
|
|
174
|
+
if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
|
|
175
|
+
return { interval: 3, ...pending };
|
|
176
|
+
}
|
|
177
|
+
function writePendingHandshake(path, pending) {
|
|
178
|
+
writePrivateJson(path, pending);
|
|
179
|
+
}
|
|
180
|
+
function clearPendingHandshake(path) {
|
|
181
|
+
rmSync(path, { force: true });
|
|
182
|
+
}
|
|
183
|
+
function minutesLeft(expiresAt) {
|
|
184
|
+
return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
|
|
185
|
+
}
|
|
186
|
+
function approvalHint(pending) {
|
|
187
|
+
return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
|
|
188
|
+
}
|
|
189
|
+
function approvalReminder(out, pending, periodMs = 3e4) {
|
|
190
|
+
const timer = setInterval(() => out.log(`auth: still waiting \u2014 ${approvalHint(pending)}`), periodMs);
|
|
191
|
+
timer.unref?.();
|
|
192
|
+
return () => clearInterval(timer);
|
|
193
|
+
}
|
|
194
|
+
function handshakeWaitMs(waitSeconds, interactive = process3.stdout.isTTY === true) {
|
|
195
|
+
if (waitSeconds !== void 0) return waitSeconds * 1e3;
|
|
196
|
+
return interactive ? void 0 : 9e4;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/token.ts
|
|
161
200
|
async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
162
201
|
if (options.token) return options.token;
|
|
163
202
|
const audience = platformAudience(cfg.platformUrl);
|
|
164
|
-
if (
|
|
165
|
-
const declared =
|
|
203
|
+
if (process4.env.ODLA_DEV_TOKEN) {
|
|
204
|
+
const declared = process4.env.ODLA_DEV_TOKEN_AUDIENCE;
|
|
166
205
|
if (declared) {
|
|
167
206
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
|
|
168
207
|
} else if (audience !== "https://odla.ai") {
|
|
169
208
|
throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
|
|
170
209
|
}
|
|
171
|
-
return
|
|
210
|
+
return process4.env.ODLA_DEV_TOKEN;
|
|
172
211
|
}
|
|
173
212
|
const cached = readJsonFile(cfg.local.tokenFile);
|
|
174
213
|
if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
175
214
|
out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
176
215
|
return cached.token;
|
|
177
216
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
out.log(`auth: opened browser for approval${browser.mode === "auto" ? " (auto)" : ""}`);
|
|
193
|
-
} catch (err) {
|
|
194
|
-
out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
195
|
-
}
|
|
196
|
-
} else if (browser.reason) {
|
|
197
|
-
out.log(`auth: browser launch skipped (${browser.reason})`);
|
|
198
|
-
}
|
|
199
|
-
out.log("");
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
writePrivateJson(cfg.local.tokenFile, { platform: audience, email, token, expiresAt });
|
|
217
|
+
const ctx = {
|
|
218
|
+
cfg,
|
|
219
|
+
options,
|
|
220
|
+
doFetch,
|
|
221
|
+
out,
|
|
222
|
+
audience,
|
|
223
|
+
browser: approvalBrowser(options),
|
|
224
|
+
email: handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0),
|
|
225
|
+
pendingFile: handshakeFile(cfg)
|
|
226
|
+
};
|
|
227
|
+
const waitMs = handshakeWaitMs(options.wait);
|
|
228
|
+
const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
|
|
229
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
230
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, email: ctx.email, token, expiresAt });
|
|
203
231
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
204
232
|
return token;
|
|
205
233
|
}
|
|
234
|
+
async function resumePendingHandshake(ctx, waitMs) {
|
|
235
|
+
const pending = readPendingHandshake(ctx.pendingFile, ctx.audience, ctx.email);
|
|
236
|
+
if (!pending) return null;
|
|
237
|
+
ctx.out.log("");
|
|
238
|
+
ctx.out.log(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
|
|
239
|
+
await launchApproval(ctx, pending.approvalUrl);
|
|
240
|
+
ctx.out.log("");
|
|
241
|
+
const stopReminder = approvalReminder(ctx.out, pending);
|
|
242
|
+
try {
|
|
243
|
+
return await collectToken({
|
|
244
|
+
endpoint: ctx.cfg.platformUrl,
|
|
245
|
+
deviceCode: pending.deviceCode,
|
|
246
|
+
expiresAt: pending.expiresAt,
|
|
247
|
+
interval: pending.interval,
|
|
248
|
+
waitMs,
|
|
249
|
+
fetch: ctx.doFetch
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const code = err instanceof OdlaError ? err.code : void 0;
|
|
253
|
+
if (code === "handshake_pending") throw stillPending(pending, ctx.email);
|
|
254
|
+
if (code === "handshake_expired" || code === "handshake_timeout") {
|
|
255
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
256
|
+
ctx.out.log("auth: pending handshake lapsed unapproved; starting a fresh one");
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
|
|
260
|
+
throw err;
|
|
261
|
+
} finally {
|
|
262
|
+
stopReminder();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function freshHandshake(ctx, waitMs) {
|
|
266
|
+
let started;
|
|
267
|
+
let stopReminder;
|
|
268
|
+
try {
|
|
269
|
+
return await requestToken({
|
|
270
|
+
endpoint: ctx.cfg.platformUrl,
|
|
271
|
+
email: ctx.email,
|
|
272
|
+
label: `${ctx.cfg.app.id} provisioner`,
|
|
273
|
+
fetch: ctx.doFetch,
|
|
274
|
+
waitMs,
|
|
275
|
+
onCode: async ({ userCode, deviceCode, expiresIn, interval, verificationUriComplete }) => {
|
|
276
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(ctx.cfg.platformUrl, userCode);
|
|
277
|
+
started = {
|
|
278
|
+
platform: ctx.audience,
|
|
279
|
+
email: ctx.email,
|
|
280
|
+
userCode,
|
|
281
|
+
deviceCode,
|
|
282
|
+
approvalUrl,
|
|
283
|
+
interval,
|
|
284
|
+
expiresAt: Date.now() + expiresIn * 1e3
|
|
285
|
+
};
|
|
286
|
+
writePendingHandshake(ctx.pendingFile, started);
|
|
287
|
+
ctx.out.log("");
|
|
288
|
+
ctx.out.log(`Review, then ${approvalHint(started)}.`);
|
|
289
|
+
await launchApproval(ctx, approvalUrl);
|
|
290
|
+
ctx.out.log("");
|
|
291
|
+
stopReminder = approvalReminder(ctx.out, started);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const code = err instanceof OdlaError ? err.code : void 0;
|
|
296
|
+
if (code === "handshake_pending" && started) throw stillPending(started, ctx.email);
|
|
297
|
+
if (code === "handshake_denied" || code === "handshake_expired" || code === "handshake_timeout") {
|
|
298
|
+
clearPendingHandshake(ctx.pendingFile);
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
} finally {
|
|
302
|
+
stopReminder?.();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function launchApproval(ctx, approvalUrl) {
|
|
306
|
+
if (ctx.browser.open) {
|
|
307
|
+
try {
|
|
308
|
+
await (ctx.options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
309
|
+
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`);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
ctx.out.log(`auth: could not open browser (${err instanceof Error ? err.message : String(err)})`);
|
|
312
|
+
}
|
|
313
|
+
} else if (ctx.browser.reason) {
|
|
314
|
+
ctx.out.log(`auth: browser launch skipped (${ctx.browser.reason}) \u2014 show the human the URL above`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function stillPending(pending, email) {
|
|
318
|
+
return new OdlaError(
|
|
319
|
+
"handshake_pending",
|
|
320
|
+
`handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
|
|
321
|
+
{ retryable: true }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
206
324
|
function handshakeEmail(value, cached) {
|
|
207
|
-
const email = (value ??
|
|
325
|
+
const email = (value ?? process4.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
|
|
208
326
|
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
209
327
|
throw new Error("a fresh odla handshake requires --email <account> or ODLA_USER_EMAIL");
|
|
210
328
|
}
|
|
@@ -213,10 +331,11 @@ function handshakeEmail(value, cached) {
|
|
|
213
331
|
function approvalBrowser(options, host = {}) {
|
|
214
332
|
if (options.open === true) return { open: true, mode: "forced" };
|
|
215
333
|
if (options.open === false) return { open: false, reason: "disabled by --no-open" };
|
|
216
|
-
const env = host.env ??
|
|
334
|
+
const env = host.env ?? process4.env;
|
|
335
|
+
if (env.VITEST || env.NODE_ENV === "test") return { open: false, reason: "test environment" };
|
|
217
336
|
if (env.CI) return { open: false, reason: "CI environment" };
|
|
218
337
|
if (env.SSH_CONNECTION || env.SSH_TTY) return { open: false, reason: "SSH session; pass --open to force" };
|
|
219
|
-
const platform = host.platform ??
|
|
338
|
+
const platform = host.platform ?? process4.platform;
|
|
220
339
|
if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
221
340
|
return { open: false, reason: "no graphical display; pass --open to force" };
|
|
222
341
|
}
|
|
@@ -251,7 +370,7 @@ async function getScopedPlatformToken(options) {
|
|
|
251
370
|
async function resolveAdminPlatformToken(options) {
|
|
252
371
|
const audience = platformAudience(options.platform);
|
|
253
372
|
if (options.token) return options.token;
|
|
254
|
-
const fromEnv =
|
|
373
|
+
const fromEnv = process5.env.ODLA_ADMIN_TOKEN;
|
|
255
374
|
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
256
375
|
return scopedToken(
|
|
257
376
|
audience,
|
|
@@ -263,7 +382,7 @@ async function resolveAdminPlatformToken(options) {
|
|
|
263
382
|
}
|
|
264
383
|
function audienceBoundEnvToken(token, platform) {
|
|
265
384
|
const audience = platformAudience(platform);
|
|
266
|
-
const declared =
|
|
385
|
+
const declared = process5.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
267
386
|
if (declared) {
|
|
268
387
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
269
388
|
} else if (audience !== "https://odla.ai") {
|
|
@@ -273,7 +392,7 @@ function audienceBoundEnvToken(token, platform) {
|
|
|
273
392
|
}
|
|
274
393
|
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
275
394
|
const audience = platformAudience(platform);
|
|
276
|
-
const tokenFile = options.tokenFile ?? `${
|
|
395
|
+
const tokenFile = options.tokenFile ?? `${process5.cwd()}/.odla/admin-token.local.json`;
|
|
277
396
|
const cache = readJsonFile(tokenFile);
|
|
278
397
|
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
279
398
|
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
@@ -297,7 +416,7 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
297
416
|
});
|
|
298
417
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
299
418
|
tokens[scope] = { token, expiresAt };
|
|
300
|
-
if (existsSync2(`${
|
|
419
|
+
if (existsSync2(`${process5.cwd()}/.git`)) ensureGitignore(process5.cwd(), [tokenFile]);
|
|
301
420
|
writePrivateJson(tokenFile, { platform: audience, email, tokens });
|
|
302
421
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
303
422
|
return token;
|
|
@@ -313,15 +432,15 @@ function requireSystemAiPurpose(value) {
|
|
|
313
432
|
}
|
|
314
433
|
|
|
315
434
|
// src/admin-ai.ts
|
|
316
|
-
import
|
|
435
|
+
import process7 from "process";
|
|
317
436
|
|
|
318
437
|
// src/secret-input.ts
|
|
319
|
-
import
|
|
438
|
+
import process6 from "process";
|
|
320
439
|
var MAX_BYTES = 64 * 1024;
|
|
321
440
|
async function secretInputValue(options, kind = "credential") {
|
|
322
441
|
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
323
442
|
let value;
|
|
324
|
-
if (options.fromEnv) value =
|
|
443
|
+
if (options.fromEnv) value = process6.env[options.fromEnv];
|
|
325
444
|
else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
|
|
326
445
|
else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
|
|
327
446
|
value = value?.replace(/[\r\n]+$/, "");
|
|
@@ -329,7 +448,7 @@ async function secretInputValue(options, kind = "credential") {
|
|
|
329
448
|
if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
330
449
|
return value;
|
|
331
450
|
}
|
|
332
|
-
async function readSecretStream(kind, stream =
|
|
451
|
+
async function readSecretStream(kind, stream = process6.stdin) {
|
|
333
452
|
let value = "";
|
|
334
453
|
for await (const chunk of stream) {
|
|
335
454
|
value += String(chunk);
|
|
@@ -484,7 +603,7 @@ function isRecord2(value) {
|
|
|
484
603
|
|
|
485
604
|
// src/admin-ai.ts
|
|
486
605
|
async function adminAi(options) {
|
|
487
|
-
const platform = platformAudience(options.platform ??
|
|
606
|
+
const platform = platformAudience(options.platform ?? process7.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
488
607
|
const doFetch = options.fetch ?? fetch;
|
|
489
608
|
const out = options.stdout ?? console;
|
|
490
609
|
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
@@ -663,82 +782,97 @@ function isRecord3(value) {
|
|
|
663
782
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
664
783
|
}
|
|
665
784
|
|
|
666
|
-
// src/
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
785
|
+
// src/config.ts
|
|
786
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
787
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
788
|
+
import { pathToFileURL } from "url";
|
|
789
|
+
|
|
790
|
+
// src/integration-validation.ts
|
|
791
|
+
function validateIntegrations(cfg, path, defaultServices) {
|
|
792
|
+
if (cfg.integrations === void 0) return;
|
|
793
|
+
if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
|
|
794
|
+
const ids = /* @__PURE__ */ new Set();
|
|
795
|
+
for (const [index, integration] of cfg.integrations.entries()) {
|
|
796
|
+
const at = `${path}: integrations[${index}]`;
|
|
797
|
+
if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
|
|
798
|
+
if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
|
|
799
|
+
if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
|
|
800
|
+
ids.add(integration.id);
|
|
801
|
+
if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
|
|
802
|
+
if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
|
|
803
|
+
if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
|
|
804
|
+
throw new Error(`${at}.schema must contain an entities object`);
|
|
805
|
+
}
|
|
806
|
+
if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
|
|
807
|
+
validateSeeds(integration, at);
|
|
808
|
+
validateProbes(integration, at);
|
|
809
|
+
}
|
|
810
|
+
const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
|
|
811
|
+
const services = unique(cfg.services?.length ? cfg.services : defaultServices);
|
|
812
|
+
if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
|
|
813
|
+
}
|
|
814
|
+
function validateSeeds(integration, at) {
|
|
815
|
+
if (integration.seeds === void 0) return;
|
|
816
|
+
if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
|
|
817
|
+
const ids = /* @__PURE__ */ new Set();
|
|
818
|
+
for (const [index, seed] of integration.seeds.entries()) {
|
|
819
|
+
const sat = `${at}.seeds[${index}]`;
|
|
820
|
+
if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
|
|
821
|
+
if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
|
|
822
|
+
ids.add(seed.id);
|
|
823
|
+
if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
|
|
824
|
+
throw new Error(`${sat}.key requires string attr and value`);
|
|
825
|
+
}
|
|
826
|
+
if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
|
|
827
|
+
if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
|
|
828
|
+
throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
|
|
829
|
+
}
|
|
709
830
|
}
|
|
710
|
-
out.log("odla-ai responsibility boundary\n");
|
|
711
|
-
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
712
|
-
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
713
|
-
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
714
|
-
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
715
831
|
}
|
|
716
|
-
function
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
832
|
+
function validateProbes(integration, at) {
|
|
833
|
+
if (integration.probes === void 0) return;
|
|
834
|
+
if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
|
|
835
|
+
for (const [index, probe] of integration.probes.entries()) {
|
|
836
|
+
const pat = `${at}.probes[${index}]`;
|
|
837
|
+
if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
|
|
838
|
+
if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
|
|
839
|
+
throw new Error(`${pat}.expectedStatus must be an HTTP status`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function isRecord4(value) {
|
|
844
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
845
|
+
}
|
|
846
|
+
function safeText(value, max) {
|
|
847
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
848
|
+
}
|
|
849
|
+
function safeProbePath(value) {
|
|
850
|
+
return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
|
|
851
|
+
}
|
|
852
|
+
function validId(value) {
|
|
853
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
854
|
+
}
|
|
855
|
+
function unique(values) {
|
|
856
|
+
return [...new Set(values.filter(Boolean))];
|
|
720
857
|
}
|
|
721
858
|
|
|
722
859
|
// src/config.ts
|
|
723
|
-
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
724
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
725
|
-
import { pathToFileURL } from "url";
|
|
726
860
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
727
861
|
var DEFAULT_ENVS = ["dev"];
|
|
728
862
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
729
|
-
var
|
|
863
|
+
var GOOGLE_CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
|
730
864
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
731
865
|
const resolved = resolve2(configPath);
|
|
732
866
|
if (!existsSync3(resolved)) {
|
|
733
867
|
throw new Error(`config not found: ${configPath}. Run "odla-ai init" first or pass --config.`);
|
|
734
868
|
}
|
|
735
869
|
const raw = await loadConfigModule(resolved);
|
|
736
|
-
const rootDir =
|
|
870
|
+
const rootDir = dirname3(resolved);
|
|
737
871
|
validateRawConfig(raw, resolved);
|
|
738
872
|
const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
|
|
739
873
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
740
|
-
const envs =
|
|
741
|
-
const services =
|
|
874
|
+
const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
875
|
+
const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
742
876
|
validateCalendarConfig(raw, envs, services, resolved);
|
|
743
877
|
const local = {
|
|
744
878
|
tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
@@ -772,6 +906,8 @@ async function resolveDataExport(cfg, value, names) {
|
|
|
772
906
|
throw new Error(`${value} did not export ${names.join(", ")} or default`);
|
|
773
907
|
}
|
|
774
908
|
function buildPlan(cfg) {
|
|
909
|
+
const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
|
|
910
|
+
const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
|
|
775
911
|
return {
|
|
776
912
|
appId: cfg.app.id,
|
|
777
913
|
appName: cfg.app.name,
|
|
@@ -779,8 +915,9 @@ function buildPlan(cfg) {
|
|
|
779
915
|
dbEndpoint: cfg.dbEndpoint,
|
|
780
916
|
envs: cfg.envs,
|
|
781
917
|
services: cfg.services,
|
|
782
|
-
|
|
783
|
-
|
|
918
|
+
integrations: (cfg.integrations ?? []).map((integration) => integration.id),
|
|
919
|
+
hasSchema: !!cfg.db?.schema || integrationSchema,
|
|
920
|
+
hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
|
|
784
921
|
aiProvider: cfg.ai?.provider
|
|
785
922
|
};
|
|
786
923
|
}
|
|
@@ -789,16 +926,14 @@ function calendarServiceConfig(cfg, env) {
|
|
|
789
926
|
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
790
927
|
const google = cfg.calendar?.google;
|
|
791
928
|
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
929
|
+
const availability = unique2(
|
|
930
|
+
(google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
|
|
931
|
+
);
|
|
792
932
|
return {
|
|
793
933
|
provider: "google",
|
|
794
|
-
access: "
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
organizerSelf: google.match?.organizerSelf ?? true,
|
|
798
|
-
requireAttendees: google.match?.requireAttendees ?? true,
|
|
799
|
-
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
800
|
-
},
|
|
801
|
-
attendeePolicy: google.attendeePolicy ?? "full"
|
|
934
|
+
access: "book",
|
|
935
|
+
bookingCalendarId: google.bookingCalendar?.[env]?.trim() ?? availability[0],
|
|
936
|
+
availabilityCalendars: availability
|
|
802
937
|
};
|
|
803
938
|
}
|
|
804
939
|
function calendarBookingPageUrl(cfg, env) {
|
|
@@ -827,15 +962,16 @@ function validateRawConfig(raw, path) {
|
|
|
827
962
|
if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
|
|
828
963
|
const cfg = raw;
|
|
829
964
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
830
|
-
if (!
|
|
965
|
+
if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
831
966
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
832
967
|
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
833
968
|
throw new Error(`${path}: envs must be an array of names`);
|
|
834
969
|
}
|
|
835
|
-
if (cfg.envs?.some((env) => !
|
|
970
|
+
if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
836
971
|
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
837
972
|
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
838
973
|
}
|
|
974
|
+
validateIntegrations(cfg, path, DEFAULT_SERVICES);
|
|
839
975
|
}
|
|
840
976
|
function validateCalendarConfig(cfg, envs, services, path) {
|
|
841
977
|
const enabled = services.includes("calendar");
|
|
@@ -843,28 +979,47 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
843
979
|
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
844
980
|
return;
|
|
845
981
|
}
|
|
846
|
-
if (!
|
|
982
|
+
if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
847
983
|
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
848
|
-
if (!
|
|
984
|
+
if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
849
985
|
const google = cfg.calendar.google;
|
|
850
|
-
assertOnly(
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
986
|
+
assertOnly(
|
|
987
|
+
google,
|
|
988
|
+
["availabilityCalendars", "calendars", "bookingCalendar", "bookingPageUrl"],
|
|
989
|
+
`${path}: calendar.google`
|
|
990
|
+
);
|
|
991
|
+
const availabilityKey = google.availabilityCalendars !== void 0 ? "availabilityCalendars" : google.calendars !== void 0 ? "calendars" : null;
|
|
992
|
+
if (!availabilityKey || google.availabilityCalendars !== void 0 && google.calendars !== void 0) {
|
|
993
|
+
throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
|
|
994
|
+
}
|
|
995
|
+
const availability = google[availabilityKey];
|
|
996
|
+
if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
|
|
997
|
+
const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
|
|
998
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
|
|
854
999
|
for (const env of envs) {
|
|
855
|
-
const ids =
|
|
1000
|
+
const ids = availability[env];
|
|
856
1001
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
857
|
-
throw new Error(`${path}: calendar.google
|
|
1002
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must be a non-empty array`);
|
|
858
1003
|
}
|
|
859
1004
|
if (ids.length > 10) {
|
|
860
|
-
throw new Error(`${path}: calendar.google
|
|
1005
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
|
|
861
1006
|
}
|
|
862
|
-
if (ids.some((id) => !
|
|
863
|
-
throw new Error(`${path}: calendar.google
|
|
1007
|
+
if (ids.some((id) => !safeText2(id, 1024))) {
|
|
1008
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (google.bookingCalendar !== void 0) {
|
|
1012
|
+
if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
|
|
1013
|
+
const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
|
|
1014
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
|
|
1015
|
+
for (const [env, value] of Object.entries(google.bookingCalendar)) {
|
|
1016
|
+
if (!safeText2(value, 1024)) {
|
|
1017
|
+
throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
|
|
1018
|
+
}
|
|
864
1019
|
}
|
|
865
1020
|
}
|
|
866
1021
|
if (google.bookingPageUrl !== void 0) {
|
|
867
|
-
if (!
|
|
1022
|
+
if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
868
1023
|
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
869
1024
|
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
870
1025
|
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
@@ -873,31 +1028,16 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
873
1028
|
}
|
|
874
1029
|
}
|
|
875
1030
|
}
|
|
876
|
-
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
877
|
-
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
878
|
-
}
|
|
879
|
-
if (google.match !== void 0) {
|
|
880
|
-
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
881
|
-
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
882
|
-
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
883
|
-
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
884
|
-
}
|
|
885
|
-
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
886
|
-
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
887
|
-
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
1031
|
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
892
1032
|
}
|
|
893
1033
|
function assertOnly(value, allowed, label) {
|
|
894
1034
|
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
895
1035
|
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
896
1036
|
}
|
|
897
|
-
function
|
|
1037
|
+
function isRecord5(value) {
|
|
898
1038
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
899
1039
|
}
|
|
900
|
-
function
|
|
1040
|
+
function safeText2(value, max) {
|
|
901
1041
|
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
902
1042
|
}
|
|
903
1043
|
function safeHttpsUrl(value) {
|
|
@@ -909,7 +1049,7 @@ function safeHttpsUrl(value) {
|
|
|
909
1049
|
return false;
|
|
910
1050
|
}
|
|
911
1051
|
}
|
|
912
|
-
function
|
|
1052
|
+
function validId2(value) {
|
|
913
1053
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
914
1054
|
}
|
|
915
1055
|
async function loadConfigModule(path) {
|
|
@@ -922,10 +1062,66 @@ async function loadConfigModule(path) {
|
|
|
922
1062
|
function trimSlash(value) {
|
|
923
1063
|
return value.replace(/\/+$/, "");
|
|
924
1064
|
}
|
|
925
|
-
function
|
|
1065
|
+
function unique2(values) {
|
|
926
1066
|
return [...new Set(values.filter(Boolean))];
|
|
927
1067
|
}
|
|
928
1068
|
|
|
1069
|
+
// src/capabilities.ts
|
|
1070
|
+
var CAPABILITIES = {
|
|
1071
|
+
cli: [
|
|
1072
|
+
"start an email-bound device request without accepting a password or user session token",
|
|
1073
|
+
"register the app and enable configured services per environment",
|
|
1074
|
+
"issue, persist, and explicitly rotate configured service credentials",
|
|
1075
|
+
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
1076
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
1077
|
+
"compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
|
|
1078
|
+
"apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
|
|
1079
|
+
"validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
|
|
1080
|
+
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
1081
|
+
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
1082
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
1083
|
+
],
|
|
1084
|
+
agent: [
|
|
1085
|
+
"install and import the selected odla SDKs",
|
|
1086
|
+
"wrap the Worker with withObservability and choose useful telemetry",
|
|
1087
|
+
"install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
|
|
1088
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
1089
|
+
],
|
|
1090
|
+
human: [
|
|
1091
|
+
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
1092
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
1093
|
+
"consent to production changes with --yes",
|
|
1094
|
+
"request destructive credential rotation explicitly",
|
|
1095
|
+
"review application semantics, security findings, releases, and merges",
|
|
1096
|
+
"approve redacted-source disclosure before hosted security reasoning",
|
|
1097
|
+
"approve GitHub App repository access separately from redacted-snippet disclosure"
|
|
1098
|
+
],
|
|
1099
|
+
studio: [
|
|
1100
|
+
"view telemetry and environment state",
|
|
1101
|
+
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
1102
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
1103
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
1104
|
+
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
1105
|
+
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
1106
|
+
]
|
|
1107
|
+
};
|
|
1108
|
+
function printCapabilities(json = false, out = console) {
|
|
1109
|
+
if (json) {
|
|
1110
|
+
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
out.log("odla-ai responsibility boundary\n");
|
|
1114
|
+
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
1115
|
+
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
1116
|
+
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
1117
|
+
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
1118
|
+
}
|
|
1119
|
+
function printGroup(out, heading, items) {
|
|
1120
|
+
out.log(`${heading}:`);
|
|
1121
|
+
for (const item of items) out.log(` - ${item}`);
|
|
1122
|
+
out.log("");
|
|
1123
|
+
}
|
|
1124
|
+
|
|
929
1125
|
// src/redact.ts
|
|
930
1126
|
var REPLACEMENTS = [
|
|
931
1127
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -949,6 +1145,9 @@ function looksSecret(value) {
|
|
|
949
1145
|
var PLATFORM_NOT_READY_CODES = /* @__PURE__ */ new Set([
|
|
950
1146
|
"calendar_google_oauth_not_configured",
|
|
951
1147
|
"calendar_token_vault_not_configured",
|
|
1148
|
+
// Current spelling (booking-era key verifier) plus the retired mirror-era
|
|
1149
|
+
// ingress code, so the CLI stays resumable against either platform version.
|
|
1150
|
+
"calendar_db_verifier_not_configured",
|
|
952
1151
|
"calendar_db_ingress_not_configured"
|
|
953
1152
|
]);
|
|
954
1153
|
var CalendarRequestError = class extends Error {
|
|
@@ -971,8 +1170,6 @@ function isCalendarPlatformNotReady(error) {
|
|
|
971
1170
|
var CALENDAR_STATES = [
|
|
972
1171
|
"not_connected",
|
|
973
1172
|
"authorizing",
|
|
974
|
-
"needs_sync",
|
|
975
|
-
"initial_sync",
|
|
976
1173
|
"healthy",
|
|
977
1174
|
"degraded",
|
|
978
1175
|
"disconnected",
|
|
@@ -1020,9 +1217,6 @@ async function pollCalendarConnection(ctx, attemptId) {
|
|
|
1020
1217
|
ctx.env
|
|
1021
1218
|
);
|
|
1022
1219
|
}
|
|
1023
|
-
async function requestCalendarResync(ctx) {
|
|
1024
|
-
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1025
|
-
}
|
|
1026
1220
|
async function requestCalendarDisconnect(ctx) {
|
|
1027
1221
|
return parseCalendarStatus(
|
|
1028
1222
|
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
@@ -1033,10 +1227,8 @@ function parseCalendarStatus(raw, env) {
|
|
|
1033
1227
|
const outer = wrapped(raw, "calendar");
|
|
1034
1228
|
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1035
1229
|
const connection = record(value.connection) ?? {};
|
|
1036
|
-
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1037
1230
|
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1038
1231
|
const googleConfig = record(config.google) ?? config;
|
|
1039
|
-
const watches = record(value.watches) ?? {};
|
|
1040
1232
|
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1041
1233
|
if (!stateValue) {
|
|
1042
1234
|
throw new Error("calendar status returned an invalid connection state");
|
|
@@ -1046,31 +1238,29 @@ function parseCalendarStatus(raw, env) {
|
|
|
1046
1238
|
throw new Error("calendar status returned an unsupported provider");
|
|
1047
1239
|
}
|
|
1048
1240
|
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1049
|
-
if (accessValue !== void 0 && accessValue !== "
|
|
1050
|
-
|
|
1051
|
-
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1052
|
-
throw new Error("calendar status returned an invalid attendee policy");
|
|
1241
|
+
if (accessValue !== void 0 && accessValue !== "book" && accessValue !== "read") {
|
|
1242
|
+
throw new Error("calendar status returned unsupported access");
|
|
1053
1243
|
}
|
|
1054
1244
|
const errorValue = record(value.error) ?? record(connection.error);
|
|
1055
1245
|
const errorCode = textField(value.lastErrorCode, 128);
|
|
1056
1246
|
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1247
|
+
const connected = typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["healthy", "degraded"].includes(stateValue);
|
|
1248
|
+
const bookingCalendarId = textField(value.bookingCalendarId ?? config.bookingCalendarId, 1024);
|
|
1057
1249
|
return {
|
|
1058
1250
|
env,
|
|
1059
1251
|
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1060
|
-
connected
|
|
1252
|
+
connected,
|
|
1253
|
+
writable: typeof value.writable === "boolean" ? value.writable : stateValue === "healthy",
|
|
1061
1254
|
provider: providerValue === "google" ? "google" : null,
|
|
1062
1255
|
status: stateValue,
|
|
1063
|
-
...accessValue
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1256
|
+
...accessValue !== void 0 ? { access: "book" } : {},
|
|
1257
|
+
...bookingCalendarId ? { bookingCalendarId } : {},
|
|
1258
|
+
calendars: calendarIds(
|
|
1259
|
+
value.availabilityCalendars ?? config.availabilityCalendars ?? value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars
|
|
1260
|
+
),
|
|
1067
1261
|
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1068
1262
|
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1069
1263
|
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1070
|
-
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1071
|
-
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1072
|
-
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1073
|
-
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1074
1264
|
...errorValue || errorCode ? { error: {
|
|
1075
1265
|
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1076
1266
|
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
@@ -1085,7 +1275,9 @@ function calendarState(value) {
|
|
|
1085
1275
|
disabled: "not_connected",
|
|
1086
1276
|
disconnected: "disconnected",
|
|
1087
1277
|
connecting: "authorizing",
|
|
1088
|
-
syncing: "
|
|
1278
|
+
syncing: "healthy",
|
|
1279
|
+
needs_sync: "healthy",
|
|
1280
|
+
initial_sync: "healthy",
|
|
1089
1281
|
ready: "healthy",
|
|
1090
1282
|
error: "failed"
|
|
1091
1283
|
};
|
|
@@ -1153,13 +1345,6 @@ function optionalText(key, value, max) {
|
|
|
1153
1345
|
const parsed = textField(value, max);
|
|
1154
1346
|
return parsed ? { [key]: parsed } : {};
|
|
1155
1347
|
}
|
|
1156
|
-
function optionalNumber(key, value) {
|
|
1157
|
-
const parsed = timestamp3(value);
|
|
1158
|
-
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1159
|
-
}
|
|
1160
|
-
function optionalInteger(key, value) {
|
|
1161
|
-
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1162
|
-
}
|
|
1163
1348
|
function optionalNullableUrl(key, value) {
|
|
1164
1349
|
if (value === null) return { [key]: null };
|
|
1165
1350
|
const parsed = textField(value, 4096);
|
|
@@ -1211,7 +1396,7 @@ async function calendarConnect(options) {
|
|
|
1211
1396
|
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1212
1397
|
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1213
1398
|
const connectOptions = connectionOptions(options, out);
|
|
1214
|
-
return await continueConnectedCalendar(ctx, applied, connectOptions
|
|
1399
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1215
1400
|
}
|
|
1216
1401
|
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1217
1402
|
if (bookingPageUrl === void 0) return null;
|
|
@@ -1219,25 +1404,18 @@ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
|
1219
1404
|
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1220
1405
|
return status;
|
|
1221
1406
|
}
|
|
1222
|
-
async function calendarResync(options) {
|
|
1223
|
-
const { ctx, out } = await lifecycleContext(options);
|
|
1224
|
-
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1225
|
-
const status = await requestCalendarResync(ctx);
|
|
1226
|
-
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1227
|
-
return status;
|
|
1228
|
-
}
|
|
1229
1407
|
async function calendarDisconnect(options) {
|
|
1230
1408
|
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1231
1409
|
const { ctx, out } = await lifecycleContext(options);
|
|
1232
1410
|
const status = await requestCalendarDisconnect(ctx);
|
|
1233
|
-
out.log(`${ctx.env}: calendar disconnected;
|
|
1411
|
+
out.log(`${ctx.env}: calendar disconnected; no calendar data was stored`);
|
|
1234
1412
|
return status;
|
|
1235
1413
|
}
|
|
1236
1414
|
async function ensureCalendarConnected(ctx, options) {
|
|
1237
1415
|
const out = options.stdout ?? console;
|
|
1238
1416
|
const current = await readCalendarStatus(ctx);
|
|
1239
1417
|
const connectOptions = connectionOptions(options, out);
|
|
1240
|
-
return await continueConnectedCalendar(ctx, current, connectOptions
|
|
1418
|
+
return await continueConnectedCalendar(ctx, current, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1241
1419
|
}
|
|
1242
1420
|
async function lifecycleContext(options) {
|
|
1243
1421
|
const cfg = await loadProjectConfig(options.configPath);
|
|
@@ -1273,25 +1451,20 @@ function connectionOptions(options, out) {
|
|
|
1273
1451
|
signal: options.signal
|
|
1274
1452
|
};
|
|
1275
1453
|
}
|
|
1276
|
-
async function continueConnectedCalendar(ctx, current, options
|
|
1277
|
-
if (current.status === "healthy"
|
|
1278
|
-
options.out.log(`${ctx.env}: calendar
|
|
1454
|
+
async function continueConnectedCalendar(ctx, current, options) {
|
|
1455
|
+
if (current.status === "healthy") {
|
|
1456
|
+
options.out.log(`${ctx.env}: calendar already connected (healthy)`);
|
|
1279
1457
|
return current;
|
|
1280
1458
|
}
|
|
1281
|
-
if (current.status === "degraded")
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1285
|
-
const synced = await requestCalendarResync(ctx);
|
|
1286
|
-
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1287
|
-
return synced;
|
|
1459
|
+
if (current.status === "degraded") {
|
|
1460
|
+
options.out.log(`${ctx.env}: calendar grant predates booking scopes \u2014 Google re-consent required`);
|
|
1461
|
+
return null;
|
|
1288
1462
|
}
|
|
1289
1463
|
if (current.status === "failed" && current.connected) {
|
|
1290
1464
|
const reason = current.error?.message ?? current.error?.code;
|
|
1291
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1465
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1292
1466
|
}
|
|
1293
|
-
|
|
1294
|
-
if (!waitingForExisting) return null;
|
|
1467
|
+
if (current.status !== "authorizing") return null;
|
|
1295
1468
|
const now = options.now ?? Date.now;
|
|
1296
1469
|
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1297
1470
|
const wait = options.wait ?? waitForCalendarPoll;
|
|
@@ -1303,17 +1476,16 @@ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
|
1303
1476
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1304
1477
|
prior = status.status;
|
|
1305
1478
|
}
|
|
1306
|
-
if (status.status === "healthy"
|
|
1479
|
+
if (status.status === "healthy") return status;
|
|
1307
1480
|
if (status.status === "degraded") return null;
|
|
1308
|
-
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1309
1481
|
if (status.status === "failed" && status.connected) {
|
|
1310
1482
|
const reason = status.error?.message ?? status.error?.code;
|
|
1311
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1483
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1312
1484
|
}
|
|
1313
|
-
if (status.status !== "authorizing"
|
|
1485
|
+
if (status.status !== "authorizing") return null;
|
|
1314
1486
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1315
1487
|
}
|
|
1316
|
-
throw new Error('Existing Google Calendar authorization
|
|
1488
|
+
throw new Error('Existing Google Calendar authorization timed out; run "odla-ai calendar status" and retry');
|
|
1317
1489
|
}
|
|
1318
1490
|
async function connectWithContext(ctx, options) {
|
|
1319
1491
|
const attempt = await startCalendarConnection(ctx);
|
|
@@ -1335,14 +1507,17 @@ async function connectWithContext(ctx, options) {
|
|
|
1335
1507
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1336
1508
|
prior = status.status;
|
|
1337
1509
|
}
|
|
1338
|
-
if (status.status === "healthy"
|
|
1510
|
+
if (status.status === "healthy") return status;
|
|
1511
|
+
if (status.status === "degraded") {
|
|
1512
|
+
throw new Error("calendar connected without booking scopes; re-run connect and grant calendar access");
|
|
1513
|
+
}
|
|
1339
1514
|
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1340
1515
|
const reason = status.error?.message ?? status.error?.code;
|
|
1341
1516
|
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1342
1517
|
}
|
|
1343
1518
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1344
1519
|
}
|
|
1345
|
-
throw new Error('Google Calendar consent
|
|
1520
|
+
throw new Error('Google Calendar consent timed out; run "odla-ai calendar status" and retry connect');
|
|
1346
1521
|
}
|
|
1347
1522
|
function trustedCalendarConsentUrl(platform, value) {
|
|
1348
1523
|
const origin = platformAudience(platform);
|
|
@@ -1372,26 +1547,22 @@ function printStatus(status, json, out) {
|
|
|
1372
1547
|
return;
|
|
1373
1548
|
}
|
|
1374
1549
|
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1375
|
-
out.log(`
|
|
1376
|
-
out.log(`
|
|
1377
|
-
|
|
1378
|
-
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1379
|
-
}
|
|
1550
|
+
out.log(` bookable: ${status.writable ? "yes" : status.connected ? "no \u2014 reconnect to grant booking scopes" : "no \u2014 not connected"}`);
|
|
1551
|
+
if (status.bookingCalendarId) out.log(` booking calendar: ${status.bookingCalendarId}`);
|
|
1552
|
+
out.log(` availability calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1380
1553
|
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1381
|
-
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1382
|
-
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1383
1554
|
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1384
1555
|
}
|
|
1385
1556
|
|
|
1386
1557
|
// src/doctor-checks.ts
|
|
1387
1558
|
import { execFileSync } from "child_process";
|
|
1388
1559
|
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
1389
|
-
import { join as
|
|
1560
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
1390
1561
|
|
|
1391
1562
|
// src/wrangler.ts
|
|
1392
1563
|
import { spawn as spawn2 } from "child_process";
|
|
1393
1564
|
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
1394
|
-
import { join } from "path";
|
|
1565
|
+
import { join as join2 } from "path";
|
|
1395
1566
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
1396
1567
|
const child = spawn2(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
1397
1568
|
let stdout = "";
|
|
@@ -1405,7 +1576,7 @@ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) =>
|
|
|
1405
1576
|
var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
1406
1577
|
function findWranglerConfig(rootDir) {
|
|
1407
1578
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
1408
|
-
const path =
|
|
1579
|
+
const path = join2(rootDir, name);
|
|
1409
1580
|
if (existsSync4(path)) return path;
|
|
1410
1581
|
}
|
|
1411
1582
|
return null;
|
|
@@ -1514,7 +1685,7 @@ function wranglerWarnings(rootDir) {
|
|
|
1514
1685
|
const dir = resolve3(rootDir, assets.directory);
|
|
1515
1686
|
if (dir === resolve3(rootDir)) {
|
|
1516
1687
|
warnings.push(`${label}assets.directory is the project root \u2014 point it at a dedicated build dir (wrangler dev fails with "spawn EBADF")`);
|
|
1517
|
-
} else if (existsSync5(
|
|
1688
|
+
} else if (existsSync5(join3(dir, "node_modules"))) {
|
|
1518
1689
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
1519
1690
|
}
|
|
1520
1691
|
}
|
|
@@ -1579,32 +1750,136 @@ function calendarProjectWarnings(rootDir) {
|
|
|
1579
1750
|
}
|
|
1580
1751
|
function readPackageJson(rootDir) {
|
|
1581
1752
|
try {
|
|
1582
|
-
return JSON.parse(readFileSync4(
|
|
1753
|
+
return JSON.parse(readFileSync4(join3(rootDir, "package.json"), "utf8"));
|
|
1583
1754
|
} catch {
|
|
1584
1755
|
return null;
|
|
1585
1756
|
}
|
|
1586
1757
|
}
|
|
1587
1758
|
|
|
1759
|
+
// src/integrations.ts
|
|
1760
|
+
import { isDeepStrictEqual } from "util";
|
|
1761
|
+
async function resolveDatabaseConfig(cfg, options = {}) {
|
|
1762
|
+
const integrations = cfg.integrations ?? [];
|
|
1763
|
+
if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
|
|
1764
|
+
const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
1765
|
+
const schema = mergeSchemas(authoredSchema, integrations);
|
|
1766
|
+
if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
|
|
1767
|
+
const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
|
|
1768
|
+
const explicitRules = mergeRules(configuredRules, integrations);
|
|
1769
|
+
const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
|
|
1770
|
+
return { schema, rules, integrations };
|
|
1771
|
+
}
|
|
1772
|
+
function integrationWarnings(integrations, schema, rules) {
|
|
1773
|
+
const warnings = [];
|
|
1774
|
+
const entities = new Set(serializedEntities(schema));
|
|
1775
|
+
for (const integration of integrations) {
|
|
1776
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {});
|
|
1777
|
+
for (const ns of namespaces) {
|
|
1778
|
+
if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
|
|
1779
|
+
const rule = rules?.[ns];
|
|
1780
|
+
if (!rule) {
|
|
1781
|
+
warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
for (const action of ["view", "create", "update", "delete"]) {
|
|
1785
|
+
if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
for (const seed of integration.seeds ?? []) {
|
|
1789
|
+
if (!entities.has(seed.ns)) {
|
|
1790
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
1791
|
+
} else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
1792
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return warnings;
|
|
1797
|
+
}
|
|
1798
|
+
function mergeSchemas(base, integrations) {
|
|
1799
|
+
const fragments = integrations.filter((integration) => integration.schema);
|
|
1800
|
+
if (fragments.length === 0) return base;
|
|
1801
|
+
const merged = normalizeSchema(base);
|
|
1802
|
+
for (const integration of fragments) {
|
|
1803
|
+
const fragment = normalizeSchema(integration.schema);
|
|
1804
|
+
mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
|
|
1805
|
+
mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
|
|
1806
|
+
}
|
|
1807
|
+
return merged;
|
|
1808
|
+
}
|
|
1809
|
+
function mergeRules(base, integrations) {
|
|
1810
|
+
const fragments = integrations.filter((integration) => integration.rules);
|
|
1811
|
+
if (fragments.length === 0) return base;
|
|
1812
|
+
const merged = { ...base ?? {} };
|
|
1813
|
+
for (const integration of fragments) {
|
|
1814
|
+
mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
|
|
1815
|
+
}
|
|
1816
|
+
return merged;
|
|
1817
|
+
}
|
|
1818
|
+
function assertSeedContracts(integrations, schema) {
|
|
1819
|
+
const entities = new Set(serializedEntities(schema));
|
|
1820
|
+
for (const integration of integrations) {
|
|
1821
|
+
for (const seed of integration.seeds ?? []) {
|
|
1822
|
+
if (!entities.has(seed.ns)) {
|
|
1823
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
1824
|
+
}
|
|
1825
|
+
if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
1826
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
function isUniqueAttr(schema, ns, attr) {
|
|
1832
|
+
if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
|
|
1833
|
+
const entity = schema.entities[ns];
|
|
1834
|
+
if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
|
|
1835
|
+
const definition = entity.attrs[attr];
|
|
1836
|
+
return isRecord6(definition) && definition.unique === true;
|
|
1837
|
+
}
|
|
1838
|
+
function normalizeSchema(value) {
|
|
1839
|
+
if (value === void 0 || value === null) return { entities: {}, links: {} };
|
|
1840
|
+
if (!isRecord6(value) || !isRecord6(value.entities)) {
|
|
1841
|
+
throw new Error("db schema must be a serialized schema object with an entities map");
|
|
1842
|
+
}
|
|
1843
|
+
if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
|
|
1844
|
+
return {
|
|
1845
|
+
entities: { ...value.entities },
|
|
1846
|
+
links: { ...value.links ?? {} }
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
function mergeMap(target, fragment, label) {
|
|
1850
|
+
for (const [name, value] of Object.entries(fragment)) {
|
|
1851
|
+
if (Object.hasOwn(target, name) && !isDeepStrictEqual(target[name], value)) {
|
|
1852
|
+
throw new Error(`${label} "${name}" conflicts with an existing definition`);
|
|
1853
|
+
}
|
|
1854
|
+
target[name] = value;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
function isRecord6(value) {
|
|
1858
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1588
1861
|
// src/doctor.ts
|
|
1589
1862
|
async function doctor(options) {
|
|
1590
1863
|
const out = options.stdout ?? console;
|
|
1591
1864
|
const cfg = await loadProjectConfig(options.configPath);
|
|
1592
1865
|
const plan = buildPlan(cfg);
|
|
1593
|
-
const
|
|
1594
|
-
const
|
|
1595
|
-
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
1596
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
1866
|
+
const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
|
|
1867
|
+
const { schema, rules } = database;
|
|
1597
1868
|
const entities = serializedEntities(schema);
|
|
1598
1869
|
out.log(`config: ${cfg.configPath}`);
|
|
1599
1870
|
out.log(`app: ${plan.appName} (${plan.appId})`);
|
|
1600
1871
|
out.log(`envs: ${plan.envs.join(", ")}`);
|
|
1601
1872
|
out.log(`svc: ${plan.services.join(", ")}`);
|
|
1873
|
+
out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
1602
1874
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1603
1875
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1604
1876
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1605
1877
|
if (cfg.services.includes("calendar")) {
|
|
1606
|
-
const calendar = cfg.envs.map((env) =>
|
|
1607
|
-
|
|
1878
|
+
const calendar = cfg.envs.map((env) => {
|
|
1879
|
+
const resolved = calendarServiceConfig(cfg, env);
|
|
1880
|
+
return `${env}:${resolved.bookingCalendarId}+${resolved.availabilityCalendars.length}`;
|
|
1881
|
+
}).join(", ");
|
|
1882
|
+
out.log(`calendar: google/booking (${calendar})`);
|
|
1608
1883
|
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1609
1884
|
out.log(`booking pages: ${pages}`);
|
|
1610
1885
|
} else {
|
|
@@ -1617,6 +1892,7 @@ async function doctor(options) {
|
|
|
1617
1892
|
if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
|
|
1618
1893
|
}
|
|
1619
1894
|
}
|
|
1895
|
+
warnings.push(...integrationWarnings(database.integrations, schema, rules));
|
|
1620
1896
|
if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
|
|
1621
1897
|
if (cfg.auth?.clerk) {
|
|
1622
1898
|
for (const [env, value] of Object.entries(cfg.auth.clerk)) {
|
|
@@ -1657,7 +1933,7 @@ async function doctor(options) {
|
|
|
1657
1933
|
|
|
1658
1934
|
// src/init.ts
|
|
1659
1935
|
import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1660
|
-
import { dirname as
|
|
1936
|
+
import { dirname as dirname4, resolve as resolve4 } from "path";
|
|
1661
1937
|
function initProject(options) {
|
|
1662
1938
|
const out = options.stdout ?? console;
|
|
1663
1939
|
const rootDir = resolve4(options.rootDir ?? process.cwd());
|
|
@@ -1672,7 +1948,7 @@ function initProject(options) {
|
|
|
1672
1948
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1673
1949
|
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1674
1950
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1675
|
-
mkdirSync2(
|
|
1951
|
+
mkdirSync2(dirname4(configPath), { recursive: true });
|
|
1676
1952
|
mkdirSync2(resolve4(rootDir, "src/odla"), { recursive: true });
|
|
1677
1953
|
mkdirSync2(resolve4(rootDir, ".odla"), { recursive: true });
|
|
1678
1954
|
writeFileSync2(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
|
|
@@ -1690,11 +1966,11 @@ function writeIfMissing(path, text) {
|
|
|
1690
1966
|
function configTemplate(input) {
|
|
1691
1967
|
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1692
1968
|
google: {
|
|
1693
|
-
|
|
1969
|
+
// Calendars consulted for availability; bookings land on bookingCalendar
|
|
1970
|
+
// (defaults to the first availability calendar). Google consent happens
|
|
1971
|
+
// in the browser during provision; bookings write live through odla.ai.
|
|
1972
|
+
availabilityCalendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1694
1973
|
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1695
|
-
match: { organizerSelf: true, requireAttendees: true },
|
|
1696
|
-
attendeePolicy: "full",
|
|
1697
|
-
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1698
1974
|
},
|
|
1699
1975
|
},
|
|
1700
1976
|
` : "";
|
|
@@ -1707,6 +1983,9 @@ function configTemplate(input) {
|
|
|
1707
1983
|
},
|
|
1708
1984
|
envs: ${JSON.stringify(input.envs)},
|
|
1709
1985
|
services: ${JSON.stringify(input.services)},
|
|
1986
|
+
// App capabilities are npm modules, not hosted services. Import their
|
|
1987
|
+
// data-only descriptor and add it here (for example createCrmIntegration()).
|
|
1988
|
+
integrations: [],
|
|
1710
1989
|
db: {
|
|
1711
1990
|
schema: "./src/odla/schema.mjs",
|
|
1712
1991
|
rules: "./src/odla/rules.mjs",
|
|
@@ -1857,8 +2136,60 @@ function assertWranglerConfig(cfg) {
|
|
|
1857
2136
|
|
|
1858
2137
|
// src/provision.ts
|
|
1859
2138
|
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
1860
|
-
import {
|
|
1861
|
-
import
|
|
2139
|
+
import { putSecret } from "@odla-ai/ai";
|
|
2140
|
+
import process8 from "process";
|
|
2141
|
+
|
|
2142
|
+
// src/integration-provision.ts
|
|
2143
|
+
import { uuidv7 } from "@odla-ai/db";
|
|
2144
|
+
async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
|
|
2145
|
+
const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
|
|
2146
|
+
for (const integration of integrations) {
|
|
2147
|
+
for (const seed of integration.seeds ?? []) {
|
|
2148
|
+
const payload = await postJson(doFetch, `${base}/query`, dbKey, {
|
|
2149
|
+
query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
|
|
2150
|
+
});
|
|
2151
|
+
const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
|
|
2152
|
+
if (!Array.isArray(rows)) {
|
|
2153
|
+
throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
|
|
2154
|
+
}
|
|
2155
|
+
if (rows.length > 0) {
|
|
2156
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
await postJson(doFetch, `${base}/transact`, dbKey, {
|
|
2160
|
+
mutationId: `integration:${integration.id}:seed:${seed.id}`,
|
|
2161
|
+
ops: [{
|
|
2162
|
+
t: "update",
|
|
2163
|
+
ns: seed.ns,
|
|
2164
|
+
// A fresh id plus the declared-unique natural key is create-only: a
|
|
2165
|
+
// concurrent creator gets a unique violation instead of being overwritten.
|
|
2166
|
+
id: uuidv7(),
|
|
2167
|
+
attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
|
|
2168
|
+
}]
|
|
2169
|
+
});
|
|
2170
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
async function postJson(doFetch, url, bearer, body) {
|
|
2175
|
+
const res = await doFetch(url, {
|
|
2176
|
+
method: "POST",
|
|
2177
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2178
|
+
body: JSON.stringify(body)
|
|
2179
|
+
});
|
|
2180
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
|
|
2181
|
+
return res.json().catch(() => ({}));
|
|
2182
|
+
}
|
|
2183
|
+
function isRecord7(value) {
|
|
2184
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2185
|
+
}
|
|
2186
|
+
async function responseText(res) {
|
|
2187
|
+
try {
|
|
2188
|
+
return redactSecrets((await res.text()).slice(0, 500));
|
|
2189
|
+
} catch {
|
|
2190
|
+
return "";
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
1862
2193
|
|
|
1863
2194
|
// src/provision-credentials.ts
|
|
1864
2195
|
import { tenantIdFor } from "@odla-ai/apps";
|
|
@@ -1919,14 +2250,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1919
2250
|
appId: tenantId
|
|
1920
2251
|
})
|
|
1921
2252
|
});
|
|
1922
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2253
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
|
|
1923
2254
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1924
2255
|
method: "POST",
|
|
1925
2256
|
headers,
|
|
1926
2257
|
body: "{}"
|
|
1927
2258
|
});
|
|
1928
2259
|
}
|
|
1929
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2260
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
|
|
1930
2261
|
const body = await res.json();
|
|
1931
2262
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1932
2263
|
return body.key;
|
|
@@ -1942,12 +2273,12 @@ async function issueO11yToken(opts) {
|
|
|
1942
2273
|
`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`
|
|
1943
2274
|
);
|
|
1944
2275
|
}
|
|
1945
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2276
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
|
|
1946
2277
|
const body = await res.json();
|
|
1947
2278
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1948
2279
|
return body.token;
|
|
1949
2280
|
}
|
|
1950
|
-
async function
|
|
2281
|
+
async function safeText3(res) {
|
|
1951
2282
|
try {
|
|
1952
2283
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1953
2284
|
} catch {
|
|
@@ -1955,6 +2286,18 @@ async function safeText2(res) {
|
|
|
1955
2286
|
}
|
|
1956
2287
|
}
|
|
1957
2288
|
|
|
2289
|
+
// src/provision-helpers.ts
|
|
2290
|
+
import { DEFAULT_SECRET_NAMES } from "@odla-ai/ai";
|
|
2291
|
+
function serviceRank(service) {
|
|
2292
|
+
if (service === "db") return 0;
|
|
2293
|
+
if (service === "calendar") return 2;
|
|
2294
|
+
return 1;
|
|
2295
|
+
}
|
|
2296
|
+
function defaultSecretName(provider) {
|
|
2297
|
+
const names = DEFAULT_SECRET_NAMES;
|
|
2298
|
+
return names[provider] ?? `${provider}_api_key`;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
1958
2301
|
// src/provision.ts
|
|
1959
2302
|
async function provision(options) {
|
|
1960
2303
|
const out = options.stdout ?? console;
|
|
@@ -1973,24 +2316,28 @@ async function provision(options) {
|
|
|
1973
2316
|
out.log(` db: ${plan.dbEndpoint}`);
|
|
1974
2317
|
out.log(` envs: ${plan.envs.join(", ")}`);
|
|
1975
2318
|
out.log(` services: ${plan.services.join(", ")}`);
|
|
2319
|
+
out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
1976
2320
|
const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
|
|
1977
2321
|
if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
|
|
1978
2322
|
throw new Error(
|
|
1979
2323
|
`refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
|
|
1980
2324
|
);
|
|
1981
2325
|
}
|
|
1982
|
-
const
|
|
1983
|
-
const
|
|
1984
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
2326
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
2327
|
+
const { schema, rules } = database;
|
|
1985
2328
|
if (options.dryRun) {
|
|
1986
2329
|
out.log("dry run: no network calls or file writes");
|
|
1987
2330
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1988
2331
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
2332
|
+
for (const integration of database.integrations) {
|
|
2333
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
|
|
2334
|
+
out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
|
|
2335
|
+
}
|
|
1989
2336
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1990
2337
|
if (cfg.services.includes("calendar")) {
|
|
1991
2338
|
for (const env of cfg.envs) {
|
|
1992
2339
|
const calendar = calendarServiceConfig(cfg, env);
|
|
1993
|
-
out.log(` calendar.${env}: google/
|
|
2340
|
+
out.log(` calendar.${env}: google/book \u2192 ${calendar.bookingCalendarId}; availability ${calendar.availabilityCalendars.join(", ")} (${GOOGLE_CALENDAR_EVENTS_SCOPE})`);
|
|
1994
2341
|
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
1995
2342
|
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
1996
2343
|
}
|
|
@@ -2013,7 +2360,7 @@ async function provision(options) {
|
|
|
2013
2360
|
}
|
|
2014
2361
|
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
2015
2362
|
if (cfg.local.gitignore) {
|
|
2016
|
-
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2363
|
+
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, handshakeFile(cfg), cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2017
2364
|
}
|
|
2018
2365
|
if (options.pushSecrets) {
|
|
2019
2366
|
await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
|
|
@@ -2095,15 +2442,18 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2095
2442
|
}
|
|
2096
2443
|
}
|
|
2097
2444
|
if (schema && dbKey) {
|
|
2098
|
-
await
|
|
2445
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
|
|
2099
2446
|
out.log(`${env}: schema pushed`);
|
|
2100
2447
|
}
|
|
2101
2448
|
if (rules) {
|
|
2102
|
-
await
|
|
2449
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
|
|
2103
2450
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
2104
2451
|
}
|
|
2452
|
+
if (dbKey) {
|
|
2453
|
+
await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
|
|
2454
|
+
}
|
|
2105
2455
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
2106
|
-
const key =
|
|
2456
|
+
const key = process8.env[cfg.ai.keyEnv];
|
|
2107
2457
|
if (key) {
|
|
2108
2458
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
2109
2459
|
await putSecret({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -2142,11 +2492,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2142
2492
|
}
|
|
2143
2493
|
}
|
|
2144
2494
|
}
|
|
2145
|
-
function serviceRank(service) {
|
|
2146
|
-
if (service === "db") return 0;
|
|
2147
|
-
if (service === "calendar") return 2;
|
|
2148
|
-
return 1;
|
|
2149
|
-
}
|
|
2150
2495
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
2151
2496
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
2152
2497
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -2156,13 +2501,13 @@ async function readRegistryApp(cfg, token, doFetch) {
|
|
|
2156
2501
|
const json = await res.json();
|
|
2157
2502
|
return json.app ?? null;
|
|
2158
2503
|
}
|
|
2159
|
-
async function
|
|
2504
|
+
async function postJson2(doFetch, url, bearer, body) {
|
|
2160
2505
|
const res = await doFetch(url, {
|
|
2161
2506
|
method: "POST",
|
|
2162
2507
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2163
2508
|
body: JSON.stringify(body)
|
|
2164
2509
|
});
|
|
2165
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2510
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
|
|
2166
2511
|
}
|
|
2167
2512
|
function normalizeClerkConfig(value) {
|
|
2168
2513
|
if (!value) return null;
|
|
@@ -2175,11 +2520,7 @@ function normalizeClerkConfig(value) {
|
|
|
2175
2520
|
const publishableKey = envValue(cfg.publishableKey);
|
|
2176
2521
|
return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
|
|
2177
2522
|
}
|
|
2178
|
-
function
|
|
2179
|
-
const names = DEFAULT_SECRET_NAMES;
|
|
2180
|
-
return names[provider] ?? `${provider}_api_key`;
|
|
2181
|
-
}
|
|
2182
|
-
async function safeText3(res) {
|
|
2523
|
+
async function safeText4(res) {
|
|
2183
2524
|
try {
|
|
2184
2525
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
2185
2526
|
} catch {
|
|
@@ -2735,7 +3076,7 @@ function securityExecutionDigest(value) {
|
|
|
2735
3076
|
// src/skill.ts
|
|
2736
3077
|
import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2737
3078
|
import { homedir } from "os";
|
|
2738
|
-
import { dirname as
|
|
3079
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join4, relative as relative3, resolve as resolve6, sep as sep2 } from "path";
|
|
2739
3080
|
import { fileURLToPath } from "url";
|
|
2740
3081
|
|
|
2741
3082
|
// src/skill-adapters.ts
|
|
@@ -2811,48 +3152,48 @@ function installSkill(options = {}) {
|
|
|
2811
3152
|
plans.set(target, { target, content, boundary, managedMerge });
|
|
2812
3153
|
};
|
|
2813
3154
|
const planSkillTree = (targetDir2, boundary = root) => {
|
|
2814
|
-
for (const rel of files) plan(
|
|
3155
|
+
for (const rel of files) plan(join4(targetDir2, rel), readFileSync5(join4(sourceDir, rel), "utf8"), false, boundary);
|
|
2815
3156
|
};
|
|
2816
3157
|
let targetDir;
|
|
2817
3158
|
if (options.global) {
|
|
2818
|
-
const claudeRoot =
|
|
2819
|
-
const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ??
|
|
3159
|
+
const claudeRoot = join4(home, ".claude", "skills");
|
|
3160
|
+
const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ?? join4(home, ".codex"), "skills");
|
|
2820
3161
|
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
2821
3162
|
for (const harness of harnesses) {
|
|
2822
3163
|
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
2823
|
-
planSkillTree(skillRoot, harness === "claude" ? home :
|
|
3164
|
+
planSkillTree(skillRoot, harness === "claude" ? home : dirname5(dirname5(codexRoot)));
|
|
2824
3165
|
rememberTarget(harness, skillRoot);
|
|
2825
3166
|
}
|
|
2826
3167
|
} else {
|
|
2827
|
-
const sharedRoot =
|
|
3168
|
+
const sharedRoot = join4(root, ".agents", "skills");
|
|
2828
3169
|
planSkillTree(sharedRoot);
|
|
2829
|
-
const claudeRoot =
|
|
3170
|
+
const claudeRoot = join4(root, ".claude", "skills");
|
|
2830
3171
|
targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
|
|
2831
3172
|
for (const harness of harnesses) rememberTarget(harness, sharedRoot);
|
|
2832
3173
|
if (harnesses.includes("claude")) {
|
|
2833
3174
|
for (const skill of skillNames(files)) {
|
|
2834
|
-
const canonical = readFileSync5(
|
|
2835
|
-
plan(
|
|
3175
|
+
const canonical = readFileSync5(join4(sourceDir, skill, "SKILL.md"), "utf8");
|
|
3176
|
+
plan(join4(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
|
|
2836
3177
|
}
|
|
2837
3178
|
rememberTarget("claude", claudeRoot);
|
|
2838
3179
|
}
|
|
2839
3180
|
if (harnesses.includes("cursor")) {
|
|
2840
|
-
const cursorRule =
|
|
3181
|
+
const cursorRule = join4(root, ".cursor", "rules", "odla.mdc");
|
|
2841
3182
|
plan(cursorRule, CURSOR_RULE);
|
|
2842
3183
|
rememberTarget("cursor", cursorRule);
|
|
2843
3184
|
}
|
|
2844
3185
|
if (harnesses.includes("agents")) {
|
|
2845
|
-
const agentsFile =
|
|
3186
|
+
const agentsFile = join4(root, "AGENTS.md");
|
|
2846
3187
|
plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2847
3188
|
rememberTarget("agents", agentsFile);
|
|
2848
3189
|
}
|
|
2849
3190
|
if (harnesses.includes("copilot")) {
|
|
2850
|
-
const copilotFile =
|
|
3191
|
+
const copilotFile = join4(root, ".github", "copilot-instructions.md");
|
|
2851
3192
|
plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2852
3193
|
rememberTarget("copilot", copilotFile);
|
|
2853
3194
|
}
|
|
2854
3195
|
if (harnesses.includes("gemini")) {
|
|
2855
|
-
const geminiFile =
|
|
3196
|
+
const geminiFile = join4(root, "GEMINI.md");
|
|
2856
3197
|
plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2857
3198
|
rememberTarget("gemini", geminiFile);
|
|
2858
3199
|
}
|
|
@@ -2888,7 +3229,7 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
2888
3229
|
}
|
|
2889
3230
|
for (const file of plans.values()) {
|
|
2890
3231
|
if (!existsSync7(file.target) || readFileSync5(file.target, "utf8") !== file.content) {
|
|
2891
|
-
mkdirSync3(
|
|
3232
|
+
mkdirSync3(dirname5(file.target), { recursive: true });
|
|
2892
3233
|
writeFileSync3(file.target, file.content);
|
|
2893
3234
|
}
|
|
2894
3235
|
}
|
|
@@ -2959,7 +3300,7 @@ function symlinkedComponent(boundary, target) {
|
|
|
2959
3300
|
}
|
|
2960
3301
|
let current = boundary;
|
|
2961
3302
|
for (const part of rel.split(sep2).filter(Boolean)) {
|
|
2962
|
-
current =
|
|
3303
|
+
current = join4(current, part);
|
|
2963
3304
|
try {
|
|
2964
3305
|
if (lstatSync(current).isSymbolicLink()) return current;
|
|
2965
3306
|
} catch (error) {
|
|
@@ -2976,7 +3317,7 @@ function listFiles(dir) {
|
|
|
2976
3317
|
const results = [];
|
|
2977
3318
|
const walk = (current) => {
|
|
2978
3319
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
2979
|
-
const path =
|
|
3320
|
+
const path = join4(current, entry.name);
|
|
2980
3321
|
if (entry.isDirectory()) walk(path);
|
|
2981
3322
|
else results.push(relative3(dir, path));
|
|
2982
3323
|
}
|
|
@@ -3029,9 +3370,10 @@ async function smoke(options) {
|
|
|
3029
3370
|
);
|
|
3030
3371
|
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3031
3372
|
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3032
|
-
out.log(` calendar: ${status.status},
|
|
3373
|
+
out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
|
|
3033
3374
|
}
|
|
3034
|
-
const
|
|
3375
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
3376
|
+
const expectedSchema = database.schema;
|
|
3035
3377
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
3036
3378
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
3037
3379
|
const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
|
|
@@ -3043,7 +3385,7 @@ async function smoke(options) {
|
|
|
3043
3385
|
out.log(` schema: ${liveEntities.length} entities`);
|
|
3044
3386
|
const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
|
|
3045
3387
|
if (aggregateEntity) {
|
|
3046
|
-
const aggregate = await
|
|
3388
|
+
const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3047
3389
|
ns: aggregateEntity,
|
|
3048
3390
|
aggregate: { count: true }
|
|
3049
3391
|
});
|
|
@@ -3052,13 +3394,20 @@ async function smoke(options) {
|
|
|
3052
3394
|
} else {
|
|
3053
3395
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3054
3396
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3397
|
+
const probes = database.integrations.flatMap(
|
|
3398
|
+
(integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
|
|
3399
|
+
);
|
|
3400
|
+
if (probes.length) {
|
|
3401
|
+
const link = cfg.links?.[env];
|
|
3402
|
+
if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
|
|
3403
|
+
for (const { integration, probe } of probes) {
|
|
3404
|
+
const probeUrl = new URL(probe.path, link).toString();
|
|
3405
|
+
const res = await doFetch(probeUrl, { redirect: "manual" });
|
|
3406
|
+
if (res.status !== probe.expectedStatus) {
|
|
3407
|
+
throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
|
|
3408
|
+
}
|
|
3409
|
+
out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
|
|
3410
|
+
}
|
|
3062
3411
|
}
|
|
3063
3412
|
out.log("ok");
|
|
3064
3413
|
}
|
|
@@ -3067,29 +3416,26 @@ function assertCalendarHealthy(status, expected) {
|
|
|
3067
3416
|
if (status.status !== "healthy") {
|
|
3068
3417
|
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3069
3418
|
}
|
|
3070
|
-
if (status.
|
|
3071
|
-
const
|
|
3072
|
-
if (!
|
|
3073
|
-
const missing = expected.
|
|
3419
|
+
if (!status.writable) throw new Error('calendar grant does not cover booking writes; run "odla-ai calendar connect" to re-consent');
|
|
3420
|
+
const hasEventsScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_EVENTS_SCOPE);
|
|
3421
|
+
if (!hasEventsScope) throw new Error("calendar connection is missing calendar.events consent");
|
|
3422
|
+
const missing = expected.availabilityCalendars.filter((id) => !status.calendars.includes(id));
|
|
3074
3423
|
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3075
|
-
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3076
|
-
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3077
|
-
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3078
3424
|
}
|
|
3079
3425
|
async function getJson(doFetch, url, bearer) {
|
|
3080
3426
|
const res = await doFetch(url, {
|
|
3081
3427
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
3082
3428
|
});
|
|
3083
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3429
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
3084
3430
|
return res.json();
|
|
3085
3431
|
}
|
|
3086
|
-
async function
|
|
3432
|
+
async function postJson3(doFetch, url, bearer, body) {
|
|
3087
3433
|
const res = await doFetch(url, {
|
|
3088
3434
|
method: "POST",
|
|
3089
3435
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
3090
3436
|
body: JSON.stringify(body)
|
|
3091
3437
|
});
|
|
3092
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3438
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
3093
3439
|
return res.json();
|
|
3094
3440
|
}
|
|
3095
3441
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -3097,7 +3443,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
3097
3443
|
url.searchParams.set("env", env);
|
|
3098
3444
|
return url.toString();
|
|
3099
3445
|
}
|
|
3100
|
-
async function
|
|
3446
|
+
async function safeText5(res) {
|
|
3101
3447
|
try {
|
|
3102
3448
|
return (await res.text()).slice(0, 500);
|
|
3103
3449
|
} catch {
|
|
@@ -3241,6 +3587,127 @@ async function adminCommand(parsed) {
|
|
|
3241
3587
|
});
|
|
3242
3588
|
}
|
|
3243
3589
|
|
|
3590
|
+
// src/app-export.ts
|
|
3591
|
+
import { createWriteStream } from "fs";
|
|
3592
|
+
import { Readable } from "stream";
|
|
3593
|
+
import { pipeline } from "stream/promises";
|
|
3594
|
+
async function appExport(options) {
|
|
3595
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
3596
|
+
const out = options.stdout ?? console;
|
|
3597
|
+
const doFetch = options.fetch ?? fetch;
|
|
3598
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
3599
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
3600
|
+
const tenant = env === "prod" ? cfg.app.id : `${cfg.app.id}--${env}`;
|
|
3601
|
+
const token = await getDeveloperToken(
|
|
3602
|
+
cfg,
|
|
3603
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
3604
|
+
doFetch,
|
|
3605
|
+
out
|
|
3606
|
+
);
|
|
3607
|
+
const auth = { authorization: `Bearer ${token}` };
|
|
3608
|
+
const base = `${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenant)}`;
|
|
3609
|
+
if (options.fresh) {
|
|
3610
|
+
const res = await doFetch(`${base}/export`, { method: "POST", headers: auth });
|
|
3611
|
+
const body = await res.json().catch(() => ({}));
|
|
3612
|
+
if (!res.ok) throw new Error(`export failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? res.status}`);
|
|
3613
|
+
}
|
|
3614
|
+
const list = await doFetch(`${base}/backups`, { headers: auth });
|
|
3615
|
+
if (!list.ok) throw new Error(`couldn't list backups (${list.status})`);
|
|
3616
|
+
const { backups } = await list.json();
|
|
3617
|
+
const newest = backups[0];
|
|
3618
|
+
if (!newest) {
|
|
3619
|
+
throw new Error(
|
|
3620
|
+
`${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)`
|
|
3621
|
+
);
|
|
3622
|
+
}
|
|
3623
|
+
const download = await doFetch(`${base}/backups/${newest.id}/download`, { headers: auth });
|
|
3624
|
+
if (!download.ok || !download.body) throw new Error(`download failed (${download.status})`);
|
|
3625
|
+
const file = options.out ?? `${tenant}-${new Date(newest.created_at).toISOString().slice(0, 10)}-tx${newest.max_tx}.jsonl.gz`;
|
|
3626
|
+
await pipeline(Readable.fromWeb(download.body), createWriteStream(file));
|
|
3627
|
+
if (options.json) out.log(JSON.stringify({ file, backup: newest }, null, 2));
|
|
3628
|
+
else {
|
|
3629
|
+
out.log(`${tenant}: wrote ${file} (${newest.bytes} bytes, ${newest.kind} snapshot at tx ${newest.max_tx})`);
|
|
3630
|
+
out.log(`sha256 ${download.headers.get("x-odla-sha256") ?? newest.sha256}`);
|
|
3631
|
+
}
|
|
3632
|
+
return { file, backup: newest };
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
// src/app-lifecycle.ts
|
|
3636
|
+
async function lifecycleCall(action, options) {
|
|
3637
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
3638
|
+
const out = options.stdout ?? console;
|
|
3639
|
+
const doFetch = options.fetch ?? fetch;
|
|
3640
|
+
const token = await getDeveloperToken(
|
|
3641
|
+
cfg,
|
|
3642
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
3643
|
+
doFetch,
|
|
3644
|
+
out
|
|
3645
|
+
);
|
|
3646
|
+
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/${action}`, {
|
|
3647
|
+
method: "POST",
|
|
3648
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }
|
|
3649
|
+
});
|
|
3650
|
+
const body = await res.json().catch(() => ({}));
|
|
3651
|
+
if (!res.ok || !body.ok) {
|
|
3652
|
+
throw new Error(`${action} failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? `registry returned ${res.status}`}`);
|
|
3653
|
+
}
|
|
3654
|
+
return body;
|
|
3655
|
+
}
|
|
3656
|
+
async function appArchive(options) {
|
|
3657
|
+
if (options.yes !== true) {
|
|
3658
|
+
throw new Error(
|
|
3659
|
+
"app archive suspends EVERY environment: API keys stop working and all services refuse requests until restored. All data is retained. Pass --yes to proceed."
|
|
3660
|
+
);
|
|
3661
|
+
}
|
|
3662
|
+
const out = options.stdout ?? console;
|
|
3663
|
+
const body = await lifecycleCall("archive", options);
|
|
3664
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
3665
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already archived`);
|
|
3666
|
+
else out.log(`${body.app?.appId}: archived \u2014 data retained; run \`odla-ai app restore\` (or use Studio) to bring it back`);
|
|
3667
|
+
}
|
|
3668
|
+
async function appRestore(options) {
|
|
3669
|
+
const out = options.stdout ?? console;
|
|
3670
|
+
const body = await lifecycleCall("restore", options);
|
|
3671
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
3672
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already active`);
|
|
3673
|
+
else out.log(`${body.app?.appId}: restored \u2014 every service's data plane is live again`);
|
|
3674
|
+
}
|
|
3675
|
+
async function appCommand(parsed, dependencies = {}) {
|
|
3676
|
+
const sub = parsed.positionals[1];
|
|
3677
|
+
if (sub === "export") {
|
|
3678
|
+
assertArgs(parsed, ["config", "env", "token", "email", "fresh", "out", "json"], 2);
|
|
3679
|
+
await appExport({
|
|
3680
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3681
|
+
env: stringOpt(parsed.options.env),
|
|
3682
|
+
token: stringOpt(parsed.options.token),
|
|
3683
|
+
email: stringOpt(parsed.options.email),
|
|
3684
|
+
fresh: parsed.options.fresh === true,
|
|
3685
|
+
out: stringOpt(parsed.options.out),
|
|
3686
|
+
json: parsed.options.json === true,
|
|
3687
|
+
fetch: dependencies.fetch,
|
|
3688
|
+
stdout: dependencies.stdout
|
|
3689
|
+
});
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
if (sub !== "archive" && sub !== "restore") {
|
|
3693
|
+
throw new Error(
|
|
3694
|
+
`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.)`
|
|
3695
|
+
);
|
|
3696
|
+
}
|
|
3697
|
+
assertArgs(parsed, ["config", "token", "email", "yes", "json"], 2);
|
|
3698
|
+
const options = {
|
|
3699
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3700
|
+
token: stringOpt(parsed.options.token),
|
|
3701
|
+
email: stringOpt(parsed.options.email),
|
|
3702
|
+
yes: parsed.options.yes === true,
|
|
3703
|
+
json: parsed.options.json === true,
|
|
3704
|
+
fetch: dependencies.fetch,
|
|
3705
|
+
stdout: dependencies.stdout
|
|
3706
|
+
};
|
|
3707
|
+
if (sub === "archive") await appArchive(options);
|
|
3708
|
+
else await appRestore(options);
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3244
3711
|
// src/help.ts
|
|
3245
3712
|
import { readFileSync as readFileSync6 } from "fs";
|
|
3246
3713
|
function cliVersion() {
|
|
@@ -3257,8 +3724,10 @@ Usage:
|
|
|
3257
3724
|
odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
|
|
3258
3725
|
odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
|
|
3259
3726
|
odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
|
|
3260
|
-
odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
|
|
3261
3727
|
odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
|
|
3728
|
+
odla-ai app archive [--config odla.config.mjs] [--email <odla-account>] [--json] --yes
|
|
3729
|
+
odla-ai app restore [--config odla.config.mjs] [--email <odla-account>] [--json]
|
|
3730
|
+
odla-ai app export [--env dev] [--fresh] [--out <file>] [--email <odla-account>] [--json]
|
|
3262
3731
|
odla-ai capabilities [--json]
|
|
3263
3732
|
odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
|
|
3264
3733
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -3278,7 +3747,7 @@ Usage:
|
|
|
3278
3747
|
odla-ai security report <job-id> [--json]
|
|
3279
3748
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
3280
3749
|
odla-ai security run [target] --self --ack-redacted-source
|
|
3281
|
-
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
3750
|
+
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
3282
3751
|
odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
|
|
3283
3752
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
3284
3753
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
@@ -3290,12 +3759,19 @@ Commands:
|
|
|
3290
3759
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
3291
3760
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
3292
3761
|
doctor Validate and summarize the project config without network calls.
|
|
3293
|
-
calendar Inspect, connect,
|
|
3762
|
+
calendar Inspect, connect, or disconnect the live Google booking connection.
|
|
3763
|
+
app Archive (suspend, data retained), restore, or export the app.
|
|
3764
|
+
Archiving takes every environment's data plane down until restored;
|
|
3765
|
+
permanent deletion has NO CLI \u2014 it requires a signed-in owner in
|
|
3766
|
+
Studio, and no machine or agent credential can purge. "app export"
|
|
3767
|
+
downloads a portable gzipped-JSONL snapshot of one environment's
|
|
3768
|
+
database (--fresh takes a new snapshot first) \u2014 your data is
|
|
3769
|
+
always yours to take.
|
|
3294
3770
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
3295
3771
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
3296
3772
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
3297
|
-
provision Register services,
|
|
3298
|
-
smoke Verify
|
|
3773
|
+
provision Register services, compose integrations, persist credentials, optionally push secrets.
|
|
3774
|
+
smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
|
|
3299
3775
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
3300
3776
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
3301
3777
|
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
@@ -3312,6 +3788,12 @@ Safety:
|
|
|
3312
3788
|
Provision opens the approval page in your browser automatically whenever the
|
|
3313
3789
|
machine can show one, including agent-driven runs; only CI, SSH, and
|
|
3314
3790
|
display-less hosts skip it. Use --open to force or --no-open to suppress.
|
|
3791
|
+
Browser launch is best-effort: the printed approval URL is authoritative and
|
|
3792
|
+
agents must relay it to the human verbatim. A started handshake is persisted
|
|
3793
|
+
under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
|
|
3794
|
+
the same code. Outside an interactive terminal the wait is capped (90s by
|
|
3795
|
+
default, --wait <seconds> to change); a still-pending handshake then exits
|
|
3796
|
+
with code 75: relay the URL, wait for approval, and re-run to collect.
|
|
3315
3797
|
A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
|
|
3316
3798
|
The email is a non-secret identity hint: never provide a password or session
|
|
3317
3799
|
token. The matching account must already exist, be signed in, explicitly
|
|
@@ -3781,6 +4263,9 @@ async function securityStatus(parsed, dependencies) {
|
|
|
3781
4263
|
}
|
|
3782
4264
|
|
|
3783
4265
|
// src/cli.ts
|
|
4266
|
+
function exitCodeFor(err) {
|
|
4267
|
+
return err?.code === "handshake_pending" ? 75 : 1;
|
|
4268
|
+
}
|
|
3784
4269
|
async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
3785
4270
|
const parsed = parseArgv(argv);
|
|
3786
4271
|
const command = parsed.positionals[0] ?? "help";
|
|
@@ -3833,6 +4318,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3833
4318
|
await adminCommand(parsed);
|
|
3834
4319
|
return;
|
|
3835
4320
|
}
|
|
4321
|
+
if (command === "app") {
|
|
4322
|
+
await appCommand(parsed, dependencies);
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
3836
4325
|
if (command === "security") {
|
|
3837
4326
|
await securityCommand(parsed, dependencies);
|
|
3838
4327
|
return;
|
|
@@ -3929,6 +4418,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3929
4418
|
"token",
|
|
3930
4419
|
"email",
|
|
3931
4420
|
"open",
|
|
4421
|
+
"wait",
|
|
3932
4422
|
"yes"
|
|
3933
4423
|
], 1);
|
|
3934
4424
|
const writeDevVars2 = parsed.options["write-dev-vars"];
|
|
@@ -3943,6 +4433,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3943
4433
|
token: stringOpt(parsed.options.token),
|
|
3944
4434
|
email: stringOpt(parsed.options.email),
|
|
3945
4435
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
4436
|
+
wait: numberOpt(parsed.options.wait, "--wait"),
|
|
3946
4437
|
yes: parsed.options.yes === true,
|
|
3947
4438
|
fetch: dependencies.fetch,
|
|
3948
4439
|
openApprovalUrl: dependencies.openUrl,
|
|
@@ -3952,7 +4443,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3952
4443
|
}
|
|
3953
4444
|
async function calendarCommand(parsed, dependencies) {
|
|
3954
4445
|
const sub = parsed.positionals[1];
|
|
3955
|
-
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "
|
|
4446
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "disconnect") {
|
|
3956
4447
|
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3957
4448
|
}
|
|
3958
4449
|
assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
|
|
@@ -3973,7 +4464,6 @@ async function calendarCommand(parsed, dependencies) {
|
|
|
3973
4464
|
if (sub === "status") await calendarStatus(options);
|
|
3974
4465
|
else if (sub === "calendars") await calendarCalendars(options);
|
|
3975
4466
|
else if (sub === "connect") await calendarConnect(options);
|
|
3976
|
-
else if (sub === "resync") await calendarResync(options);
|
|
3977
4467
|
else await calendarDisconnect(options);
|
|
3978
4468
|
}
|
|
3979
4469
|
|
|
@@ -3981,16 +4471,15 @@ export {
|
|
|
3981
4471
|
getScopedPlatformToken,
|
|
3982
4472
|
SYSTEM_AI_PURPOSES,
|
|
3983
4473
|
adminAi,
|
|
3984
|
-
|
|
3985
|
-
printCapabilities,
|
|
3986
|
-
GOOGLE_CALENDAR_READ_SCOPE,
|
|
4474
|
+
GOOGLE_CALENDAR_EVENTS_SCOPE,
|
|
3987
4475
|
calendarServiceConfig,
|
|
3988
4476
|
calendarBookingPageUrl,
|
|
4477
|
+
CAPABILITIES,
|
|
4478
|
+
printCapabilities,
|
|
3989
4479
|
redactSecrets,
|
|
3990
4480
|
calendarStatus,
|
|
3991
4481
|
calendarCalendars,
|
|
3992
4482
|
calendarConnect,
|
|
3993
|
-
calendarResync,
|
|
3994
4483
|
calendarDisconnect,
|
|
3995
4484
|
doctor,
|
|
3996
4485
|
initProject,
|
|
@@ -4015,6 +4504,7 @@ export {
|
|
|
4015
4504
|
AGENT_HARNESSES,
|
|
4016
4505
|
installSkill,
|
|
4017
4506
|
smoke,
|
|
4507
|
+
exitCodeFor,
|
|
4018
4508
|
runCli
|
|
4019
4509
|
};
|
|
4020
|
-
//# sourceMappingURL=chunk-
|
|
4510
|
+
//# sourceMappingURL=chunk-I7XDJZB6.js.map
|