@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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/admin-ai-auth.ts
4
4
  import { existsSync as existsSync2 } from "fs";
5
- import process4 from "process";
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 (process3.env.ODLA_DEV_TOKEN) {
165
- const declared = process3.env.ODLA_DEV_TOKEN_AUDIENCE;
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 process3.env.ODLA_DEV_TOKEN;
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 browser = approvalBrowser(options);
179
- const email = handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0);
180
- const { token, expiresAt } = await requestToken({
181
- endpoint: cfg.platformUrl,
182
- email,
183
- label: `${cfg.app.id} provisioner`,
184
- fetch: doFetch,
185
- onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
186
- const approvalUrl = verificationUriComplete ?? handshakeUrl(cfg.platformUrl, userCode);
187
- out.log("");
188
- out.log(`Review, then approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
189
- if (browser.open) {
190
- try {
191
- await (options.openApprovalUrl ?? openUrl)(approvalUrl);
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 ?? process3.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
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 ?? process3.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 ?? process3.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 = process4.env.ODLA_ADMIN_TOKEN;
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 = process4.env.ODLA_ADMIN_TOKEN_AUDIENCE;
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 ?? `${process4.cwd()}/.odla/admin-token.local.json`;
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(`${process4.cwd()}/.git`)) ensureGitignore(process4.cwd(), [tokenFile]);
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 process6 from "process";
435
+ import process7 from "process";
317
436
 
318
437
  // src/secret-input.ts
319
- import process5 from "process";
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 = process5.env[options.fromEnv];
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 = process5.stdin) {
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 ?? process6.env.ODLA_PLATFORM ?? "https://odla.ai");
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 dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
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 GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
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 = dirname2(resolved);
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: "read",
795
- calendars: unique(google.calendars[env].map((id) => id.trim())),
796
- match: {
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(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
851
- if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
852
- const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
853
- if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
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 = google.calendars[env];
925
+ const ids = availability[env];
856
926
  if (!Array.isArray(ids) || ids.length === 0) {
857
- throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
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.calendars.${env} must contain at most 10 calendar ids`);
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.calendars.${env} contains an invalid calendar id`);
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 !== "read") throw new Error("calendar status returned unsupported access");
1050
- const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
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: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
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 === "read" ? { access: "read" } : {},
1064
- calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
1065
- ...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
1066
- ...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
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: "initial_sync",
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, false) ?? connectWithContext(ctx, 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; retained mirror rows may now be stale`);
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, true) ?? connectWithContext(ctx, 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, reuseDegraded) {
1277
- if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
1278
- options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
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") return null;
1282
- if (current.status === "needs_sync") {
1283
- if (!current.connected) return null;
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 resync or reconnect explicitly`);
1390
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
1292
1391
  }
1293
- const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
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" || reuseDegraded && status.status === "degraded") return status;
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 resync or reconnect explicitly`);
1408
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and reconnect explicitly`);
1312
1409
  }
1313
- if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
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 or initial sync timed out; run "odla-ai calendar status" and retry');
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" || status.status === "degraded") return status;
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 or initial sync timed out; run "odla-ai calendar status" and retry connect');
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(` access: ${status.access ?? "not granted"}`);
1376
- out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
1377
- if (status.attendeePolicy) {
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 join2, resolve as resolve3 } from "path";
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 = join(rootDir, name);
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(join2(dir, "node_modules"))) {
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(join2(rootDir, "package.json"), "utf8"));
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) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
1607
- out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
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 dirname3, resolve as resolve4 } from "path";
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(dirname3(configPath), { recursive: true });
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
- calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
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 process7 from "process";
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/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
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 = process7.env[cfg.ai.keyEnv];
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 dirname4, isAbsolute as isAbsolute4, join as join3, relative as relative3, resolve as resolve6, sep as sep2 } from "path";
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(join3(targetDir2, rel), readFileSync5(join3(sourceDir, rel), "utf8"), false, boundary);
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 = join3(home, ".claude", "skills");
2819
- const codexRoot = resolve6(options.codexHomeDir ?? process.env.CODEX_HOME ?? join3(home, ".codex"), "skills");
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 : dirname4(dirname4(codexRoot)));
2922
+ planSkillTree(skillRoot, harness === "claude" ? home : dirname5(dirname5(codexRoot)));
2824
2923
  rememberTarget(harness, skillRoot);
2825
2924
  }
2826
2925
  } else {
2827
- const sharedRoot = join3(root, ".agents", "skills");
2926
+ const sharedRoot = join4(root, ".agents", "skills");
2828
2927
  planSkillTree(sharedRoot);
2829
- const claudeRoot = join3(root, ".claude", "skills");
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(join3(sourceDir, skill, "SKILL.md"), "utf8");
2835
- plan(join3(claudeRoot, skill, "SKILL.md"), claudeAdapter(skill, canonical));
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 = join3(root, ".cursor", "rules", "odla.mdc");
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 = join3(root, "AGENTS.md");
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 = join3(root, ".github", "copilot-instructions.md");
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 = join3(root, "GEMINI.md");
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(dirname4(file.target), { recursive: true });
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 = join3(current, part);
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 = join3(current, entry.name);
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}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
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.access !== "read") throw new Error("calendar connection is not read-only");
3071
- const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
3072
- if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
3073
- const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
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, resync, or disconnect a read-only Google mirror.
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 !== "resync" && sub !== "disconnect") {
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
- CAPABILITIES,
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-DC4MIB4X.js.map
4252
+ //# sourceMappingURL=chunk-Y4HWUEFF.js.map