@odla-ai/cli 0.11.2 → 0.12.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 +6 -1
- package/dist/bin.cjs +566 -332
- 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-Y4HWUEFF.js} +488 -256
- package/dist/chunk-Y4HWUEFF.js.map +1 -0
- package/dist/index.cjs +569 -335
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -51
- package/dist/index.d.ts +50 -51
- package/dist/index.js +5 -5
- package/package.json +1 -1
- package/skills/odla/SKILL.md +18 -10
- package/skills/odla/references/build.md +17 -14
- package/skills/odla/references/sdks.md +29 -17
- package/skills/odla-migrate/SKILL.md +8 -6
- 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,77 +782,21 @@ function isRecord3(value) {
|
|
|
663
782
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
664
783
|
}
|
|
665
784
|
|
|
666
|
-
// src/capabilities.ts
|
|
667
|
-
var CAPABILITIES = {
|
|
668
|
-
cli: [
|
|
669
|
-
"start an email-bound device request without accepting a password or user session token",
|
|
670
|
-
"register the app and enable configured services per environment",
|
|
671
|
-
"issue, persist, and explicitly rotate configured service credentials",
|
|
672
|
-
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
673
|
-
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
674
|
-
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
675
|
-
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
676
|
-
"validate config offline and smoke-test a provisioned db environment",
|
|
677
|
-
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
678
|
-
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
679
|
-
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
680
|
-
],
|
|
681
|
-
agent: [
|
|
682
|
-
"install and import the selected odla SDKs",
|
|
683
|
-
"wrap the Worker with withObservability and choose useful telemetry",
|
|
684
|
-
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
685
|
-
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
686
|
-
],
|
|
687
|
-
human: [
|
|
688
|
-
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
689
|
-
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
690
|
-
"consent to production changes with --yes",
|
|
691
|
-
"request destructive credential rotation explicitly",
|
|
692
|
-
"review application semantics, security findings, releases, and merges",
|
|
693
|
-
"approve redacted-source disclosure before hosted security reasoning",
|
|
694
|
-
"approve GitHub App repository access separately from redacted-snippet disclosure"
|
|
695
|
-
],
|
|
696
|
-
studio: [
|
|
697
|
-
"view telemetry and environment state",
|
|
698
|
-
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
699
|
-
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
700
|
-
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
701
|
-
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
702
|
-
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
703
|
-
]
|
|
704
|
-
};
|
|
705
|
-
function printCapabilities(json = false, out = console) {
|
|
706
|
-
if (json) {
|
|
707
|
-
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
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
|
-
}
|
|
716
|
-
function printGroup(out, heading, items) {
|
|
717
|
-
out.log(`${heading}:`);
|
|
718
|
-
for (const item of items) out.log(` - ${item}`);
|
|
719
|
-
out.log("");
|
|
720
|
-
}
|
|
721
|
-
|
|
722
785
|
// src/config.ts
|
|
723
786
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
724
|
-
import { dirname as
|
|
787
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
725
788
|
import { pathToFileURL } from "url";
|
|
726
789
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
727
790
|
var DEFAULT_ENVS = ["dev"];
|
|
728
791
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
729
|
-
var
|
|
792
|
+
var GOOGLE_CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
|
730
793
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
731
794
|
const resolved = resolve2(configPath);
|
|
732
795
|
if (!existsSync3(resolved)) {
|
|
733
796
|
throw new Error(`config not found: ${configPath}. Run "odla-ai init" first or pass --config.`);
|
|
734
797
|
}
|
|
735
798
|
const raw = await loadConfigModule(resolved);
|
|
736
|
-
const rootDir =
|
|
799
|
+
const rootDir = dirname3(resolved);
|
|
737
800
|
validateRawConfig(raw, resolved);
|
|
738
801
|
const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
|
|
739
802
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
@@ -789,16 +852,14 @@ function calendarServiceConfig(cfg, env) {
|
|
|
789
852
|
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
790
853
|
const google = cfg.calendar?.google;
|
|
791
854
|
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
855
|
+
const availability = unique(
|
|
856
|
+
(google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
|
|
857
|
+
);
|
|
792
858
|
return {
|
|
793
859
|
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"
|
|
860
|
+
access: "book",
|
|
861
|
+
bookingCalendarId: google.bookingCalendar?.[env]?.trim() ?? availability[0],
|
|
862
|
+
availabilityCalendars: availability
|
|
802
863
|
};
|
|
803
864
|
}
|
|
804
865
|
function calendarBookingPageUrl(cfg, env) {
|
|
@@ -847,20 +908,39 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
847
908
|
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
848
909
|
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
849
910
|
const google = cfg.calendar.google;
|
|
850
|
-
assertOnly(
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
911
|
+
assertOnly(
|
|
912
|
+
google,
|
|
913
|
+
["availabilityCalendars", "calendars", "bookingCalendar", "bookingPageUrl"],
|
|
914
|
+
`${path}: calendar.google`
|
|
915
|
+
);
|
|
916
|
+
const availabilityKey = google.availabilityCalendars !== void 0 ? "availabilityCalendars" : google.calendars !== void 0 ? "calendars" : null;
|
|
917
|
+
if (!availabilityKey || google.availabilityCalendars !== void 0 && google.calendars !== void 0) {
|
|
918
|
+
throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
|
|
919
|
+
}
|
|
920
|
+
const availability = google[availabilityKey];
|
|
921
|
+
if (!isRecord4(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
|
|
922
|
+
const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
|
|
923
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
|
|
854
924
|
for (const env of envs) {
|
|
855
|
-
const ids =
|
|
925
|
+
const ids = availability[env];
|
|
856
926
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
857
|
-
throw new Error(`${path}: calendar.google
|
|
927
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must be a non-empty array`);
|
|
858
928
|
}
|
|
859
929
|
if (ids.length > 10) {
|
|
860
|
-
throw new Error(`${path}: calendar.google
|
|
930
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
|
|
861
931
|
}
|
|
862
932
|
if (ids.some((id) => !safeText(id, 1024))) {
|
|
863
|
-
throw new Error(`${path}: calendar.google
|
|
933
|
+
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (google.bookingCalendar !== void 0) {
|
|
937
|
+
if (!isRecord4(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
|
|
938
|
+
const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
|
|
939
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
|
|
940
|
+
for (const [env, value] of Object.entries(google.bookingCalendar)) {
|
|
941
|
+
if (!safeText(value, 1024)) {
|
|
942
|
+
throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
|
|
943
|
+
}
|
|
864
944
|
}
|
|
865
945
|
}
|
|
866
946
|
if (google.bookingPageUrl !== void 0) {
|
|
@@ -873,21 +953,6 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
873
953
|
}
|
|
874
954
|
}
|
|
875
955
|
}
|
|
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
956
|
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
892
957
|
}
|
|
893
958
|
function assertOnly(value, allowed, label) {
|
|
@@ -926,6 +991,62 @@ function unique(values) {
|
|
|
926
991
|
return [...new Set(values.filter(Boolean))];
|
|
927
992
|
}
|
|
928
993
|
|
|
994
|
+
// src/capabilities.ts
|
|
995
|
+
var CAPABILITIES = {
|
|
996
|
+
cli: [
|
|
997
|
+
"start an email-bound device request without accepting a password or user session token",
|
|
998
|
+
"register the app and enable configured services per environment",
|
|
999
|
+
"issue, persist, and explicitly rotate configured service credentials",
|
|
1000
|
+
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
1001
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
1002
|
+
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
1003
|
+
"apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
|
|
1004
|
+
"validate config offline and smoke-test a provisioned db environment",
|
|
1005
|
+
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
1006
|
+
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
1007
|
+
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
1008
|
+
],
|
|
1009
|
+
agent: [
|
|
1010
|
+
"install and import the selected odla SDKs",
|
|
1011
|
+
"wrap the Worker with withObservability and choose useful telemetry",
|
|
1012
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
1013
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
1014
|
+
],
|
|
1015
|
+
human: [
|
|
1016
|
+
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
1017
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
1018
|
+
"consent to production changes with --yes",
|
|
1019
|
+
"request destructive credential rotation explicitly",
|
|
1020
|
+
"review application semantics, security findings, releases, and merges",
|
|
1021
|
+
"approve redacted-source disclosure before hosted security reasoning",
|
|
1022
|
+
"approve GitHub App repository access separately from redacted-snippet disclosure"
|
|
1023
|
+
],
|
|
1024
|
+
studio: [
|
|
1025
|
+
"view telemetry and environment state",
|
|
1026
|
+
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
1027
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
1028
|
+
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
1029
|
+
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
1030
|
+
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
1031
|
+
]
|
|
1032
|
+
};
|
|
1033
|
+
function printCapabilities(json = false, out = console) {
|
|
1034
|
+
if (json) {
|
|
1035
|
+
out.log(JSON.stringify(CAPABILITIES, null, 2));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
out.log("odla-ai responsibility boundary\n");
|
|
1039
|
+
printGroup(out, "CLI automates", CAPABILITIES.cli);
|
|
1040
|
+
printGroup(out, "Coding agent changes source", CAPABILITIES.agent);
|
|
1041
|
+
printGroup(out, "Human checkpoints", CAPABILITIES.human);
|
|
1042
|
+
printGroup(out, "Studio", CAPABILITIES.studio);
|
|
1043
|
+
}
|
|
1044
|
+
function printGroup(out, heading, items) {
|
|
1045
|
+
out.log(`${heading}:`);
|
|
1046
|
+
for (const item of items) out.log(` - ${item}`);
|
|
1047
|
+
out.log("");
|
|
1048
|
+
}
|
|
1049
|
+
|
|
929
1050
|
// src/redact.ts
|
|
930
1051
|
var REPLACEMENTS = [
|
|
931
1052
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -949,6 +1070,9 @@ function looksSecret(value) {
|
|
|
949
1070
|
var PLATFORM_NOT_READY_CODES = /* @__PURE__ */ new Set([
|
|
950
1071
|
"calendar_google_oauth_not_configured",
|
|
951
1072
|
"calendar_token_vault_not_configured",
|
|
1073
|
+
// Current spelling (booking-era key verifier) plus the retired mirror-era
|
|
1074
|
+
// ingress code, so the CLI stays resumable against either platform version.
|
|
1075
|
+
"calendar_db_verifier_not_configured",
|
|
952
1076
|
"calendar_db_ingress_not_configured"
|
|
953
1077
|
]);
|
|
954
1078
|
var CalendarRequestError = class extends Error {
|
|
@@ -971,8 +1095,6 @@ function isCalendarPlatformNotReady(error) {
|
|
|
971
1095
|
var CALENDAR_STATES = [
|
|
972
1096
|
"not_connected",
|
|
973
1097
|
"authorizing",
|
|
974
|
-
"needs_sync",
|
|
975
|
-
"initial_sync",
|
|
976
1098
|
"healthy",
|
|
977
1099
|
"degraded",
|
|
978
1100
|
"disconnected",
|
|
@@ -1020,9 +1142,6 @@ async function pollCalendarConnection(ctx, attemptId) {
|
|
|
1020
1142
|
ctx.env
|
|
1021
1143
|
);
|
|
1022
1144
|
}
|
|
1023
|
-
async function requestCalendarResync(ctx) {
|
|
1024
|
-
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1025
|
-
}
|
|
1026
1145
|
async function requestCalendarDisconnect(ctx) {
|
|
1027
1146
|
return parseCalendarStatus(
|
|
1028
1147
|
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
@@ -1033,10 +1152,8 @@ function parseCalendarStatus(raw, env) {
|
|
|
1033
1152
|
const outer = wrapped(raw, "calendar");
|
|
1034
1153
|
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1035
1154
|
const connection = record(value.connection) ?? {};
|
|
1036
|
-
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1037
1155
|
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1038
1156
|
const googleConfig = record(config.google) ?? config;
|
|
1039
|
-
const watches = record(value.watches) ?? {};
|
|
1040
1157
|
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1041
1158
|
if (!stateValue) {
|
|
1042
1159
|
throw new Error("calendar status returned an invalid connection state");
|
|
@@ -1046,31 +1163,29 @@ function parseCalendarStatus(raw, env) {
|
|
|
1046
1163
|
throw new Error("calendar status returned an unsupported provider");
|
|
1047
1164
|
}
|
|
1048
1165
|
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");
|
|
1166
|
+
if (accessValue !== void 0 && accessValue !== "book" && accessValue !== "read") {
|
|
1167
|
+
throw new Error("calendar status returned unsupported access");
|
|
1053
1168
|
}
|
|
1054
1169
|
const errorValue = record(value.error) ?? record(connection.error);
|
|
1055
1170
|
const errorCode = textField(value.lastErrorCode, 128);
|
|
1056
1171
|
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1172
|
+
const connected = typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["healthy", "degraded"].includes(stateValue);
|
|
1173
|
+
const bookingCalendarId = textField(value.bookingCalendarId ?? config.bookingCalendarId, 1024);
|
|
1057
1174
|
return {
|
|
1058
1175
|
env,
|
|
1059
1176
|
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1060
|
-
connected
|
|
1177
|
+
connected,
|
|
1178
|
+
writable: typeof value.writable === "boolean" ? value.writable : stateValue === "healthy",
|
|
1061
1179
|
provider: providerValue === "google" ? "google" : null,
|
|
1062
1180
|
status: stateValue,
|
|
1063
|
-
...accessValue
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1181
|
+
...accessValue !== void 0 ? { access: "book" } : {},
|
|
1182
|
+
...bookingCalendarId ? { bookingCalendarId } : {},
|
|
1183
|
+
calendars: calendarIds(
|
|
1184
|
+
value.availabilityCalendars ?? config.availabilityCalendars ?? value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars
|
|
1185
|
+
),
|
|
1067
1186
|
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1068
1187
|
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1069
1188
|
...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
1189
|
...errorValue || errorCode ? { error: {
|
|
1075
1190
|
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1076
1191
|
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
@@ -1085,7 +1200,9 @@ function calendarState(value) {
|
|
|
1085
1200
|
disabled: "not_connected",
|
|
1086
1201
|
disconnected: "disconnected",
|
|
1087
1202
|
connecting: "authorizing",
|
|
1088
|
-
syncing: "
|
|
1203
|
+
syncing: "healthy",
|
|
1204
|
+
needs_sync: "healthy",
|
|
1205
|
+
initial_sync: "healthy",
|
|
1089
1206
|
ready: "healthy",
|
|
1090
1207
|
error: "failed"
|
|
1091
1208
|
};
|
|
@@ -1153,13 +1270,6 @@ function optionalText(key, value, max) {
|
|
|
1153
1270
|
const parsed = textField(value, max);
|
|
1154
1271
|
return parsed ? { [key]: parsed } : {};
|
|
1155
1272
|
}
|
|
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
1273
|
function optionalNullableUrl(key, value) {
|
|
1164
1274
|
if (value === null) return { [key]: null };
|
|
1165
1275
|
const parsed = textField(value, 4096);
|
|
@@ -1211,7 +1321,7 @@ async function calendarConnect(options) {
|
|
|
1211
1321
|
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1212
1322
|
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1213
1323
|
const connectOptions = connectionOptions(options, out);
|
|
1214
|
-
return await continueConnectedCalendar(ctx, applied, connectOptions
|
|
1324
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1215
1325
|
}
|
|
1216
1326
|
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1217
1327
|
if (bookingPageUrl === void 0) return null;
|
|
@@ -1219,25 +1329,18 @@ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
|
1219
1329
|
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1220
1330
|
return status;
|
|
1221
1331
|
}
|
|
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
1332
|
async function calendarDisconnect(options) {
|
|
1230
1333
|
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1231
1334
|
const { ctx, out } = await lifecycleContext(options);
|
|
1232
1335
|
const status = await requestCalendarDisconnect(ctx);
|
|
1233
|
-
out.log(`${ctx.env}: calendar disconnected;
|
|
1336
|
+
out.log(`${ctx.env}: calendar disconnected; no calendar data was stored`);
|
|
1234
1337
|
return status;
|
|
1235
1338
|
}
|
|
1236
1339
|
async function ensureCalendarConnected(ctx, options) {
|
|
1237
1340
|
const out = options.stdout ?? console;
|
|
1238
1341
|
const current = await readCalendarStatus(ctx);
|
|
1239
1342
|
const connectOptions = connectionOptions(options, out);
|
|
1240
|
-
return await continueConnectedCalendar(ctx, current, connectOptions
|
|
1343
|
+
return await continueConnectedCalendar(ctx, current, connectOptions) ?? connectWithContext(ctx, connectOptions);
|
|
1241
1344
|
}
|
|
1242
1345
|
async function lifecycleContext(options) {
|
|
1243
1346
|
const cfg = await loadProjectConfig(options.configPath);
|
|
@@ -1273,25 +1376,20 @@ function connectionOptions(options, out) {
|
|
|
1273
1376
|
signal: options.signal
|
|
1274
1377
|
};
|
|
1275
1378
|
}
|
|
1276
|
-
async function continueConnectedCalendar(ctx, current, options
|
|
1277
|
-
if (current.status === "healthy"
|
|
1278
|
-
options.out.log(`${ctx.env}: calendar
|
|
1379
|
+
async function continueConnectedCalendar(ctx, current, options) {
|
|
1380
|
+
if (current.status === "healthy") {
|
|
1381
|
+
options.out.log(`${ctx.env}: calendar already connected (healthy)`);
|
|
1279
1382
|
return current;
|
|
1280
1383
|
}
|
|
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;
|
|
1384
|
+
if (current.status === "degraded") {
|
|
1385
|
+
options.out.log(`${ctx.env}: calendar grant predates booking scopes \u2014 Google re-consent required`);
|
|
1386
|
+
return null;
|
|
1288
1387
|
}
|
|
1289
1388
|
if (current.status === "failed" && current.connected) {
|
|
1290
1389
|
const reason = current.error?.message ?? current.error?.code;
|
|
1291
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1390
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1292
1391
|
}
|
|
1293
|
-
|
|
1294
|
-
if (!waitingForExisting) return null;
|
|
1392
|
+
if (current.status !== "authorizing") return null;
|
|
1295
1393
|
const now = options.now ?? Date.now;
|
|
1296
1394
|
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1297
1395
|
const wait = options.wait ?? waitForCalendarPoll;
|
|
@@ -1303,17 +1401,16 @@ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
|
1303
1401
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1304
1402
|
prior = status.status;
|
|
1305
1403
|
}
|
|
1306
|
-
if (status.status === "healthy"
|
|
1404
|
+
if (status.status === "healthy") return status;
|
|
1307
1405
|
if (status.status === "degraded") return null;
|
|
1308
|
-
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1309
1406
|
if (status.status === "failed" && status.connected) {
|
|
1310
1407
|
const reason = status.error?.message ?? status.error?.code;
|
|
1311
|
-
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and
|
|
1408
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
|
|
1312
1409
|
}
|
|
1313
|
-
if (status.status !== "authorizing"
|
|
1410
|
+
if (status.status !== "authorizing") return null;
|
|
1314
1411
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1315
1412
|
}
|
|
1316
|
-
throw new Error('Existing Google Calendar authorization
|
|
1413
|
+
throw new Error('Existing Google Calendar authorization timed out; run "odla-ai calendar status" and retry');
|
|
1317
1414
|
}
|
|
1318
1415
|
async function connectWithContext(ctx, options) {
|
|
1319
1416
|
const attempt = await startCalendarConnection(ctx);
|
|
@@ -1335,14 +1432,17 @@ async function connectWithContext(ctx, options) {
|
|
|
1335
1432
|
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1336
1433
|
prior = status.status;
|
|
1337
1434
|
}
|
|
1338
|
-
if (status.status === "healthy"
|
|
1435
|
+
if (status.status === "healthy") return status;
|
|
1436
|
+
if (status.status === "degraded") {
|
|
1437
|
+
throw new Error("calendar connected without booking scopes; re-run connect and grant calendar access");
|
|
1438
|
+
}
|
|
1339
1439
|
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1340
1440
|
const reason = status.error?.message ?? status.error?.code;
|
|
1341
1441
|
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1342
1442
|
}
|
|
1343
1443
|
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1344
1444
|
}
|
|
1345
|
-
throw new Error('Google Calendar consent
|
|
1445
|
+
throw new Error('Google Calendar consent timed out; run "odla-ai calendar status" and retry connect');
|
|
1346
1446
|
}
|
|
1347
1447
|
function trustedCalendarConsentUrl(platform, value) {
|
|
1348
1448
|
const origin = platformAudience(platform);
|
|
@@ -1372,26 +1472,22 @@ function printStatus(status, json, out) {
|
|
|
1372
1472
|
return;
|
|
1373
1473
|
}
|
|
1374
1474
|
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
|
-
}
|
|
1475
|
+
out.log(` bookable: ${status.writable ? "yes" : status.connected ? "no \u2014 reconnect to grant booking scopes" : "no \u2014 not connected"}`);
|
|
1476
|
+
if (status.bookingCalendarId) out.log(` booking calendar: ${status.bookingCalendarId}`);
|
|
1477
|
+
out.log(` availability calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1380
1478
|
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
1479
|
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1384
1480
|
}
|
|
1385
1481
|
|
|
1386
1482
|
// src/doctor-checks.ts
|
|
1387
1483
|
import { execFileSync } from "child_process";
|
|
1388
1484
|
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
1389
|
-
import { join as
|
|
1485
|
+
import { join as join3, resolve as resolve3 } from "path";
|
|
1390
1486
|
|
|
1391
1487
|
// src/wrangler.ts
|
|
1392
1488
|
import { spawn as spawn2 } from "child_process";
|
|
1393
1489
|
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
1394
|
-
import { join } from "path";
|
|
1490
|
+
import { join as join2 } from "path";
|
|
1395
1491
|
var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) => {
|
|
1396
1492
|
const child = spawn2(cmd, args, { cwd: opts?.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
1397
1493
|
let stdout = "";
|
|
@@ -1405,7 +1501,7 @@ var defaultRunner = (cmd, args, opts) => new Promise((resolvePromise, reject) =>
|
|
|
1405
1501
|
var WRANGLER_CONFIG_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
1406
1502
|
function findWranglerConfig(rootDir) {
|
|
1407
1503
|
for (const name of WRANGLER_CONFIG_FILES) {
|
|
1408
|
-
const path =
|
|
1504
|
+
const path = join2(rootDir, name);
|
|
1409
1505
|
if (existsSync4(path)) return path;
|
|
1410
1506
|
}
|
|
1411
1507
|
return null;
|
|
@@ -1514,7 +1610,7 @@ function wranglerWarnings(rootDir) {
|
|
|
1514
1610
|
const dir = resolve3(rootDir, assets.directory);
|
|
1515
1611
|
if (dir === resolve3(rootDir)) {
|
|
1516
1612
|
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(
|
|
1613
|
+
} else if (existsSync5(join3(dir, "node_modules"))) {
|
|
1518
1614
|
warnings.push(`${label}assets.directory contains node_modules \u2014 wrangler dev's watcher will exhaust file descriptors`);
|
|
1519
1615
|
}
|
|
1520
1616
|
}
|
|
@@ -1579,7 +1675,7 @@ function calendarProjectWarnings(rootDir) {
|
|
|
1579
1675
|
}
|
|
1580
1676
|
function readPackageJson(rootDir) {
|
|
1581
1677
|
try {
|
|
1582
|
-
return JSON.parse(readFileSync4(
|
|
1678
|
+
return JSON.parse(readFileSync4(join3(rootDir, "package.json"), "utf8"));
|
|
1583
1679
|
} catch {
|
|
1584
1680
|
return null;
|
|
1585
1681
|
}
|
|
@@ -1603,8 +1699,11 @@ async function doctor(options) {
|
|
|
1603
1699
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1604
1700
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1605
1701
|
if (cfg.services.includes("calendar")) {
|
|
1606
|
-
const calendar = cfg.envs.map((env) =>
|
|
1607
|
-
|
|
1702
|
+
const calendar = cfg.envs.map((env) => {
|
|
1703
|
+
const resolved = calendarServiceConfig(cfg, env);
|
|
1704
|
+
return `${env}:${resolved.bookingCalendarId}+${resolved.availabilityCalendars.length}`;
|
|
1705
|
+
}).join(", ");
|
|
1706
|
+
out.log(`calendar: google/booking (${calendar})`);
|
|
1608
1707
|
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1609
1708
|
out.log(`booking pages: ${pages}`);
|
|
1610
1709
|
} else {
|
|
@@ -1657,7 +1756,7 @@ async function doctor(options) {
|
|
|
1657
1756
|
|
|
1658
1757
|
// src/init.ts
|
|
1659
1758
|
import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1660
|
-
import { dirname as
|
|
1759
|
+
import { dirname as dirname4, resolve as resolve4 } from "path";
|
|
1661
1760
|
function initProject(options) {
|
|
1662
1761
|
const out = options.stdout ?? console;
|
|
1663
1762
|
const rootDir = resolve4(options.rootDir ?? process.cwd());
|
|
@@ -1672,7 +1771,7 @@ function initProject(options) {
|
|
|
1672
1771
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1673
1772
|
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1674
1773
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1675
|
-
mkdirSync2(
|
|
1774
|
+
mkdirSync2(dirname4(configPath), { recursive: true });
|
|
1676
1775
|
mkdirSync2(resolve4(rootDir, "src/odla"), { recursive: true });
|
|
1677
1776
|
mkdirSync2(resolve4(rootDir, ".odla"), { recursive: true });
|
|
1678
1777
|
writeFileSync2(configPath, configTemplate({ appId: options.appId, name: options.name, envs, services, aiProvider }));
|
|
@@ -1690,11 +1789,11 @@ function writeIfMissing(path, text) {
|
|
|
1690
1789
|
function configTemplate(input) {
|
|
1691
1790
|
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1692
1791
|
google: {
|
|
1693
|
-
|
|
1792
|
+
// Calendars consulted for availability; bookings land on bookingCalendar
|
|
1793
|
+
// (defaults to the first availability calendar). Google consent happens
|
|
1794
|
+
// in the browser during provision; bookings write live through odla.ai.
|
|
1795
|
+
availabilityCalendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1694
1796
|
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
1797
|
},
|
|
1699
1798
|
},
|
|
1700
1799
|
` : "";
|
|
@@ -1858,7 +1957,7 @@ function assertWranglerConfig(cfg) {
|
|
|
1858
1957
|
// src/provision.ts
|
|
1859
1958
|
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
1860
1959
|
import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
|
|
1861
|
-
import
|
|
1960
|
+
import process8 from "process";
|
|
1862
1961
|
|
|
1863
1962
|
// src/provision-credentials.ts
|
|
1864
1963
|
import { tenantIdFor } from "@odla-ai/apps";
|
|
@@ -1990,7 +2089,7 @@ async function provision(options) {
|
|
|
1990
2089
|
if (cfg.services.includes("calendar")) {
|
|
1991
2090
|
for (const env of cfg.envs) {
|
|
1992
2091
|
const calendar = calendarServiceConfig(cfg, env);
|
|
1993
|
-
out.log(` calendar.${env}: google/
|
|
2092
|
+
out.log(` calendar.${env}: google/book \u2192 ${calendar.bookingCalendarId}; availability ${calendar.availabilityCalendars.join(", ")} (${GOOGLE_CALENDAR_EVENTS_SCOPE})`);
|
|
1994
2093
|
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
1995
2094
|
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
1996
2095
|
}
|
|
@@ -2013,7 +2112,7 @@ async function provision(options) {
|
|
|
2013
2112
|
}
|
|
2014
2113
|
const devVarsTarget = resolveWriteDevVarsTarget(cfg, options.writeDevVars);
|
|
2015
2114
|
if (cfg.local.gitignore) {
|
|
2016
|
-
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2115
|
+
ensureGitignore(cfg.rootDir, [cfg.local.tokenFile, handshakeFile(cfg), cfg.local.credentialsFile, ...devVarsTarget ? [devVarsTarget] : []]);
|
|
2017
2116
|
}
|
|
2018
2117
|
if (options.pushSecrets) {
|
|
2019
2118
|
await preflightSecretsPush({ configPath: cfg.configPath, runner: options.secretRunner });
|
|
@@ -2103,7 +2202,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2103
2202
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
2104
2203
|
}
|
|
2105
2204
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
2106
|
-
const key =
|
|
2205
|
+
const key = process8.env[cfg.ai.keyEnv];
|
|
2107
2206
|
if (key) {
|
|
2108
2207
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
2109
2208
|
await putSecret({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -2735,7 +2834,7 @@ function securityExecutionDigest(value) {
|
|
|
2735
2834
|
// src/skill.ts
|
|
2736
2835
|
import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2737
2836
|
import { homedir } from "os";
|
|
2738
|
-
import { dirname as
|
|
2837
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join4, relative as relative3, resolve as resolve6, sep as sep2 } from "path";
|
|
2739
2838
|
import { fileURLToPath } from "url";
|
|
2740
2839
|
|
|
2741
2840
|
// src/skill-adapters.ts
|
|
@@ -2811,48 +2910,48 @@ function installSkill(options = {}) {
|
|
|
2811
2910
|
plans.set(target, { target, content, boundary, managedMerge });
|
|
2812
2911
|
};
|
|
2813
2912
|
const planSkillTree = (targetDir2, boundary = root) => {
|
|
2814
|
-
for (const rel of files) plan(
|
|
2913
|
+
for (const rel of files) plan(join4(targetDir2, rel), readFileSync5(join4(sourceDir, rel), "utf8"), false, boundary);
|
|
2815
2914
|
};
|
|
2816
2915
|
let targetDir;
|
|
2817
2916
|
if (options.global) {
|
|
2818
|
-
const claudeRoot =
|
|
2819
|
-
const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ??
|
|
2917
|
+
const claudeRoot = join4(home, ".claude", "skills");
|
|
2918
|
+
const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ?? join4(home, ".codex"), "skills");
|
|
2820
2919
|
targetDir = harnesses[0] === "codex" ? codexRoot : claudeRoot;
|
|
2821
2920
|
for (const harness of harnesses) {
|
|
2822
2921
|
const skillRoot = harness === "claude" ? claudeRoot : codexRoot;
|
|
2823
|
-
planSkillTree(skillRoot, harness === "claude" ? home :
|
|
2922
|
+
planSkillTree(skillRoot, harness === "claude" ? home : dirname5(dirname5(codexRoot)));
|
|
2824
2923
|
rememberTarget(harness, skillRoot);
|
|
2825
2924
|
}
|
|
2826
2925
|
} else {
|
|
2827
|
-
const sharedRoot =
|
|
2926
|
+
const sharedRoot = join4(root, ".agents", "skills");
|
|
2828
2927
|
planSkillTree(sharedRoot);
|
|
2829
|
-
const claudeRoot =
|
|
2928
|
+
const claudeRoot = join4(root, ".claude", "skills");
|
|
2830
2929
|
targetDir = harnesses.includes("claude") ? claudeRoot : sharedRoot;
|
|
2831
2930
|
for (const harness of harnesses) rememberTarget(harness, sharedRoot);
|
|
2832
2931
|
if (harnesses.includes("claude")) {
|
|
2833
2932
|
for (const skill of skillNames(files)) {
|
|
2834
|
-
const canonical = readFileSync5(
|
|
2835
|
-
plan(
|
|
2933
|
+
const canonical = readFileSync5(join4(sourceDir, skill, "SKILL.md"), "utf8");
|
|
2934
|
+
plan(join4(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
|
|
2836
2935
|
}
|
|
2837
2936
|
rememberTarget("claude", claudeRoot);
|
|
2838
2937
|
}
|
|
2839
2938
|
if (harnesses.includes("cursor")) {
|
|
2840
|
-
const cursorRule =
|
|
2939
|
+
const cursorRule = join4(root, ".cursor", "rules", "odla.mdc");
|
|
2841
2940
|
plan(cursorRule, CURSOR_RULE);
|
|
2842
2941
|
rememberTarget("cursor", cursorRule);
|
|
2843
2942
|
}
|
|
2844
2943
|
if (harnesses.includes("agents")) {
|
|
2845
|
-
const agentsFile =
|
|
2944
|
+
const agentsFile = join4(root, "AGENTS.md");
|
|
2846
2945
|
plan(agentsFile, managedFileContent(agentsFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2847
2946
|
rememberTarget("agents", agentsFile);
|
|
2848
2947
|
}
|
|
2849
2948
|
if (harnesses.includes("copilot")) {
|
|
2850
|
-
const copilotFile =
|
|
2949
|
+
const copilotFile = join4(root, ".github", "copilot-instructions.md");
|
|
2851
2950
|
plan(copilotFile, managedFileContent(copilotFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2852
2951
|
rememberTarget("copilot", copilotFile);
|
|
2853
2952
|
}
|
|
2854
2953
|
if (harnesses.includes("gemini")) {
|
|
2855
|
-
const geminiFile =
|
|
2954
|
+
const geminiFile = join4(root, "GEMINI.md");
|
|
2856
2955
|
plan(geminiFile, managedFileContent(geminiFile, PROJECT_INSTRUCTIONS, options.force === true, root), true);
|
|
2857
2956
|
rememberTarget("gemini", geminiFile);
|
|
2858
2957
|
}
|
|
@@ -2888,7 +2987,7 @@ ${conflicts.map((f) => ` - ${f}`).join("\n")}`
|
|
|
2888
2987
|
}
|
|
2889
2988
|
for (const file of plans.values()) {
|
|
2890
2989
|
if (!existsSync7(file.target) || readFileSync5(file.target, "utf8") !== file.content) {
|
|
2891
|
-
mkdirSync3(
|
|
2990
|
+
mkdirSync3(dirname5(file.target), { recursive: true });
|
|
2892
2991
|
writeFileSync3(file.target, file.content);
|
|
2893
2992
|
}
|
|
2894
2993
|
}
|
|
@@ -2959,7 +3058,7 @@ function symlinkedComponent(boundary, target) {
|
|
|
2959
3058
|
}
|
|
2960
3059
|
let current = boundary;
|
|
2961
3060
|
for (const part of rel.split(sep2).filter(Boolean)) {
|
|
2962
|
-
current =
|
|
3061
|
+
current = join4(current, part);
|
|
2963
3062
|
try {
|
|
2964
3063
|
if (lstatSync(current).isSymbolicLink()) return current;
|
|
2965
3064
|
} catch (error) {
|
|
@@ -2976,7 +3075,7 @@ function listFiles(dir) {
|
|
|
2976
3075
|
const results = [];
|
|
2977
3076
|
const walk = (current) => {
|
|
2978
3077
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
2979
|
-
const path =
|
|
3078
|
+
const path = join4(current, entry.name);
|
|
2980
3079
|
if (entry.isDirectory()) walk(path);
|
|
2981
3080
|
else results.push(relative3(dir, path));
|
|
2982
3081
|
}
|
|
@@ -3029,7 +3128,7 @@ async function smoke(options) {
|
|
|
3029
3128
|
);
|
|
3030
3129
|
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3031
3130
|
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3032
|
-
out.log(` calendar: ${status.status},
|
|
3131
|
+
out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
|
|
3033
3132
|
}
|
|
3034
3133
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
3035
3134
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
@@ -3052,14 +3151,6 @@ async function smoke(options) {
|
|
|
3052
3151
|
} else {
|
|
3053
3152
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3054
3153
|
}
|
|
3055
|
-
if (cfg.services.includes("calendar")) {
|
|
3056
|
-
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3057
|
-
ns: "$bookings",
|
|
3058
|
-
aggregate: { count: true }
|
|
3059
|
-
});
|
|
3060
|
-
const count = bookings.aggregate?.count;
|
|
3061
|
-
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3062
|
-
}
|
|
3063
3154
|
out.log("ok");
|
|
3064
3155
|
}
|
|
3065
3156
|
function assertCalendarHealthy(status, expected) {
|
|
@@ -3067,14 +3158,11 @@ function assertCalendarHealthy(status, expected) {
|
|
|
3067
3158
|
if (status.status !== "healthy") {
|
|
3068
3159
|
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3069
3160
|
}
|
|
3070
|
-
if (status.
|
|
3071
|
-
const
|
|
3072
|
-
if (!
|
|
3073
|
-
const missing = expected.
|
|
3161
|
+
if (!status.writable) throw new Error('calendar grant does not cover booking writes; run "odla-ai calendar connect" to re-consent');
|
|
3162
|
+
const hasEventsScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_EVENTS_SCOPE);
|
|
3163
|
+
if (!hasEventsScope) throw new Error("calendar connection is missing calendar.events consent");
|
|
3164
|
+
const missing = expected.availabilityCalendars.filter((id) => !status.calendars.includes(id));
|
|
3074
3165
|
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
3166
|
}
|
|
3079
3167
|
async function getJson(doFetch, url, bearer) {
|
|
3080
3168
|
const res = await doFetch(url, {
|
|
@@ -3241,6 +3329,127 @@ async function adminCommand(parsed) {
|
|
|
3241
3329
|
});
|
|
3242
3330
|
}
|
|
3243
3331
|
|
|
3332
|
+
// src/app-export.ts
|
|
3333
|
+
import { createWriteStream } from "fs";
|
|
3334
|
+
import { Readable } from "stream";
|
|
3335
|
+
import { pipeline } from "stream/promises";
|
|
3336
|
+
async function appExport(options) {
|
|
3337
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
3338
|
+
const out = options.stdout ?? console;
|
|
3339
|
+
const doFetch = options.fetch ?? fetch;
|
|
3340
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
3341
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
3342
|
+
const tenant = env === "prod" ? cfg.app.id : `${cfg.app.id}--${env}`;
|
|
3343
|
+
const token = await getDeveloperToken(
|
|
3344
|
+
cfg,
|
|
3345
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
3346
|
+
doFetch,
|
|
3347
|
+
out
|
|
3348
|
+
);
|
|
3349
|
+
const auth = { authorization: `Bearer ${token}` };
|
|
3350
|
+
const base = `${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenant)}`;
|
|
3351
|
+
if (options.fresh) {
|
|
3352
|
+
const res = await doFetch(`${base}/export`, { method: "POST", headers: auth });
|
|
3353
|
+
const body = await res.json().catch(() => ({}));
|
|
3354
|
+
if (!res.ok) throw new Error(`export failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? res.status}`);
|
|
3355
|
+
}
|
|
3356
|
+
const list = await doFetch(`${base}/backups`, { headers: auth });
|
|
3357
|
+
if (!list.ok) throw new Error(`couldn't list backups (${list.status})`);
|
|
3358
|
+
const { backups } = await list.json();
|
|
3359
|
+
const newest = backups[0];
|
|
3360
|
+
if (!newest) {
|
|
3361
|
+
throw new Error(
|
|
3362
|
+
`${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)`
|
|
3363
|
+
);
|
|
3364
|
+
}
|
|
3365
|
+
const download = await doFetch(`${base}/backups/${newest.id}/download`, { headers: auth });
|
|
3366
|
+
if (!download.ok || !download.body) throw new Error(`download failed (${download.status})`);
|
|
3367
|
+
const file = options.out ?? `${tenant}-${new Date(newest.created_at).toISOString().slice(0, 10)}-tx${newest.max_tx}.jsonl.gz`;
|
|
3368
|
+
await pipeline(Readable.fromWeb(download.body), createWriteStream(file));
|
|
3369
|
+
if (options.json) out.log(JSON.stringify({ file, backup: newest }, null, 2));
|
|
3370
|
+
else {
|
|
3371
|
+
out.log(`${tenant}: wrote ${file} (${newest.bytes} bytes, ${newest.kind} snapshot at tx ${newest.max_tx})`);
|
|
3372
|
+
out.log(`sha256 ${download.headers.get("x-odla-sha256") ?? newest.sha256}`);
|
|
3373
|
+
}
|
|
3374
|
+
return { file, backup: newest };
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
// src/app-lifecycle.ts
|
|
3378
|
+
async function lifecycleCall(action, options) {
|
|
3379
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
3380
|
+
const out = options.stdout ?? console;
|
|
3381
|
+
const doFetch = options.fetch ?? fetch;
|
|
3382
|
+
const token = await getDeveloperToken(
|
|
3383
|
+
cfg,
|
|
3384
|
+
{ configPath: cfg.configPath, token: options.token, email: options.email, open: false },
|
|
3385
|
+
doFetch,
|
|
3386
|
+
out
|
|
3387
|
+
);
|
|
3388
|
+
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/${action}`, {
|
|
3389
|
+
method: "POST",
|
|
3390
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }
|
|
3391
|
+
});
|
|
3392
|
+
const body = await res.json().catch(() => ({}));
|
|
3393
|
+
if (!res.ok || !body.ok) {
|
|
3394
|
+
throw new Error(`${action} failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? `registry returned ${res.status}`}`);
|
|
3395
|
+
}
|
|
3396
|
+
return body;
|
|
3397
|
+
}
|
|
3398
|
+
async function appArchive(options) {
|
|
3399
|
+
if (options.yes !== true) {
|
|
3400
|
+
throw new Error(
|
|
3401
|
+
"app archive suspends EVERY environment: API keys stop working and all services refuse requests until restored. All data is retained. Pass --yes to proceed."
|
|
3402
|
+
);
|
|
3403
|
+
}
|
|
3404
|
+
const out = options.stdout ?? console;
|
|
3405
|
+
const body = await lifecycleCall("archive", options);
|
|
3406
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
3407
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already archived`);
|
|
3408
|
+
else out.log(`${body.app?.appId}: archived \u2014 data retained; run \`odla-ai app restore\` (or use Studio) to bring it back`);
|
|
3409
|
+
}
|
|
3410
|
+
async function appRestore(options) {
|
|
3411
|
+
const out = options.stdout ?? console;
|
|
3412
|
+
const body = await lifecycleCall("restore", options);
|
|
3413
|
+
if (options.json) out.log(JSON.stringify(body, null, 2));
|
|
3414
|
+
else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already active`);
|
|
3415
|
+
else out.log(`${body.app?.appId}: restored \u2014 every service's data plane is live again`);
|
|
3416
|
+
}
|
|
3417
|
+
async function appCommand(parsed, dependencies = {}) {
|
|
3418
|
+
const sub = parsed.positionals[1];
|
|
3419
|
+
if (sub === "export") {
|
|
3420
|
+
assertArgs(parsed, ["config", "env", "token", "email", "fresh", "out", "json"], 2);
|
|
3421
|
+
await appExport({
|
|
3422
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3423
|
+
env: stringOpt(parsed.options.env),
|
|
3424
|
+
token: stringOpt(parsed.options.token),
|
|
3425
|
+
email: stringOpt(parsed.options.email),
|
|
3426
|
+
fresh: parsed.options.fresh === true,
|
|
3427
|
+
out: stringOpt(parsed.options.out),
|
|
3428
|
+
json: parsed.options.json === true,
|
|
3429
|
+
fetch: dependencies.fetch,
|
|
3430
|
+
stdout: dependencies.stdout
|
|
3431
|
+
});
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
if (sub !== "archive" && sub !== "restore") {
|
|
3435
|
+
throw new Error(
|
|
3436
|
+
`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.)`
|
|
3437
|
+
);
|
|
3438
|
+
}
|
|
3439
|
+
assertArgs(parsed, ["config", "token", "email", "yes", "json"], 2);
|
|
3440
|
+
const options = {
|
|
3441
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3442
|
+
token: stringOpt(parsed.options.token),
|
|
3443
|
+
email: stringOpt(parsed.options.email),
|
|
3444
|
+
yes: parsed.options.yes === true,
|
|
3445
|
+
json: parsed.options.json === true,
|
|
3446
|
+
fetch: dependencies.fetch,
|
|
3447
|
+
stdout: dependencies.stdout
|
|
3448
|
+
};
|
|
3449
|
+
if (sub === "archive") await appArchive(options);
|
|
3450
|
+
else await appRestore(options);
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3244
3453
|
// src/help.ts
|
|
3245
3454
|
import { readFileSync as readFileSync6 } from "fs";
|
|
3246
3455
|
function cliVersion() {
|
|
@@ -3257,8 +3466,10 @@ Usage:
|
|
|
3257
3466
|
odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
|
|
3258
3467
|
odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
|
|
3259
3468
|
odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
|
|
3260
|
-
odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
|
|
3261
3469
|
odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
|
|
3470
|
+
odla-ai app archive [--config odla.config.mjs] [--email <odla-account>] [--json] --yes
|
|
3471
|
+
odla-ai app restore [--config odla.config.mjs] [--email <odla-account>] [--json]
|
|
3472
|
+
odla-ai app export [--env dev] [--fresh] [--out <file>] [--email <odla-account>] [--json]
|
|
3262
3473
|
odla-ai capabilities [--json]
|
|
3263
3474
|
odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
|
|
3264
3475
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -3278,7 +3489,7 @@ Usage:
|
|
|
3278
3489
|
odla-ai security report <job-id> [--json]
|
|
3279
3490
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
3280
3491
|
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]
|
|
3492
|
+
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
3493
|
odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
|
|
3283
3494
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
3284
3495
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
@@ -3290,7 +3501,14 @@ Commands:
|
|
|
3290
3501
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
3291
3502
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
3292
3503
|
doctor Validate and summarize the project config without network calls.
|
|
3293
|
-
calendar Inspect, connect,
|
|
3504
|
+
calendar Inspect, connect, or disconnect the live Google booking connection.
|
|
3505
|
+
app Archive (suspend, data retained), restore, or export the app.
|
|
3506
|
+
Archiving takes every environment's data plane down until restored;
|
|
3507
|
+
permanent deletion has NO CLI \u2014 it requires a signed-in owner in
|
|
3508
|
+
Studio, and no machine or agent credential can purge. "app export"
|
|
3509
|
+
downloads a portable gzipped-JSONL snapshot of one environment's
|
|
3510
|
+
database (--fresh takes a new snapshot first) \u2014 your data is
|
|
3511
|
+
always yours to take.
|
|
3294
3512
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
3295
3513
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
3296
3514
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -3312,6 +3530,12 @@ Safety:
|
|
|
3312
3530
|
Provision opens the approval page in your browser automatically whenever the
|
|
3313
3531
|
machine can show one, including agent-driven runs; only CI, SSH, and
|
|
3314
3532
|
display-less hosts skip it. Use --open to force or --no-open to suppress.
|
|
3533
|
+
Browser launch is best-effort: the printed approval URL is authoritative and
|
|
3534
|
+
agents must relay it to the human verbatim. A started handshake is persisted
|
|
3535
|
+
under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
|
|
3536
|
+
the same code. Outside an interactive terminal the wait is capped (90s by
|
|
3537
|
+
default, --wait <seconds> to change); a still-pending handshake then exits
|
|
3538
|
+
with code 75: relay the URL, wait for approval, and re-run to collect.
|
|
3315
3539
|
A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
|
|
3316
3540
|
The email is a non-secret identity hint: never provide a password or session
|
|
3317
3541
|
token. The matching account must already exist, be signed in, explicitly
|
|
@@ -3781,6 +4005,9 @@ async function securityStatus(parsed, dependencies) {
|
|
|
3781
4005
|
}
|
|
3782
4006
|
|
|
3783
4007
|
// src/cli.ts
|
|
4008
|
+
function exitCodeFor(err) {
|
|
4009
|
+
return err?.code === "handshake_pending" ? 75 : 1;
|
|
4010
|
+
}
|
|
3784
4011
|
async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
3785
4012
|
const parsed = parseArgv(argv);
|
|
3786
4013
|
const command = parsed.positionals[0] ?? "help";
|
|
@@ -3833,6 +4060,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3833
4060
|
await adminCommand(parsed);
|
|
3834
4061
|
return;
|
|
3835
4062
|
}
|
|
4063
|
+
if (command === "app") {
|
|
4064
|
+
await appCommand(parsed, dependencies);
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
3836
4067
|
if (command === "security") {
|
|
3837
4068
|
await securityCommand(parsed, dependencies);
|
|
3838
4069
|
return;
|
|
@@ -3929,6 +4160,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3929
4160
|
"token",
|
|
3930
4161
|
"email",
|
|
3931
4162
|
"open",
|
|
4163
|
+
"wait",
|
|
3932
4164
|
"yes"
|
|
3933
4165
|
], 1);
|
|
3934
4166
|
const writeDevVars2 = parsed.options["write-dev-vars"];
|
|
@@ -3943,6 +4175,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3943
4175
|
token: stringOpt(parsed.options.token),
|
|
3944
4176
|
email: stringOpt(parsed.options.email),
|
|
3945
4177
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
4178
|
+
wait: numberOpt(parsed.options.wait, "--wait"),
|
|
3946
4179
|
yes: parsed.options.yes === true,
|
|
3947
4180
|
fetch: dependencies.fetch,
|
|
3948
4181
|
openApprovalUrl: dependencies.openUrl,
|
|
@@ -3952,7 +4185,7 @@ async function provisionCommand(parsed, dependencies) {
|
|
|
3952
4185
|
}
|
|
3953
4186
|
async function calendarCommand(parsed, dependencies) {
|
|
3954
4187
|
const sub = parsed.positionals[1];
|
|
3955
|
-
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "
|
|
4188
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "disconnect") {
|
|
3956
4189
|
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3957
4190
|
}
|
|
3958
4191
|
assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
|
|
@@ -3973,7 +4206,6 @@ async function calendarCommand(parsed, dependencies) {
|
|
|
3973
4206
|
if (sub === "status") await calendarStatus(options);
|
|
3974
4207
|
else if (sub === "calendars") await calendarCalendars(options);
|
|
3975
4208
|
else if (sub === "connect") await calendarConnect(options);
|
|
3976
|
-
else if (sub === "resync") await calendarResync(options);
|
|
3977
4209
|
else await calendarDisconnect(options);
|
|
3978
4210
|
}
|
|
3979
4211
|
|
|
@@ -3981,16 +4213,15 @@ export {
|
|
|
3981
4213
|
getScopedPlatformToken,
|
|
3982
4214
|
SYSTEM_AI_PURPOSES,
|
|
3983
4215
|
adminAi,
|
|
3984
|
-
|
|
3985
|
-
printCapabilities,
|
|
3986
|
-
GOOGLE_CALENDAR_READ_SCOPE,
|
|
4216
|
+
GOOGLE_CALENDAR_EVENTS_SCOPE,
|
|
3987
4217
|
calendarServiceConfig,
|
|
3988
4218
|
calendarBookingPageUrl,
|
|
4219
|
+
CAPABILITIES,
|
|
4220
|
+
printCapabilities,
|
|
3989
4221
|
redactSecrets,
|
|
3990
4222
|
calendarStatus,
|
|
3991
4223
|
calendarCalendars,
|
|
3992
4224
|
calendarConnect,
|
|
3993
|
-
calendarResync,
|
|
3994
4225
|
calendarDisconnect,
|
|
3995
4226
|
doctor,
|
|
3996
4227
|
initProject,
|
|
@@ -4015,6 +4246,7 @@ export {
|
|
|
4015
4246
|
AGENT_HARNESSES,
|
|
4016
4247
|
installSkill,
|
|
4017
4248
|
smoke,
|
|
4249
|
+
exitCodeFor,
|
|
4018
4250
|
runCli
|
|
4019
4251
|
};
|
|
4020
|
-
//# sourceMappingURL=chunk-
|
|
4252
|
+
//# sourceMappingURL=chunk-Y4HWUEFF.js.map
|