@odla-ai/cli 0.11.2 → 0.13.0

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