@odla-ai/cli 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33,8 +33,16 @@ var index_exports = {};
33
33
  __export(index_exports, {
34
34
  AGENT_HARNESSES: () => AGENT_HARNESSES,
35
35
  CAPABILITIES: () => CAPABILITIES,
36
+ GOOGLE_CALENDAR_READ_SCOPE: () => GOOGLE_CALENDAR_READ_SCOPE,
36
37
  SYSTEM_AI_PURPOSES: () => SYSTEM_AI_PURPOSES,
37
38
  adminAi: () => adminAi,
39
+ calendarBookingPageUrl: () => calendarBookingPageUrl,
40
+ calendarCalendars: () => calendarCalendars,
41
+ calendarConnect: () => calendarConnect,
42
+ calendarDisconnect: () => calendarDisconnect,
43
+ calendarResync: () => calendarResync,
44
+ calendarServiceConfig: () => calendarServiceConfig,
45
+ calendarStatus: () => calendarStatus,
38
46
  connectGitHubSecuritySource: () => connectGitHubSecuritySource,
39
47
  disconnectGitHubSecuritySource: () => disconnectGitHubSecuritySource,
40
48
  doctor: () => doctor,
@@ -244,14 +252,16 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
244
252
  return cached.token;
245
253
  }
246
254
  const browser = approvalBrowser(options);
255
+ const email = handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0);
247
256
  const { token, expiresAt } = await (0, import_db.requestToken)({
248
257
  endpoint: cfg.platformUrl,
258
+ email,
249
259
  label: `${cfg.app.id} provisioner`,
250
260
  fetch: doFetch,
251
- onCode: async ({ userCode, expiresIn }) => {
252
- const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
261
+ onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
262
+ const approvalUrl = verificationUriComplete ?? handshakeUrl(cfg.platformUrl, userCode);
253
263
  out.log("");
254
- out.log(`Approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
264
+ out.log(`Review, then approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
255
265
  if (browser.open) {
256
266
  try {
257
267
  await (options.openApprovalUrl ?? openUrl)(approvalUrl);
@@ -265,10 +275,17 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
265
275
  out.log("");
266
276
  }
267
277
  });
268
- writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
278
+ writePrivateJson(cfg.local.tokenFile, { platform: audience, email, token, expiresAt });
269
279
  out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
270
280
  return token;
271
281
  }
282
+ function handshakeEmail(value, cached) {
283
+ const email = (value ?? import_node_process2.default.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
284
+ if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
285
+ throw new Error("a fresh odla handshake requires --email <account> or ODLA_USER_EMAIL");
286
+ }
287
+ return email;
288
+ }
272
289
  function approvalBrowser(options) {
273
290
  if (options.open === true) return { open: true, mode: "forced" };
274
291
  if (options.open === false) return { open: false, reason: "disabled by --no-open" };
@@ -361,14 +378,16 @@ async function scopedToken(platform, scope, options, doFetch, out) {
361
378
  out.log(`auth: using cached ${scope} grant (${tokenFile})`);
362
379
  return cached.token;
363
380
  }
381
+ const email = handshakeEmail(options.email, cache?.platform === audience ? cache.email : void 0);
364
382
  const { token, expiresAt } = await (0, import_db2.requestToken)({
365
383
  endpoint: audience,
384
+ email,
366
385
  label: `odla CLI admin AI (${scope})`,
367
386
  scopes: [scope],
368
387
  fetch: doFetch,
369
- onCode: async ({ userCode, expiresIn }) => {
370
- const approvalUrl = handshakeUrl(audience, userCode);
371
- out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
388
+ onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
389
+ const approvalUrl = verificationUriComplete ?? handshakeUrl(audience, userCode);
390
+ out.log(`Review, then approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
372
391
  const shouldOpen = options.open === true || options.open !== false && !import_node_process4.default.env.CI && Boolean(import_node_process4.default.stdin.isTTY && import_node_process4.default.stdout.isTTY);
373
392
  if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
374
393
  }
@@ -376,7 +395,7 @@ async function scopedToken(platform, scope, options, doFetch, out) {
376
395
  const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
377
396
  tokens[scope] = { token, expiresAt };
378
397
  if ((0, import_node_fs2.existsSync)(`${import_node_process4.default.cwd()}/.git`)) ensureGitignore(import_node_process4.default.cwd(), [tokenFile]);
379
- writePrivateJson(tokenFile, { platform: audience, tokens });
398
+ writePrivateJson(tokenFile, { platform: audience, email, tokens });
380
399
  out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
381
400
  return token;
382
401
  }
@@ -546,6 +565,7 @@ async function adminAi(options) {
546
565
  platform,
547
566
  scope,
548
567
  token: options.token,
568
+ email: options.email,
549
569
  open: options.open,
550
570
  fetch: doFetch,
551
571
  stdout: out,
@@ -805,6 +825,7 @@ async function adminCommand(parsed) {
805
825
  "platform",
806
826
  "json",
807
827
  "open",
828
+ "email",
808
829
  "provider",
809
830
  "model",
810
831
  "enabled",
@@ -832,6 +853,7 @@ async function adminCommand(parsed) {
832
853
  maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
833
854
  maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
834
855
  platform: stringOpt(parsed.options.platform),
856
+ email: stringOpt(parsed.options.email),
835
857
  json: parsed.options.json === true,
836
858
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
837
859
  credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
@@ -851,11 +873,13 @@ async function adminCommand(parsed) {
851
873
  // src/capabilities.ts
852
874
  var CAPABILITIES = {
853
875
  cli: [
876
+ "start an email-bound device request without accepting a password or user session token",
854
877
  "register the app and enable configured services per environment",
855
878
  "issue, persist, and explicitly rotate configured service credentials",
856
879
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
857
880
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
858
881
  "push db schema/rules and configure platform AI, auth, and deployment links",
882
+ "apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
859
883
  "validate config offline and smoke-test a provisioned db environment",
860
884
  "run app-attributed hosted security discovery and independent validation without provider keys",
861
885
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
@@ -864,10 +888,12 @@ var CAPABILITIES = {
864
888
  agent: [
865
889
  "install and import the selected odla SDKs",
866
890
  "wrap the Worker with withObservability and choose useful telemetry",
867
- "make application-specific schema, rules, auth, UI, and migration decisions"
891
+ "make application-specific schema, rules, auth, UI, and migration decisions",
892
+ "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
868
893
  ],
869
894
  human: [
870
- "approve the odla device code and one-off third-party logins",
895
+ "provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
896
+ "review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
871
897
  "consent to production changes with --yes",
872
898
  "request destructive credential rotation explicitly",
873
899
  "review application semantics, security findings, releases, and merges",
@@ -876,6 +902,8 @@ var CAPABILITIES = {
876
902
  ],
877
903
  studio: [
878
904
  "view telemetry and environment state",
905
+ "let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
906
+ "review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
879
907
  "perform manual credential recovery when the CLI's local shown-once copy is unavailable",
880
908
  "configure system AI purposes and view app/environment/run-attributed usage",
881
909
  "connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
@@ -905,6 +933,7 @@ var import_node_url = require("url");
905
933
  var DEFAULT_PLATFORM = "https://odla.ai";
906
934
  var DEFAULT_ENVS = ["dev"];
907
935
  var DEFAULT_SERVICES = ["db", "ai"];
936
+ var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
908
937
  async function loadProjectConfig(configPath = "odla.config.mjs") {
909
938
  const resolved = (0, import_node_path2.resolve)(configPath);
910
939
  if (!(0, import_node_fs3.existsSync)(resolved)) {
@@ -917,6 +946,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
917
946
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
918
947
  const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
919
948
  const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
949
+ validateCalendarConfig(raw, envs, services, resolved);
920
950
  const local = {
921
951
  tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
922
952
  credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
@@ -961,6 +991,28 @@ function buildPlan(cfg) {
961
991
  aiProvider: cfg.ai?.provider
962
992
  };
963
993
  }
994
+ function calendarServiceConfig(cfg, env) {
995
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
996
+ if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
997
+ const google = cfg.calendar?.google;
998
+ if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
999
+ return {
1000
+ provider: "google",
1001
+ access: "read",
1002
+ calendars: unique(google.calendars[env].map((id) => id.trim())),
1003
+ match: {
1004
+ organizerSelf: google.match?.organizerSelf ?? true,
1005
+ requireAttendees: google.match?.requireAttendees ?? true,
1006
+ ...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
1007
+ },
1008
+ attendeePolicy: google.attendeePolicy ?? "full"
1009
+ };
1010
+ }
1011
+ function calendarBookingPageUrl(cfg, env) {
1012
+ const value = cfg.calendar?.google.bookingPageUrl?.[env];
1013
+ if (value === void 0 || value === null) return value;
1014
+ return new URL(value).toString();
1015
+ }
964
1016
  function rulesFromSchema(schema) {
965
1017
  const entities = serializedEntities(schema);
966
1018
  return Object.fromEntries(
@@ -984,7 +1036,85 @@ function validateRawConfig(raw, path) {
984
1036
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
985
1037
  if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
986
1038
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
1039
+ if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
1040
+ throw new Error(`${path}: envs must be an array of names`);
1041
+ }
987
1042
  if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1043
+ if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
1044
+ throw new Error(`${path}: services must be an array of non-empty names`);
1045
+ }
1046
+ }
1047
+ function validateCalendarConfig(cfg, envs, services, path) {
1048
+ const enabled = services.includes("calendar");
1049
+ if (!cfg.calendar) {
1050
+ if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
1051
+ return;
1052
+ }
1053
+ if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1054
+ assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
1055
+ if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1056
+ const google = cfg.calendar.google;
1057
+ assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
1058
+ if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
1059
+ const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
1060
+ if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
1061
+ for (const env of envs) {
1062
+ const ids = google.calendars[env];
1063
+ if (!Array.isArray(ids) || ids.length === 0) {
1064
+ throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
1065
+ }
1066
+ if (ids.length > 10) {
1067
+ throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
1068
+ }
1069
+ if (ids.some((id) => !safeText(id, 1024))) {
1070
+ throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
1071
+ }
1072
+ }
1073
+ if (google.bookingPageUrl !== void 0) {
1074
+ if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1075
+ const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1076
+ if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1077
+ for (const [env, value] of Object.entries(google.bookingPageUrl)) {
1078
+ if (value !== null && !safeHttpsUrl(value)) {
1079
+ throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
1080
+ }
1081
+ }
1082
+ }
1083
+ if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
1084
+ throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
1085
+ }
1086
+ if (google.match !== void 0) {
1087
+ if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
1088
+ assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
1089
+ if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
1090
+ throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
1091
+ }
1092
+ for (const name of ["organizerSelf", "requireAttendees"]) {
1093
+ if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
1094
+ throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
1095
+ }
1096
+ }
1097
+ }
1098
+ if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
1099
+ }
1100
+ function assertOnly(value, allowed, label) {
1101
+ const extra = Object.keys(value).find((key) => !allowed.includes(key));
1102
+ if (extra) throw new Error(`${label}.${extra} is not supported`);
1103
+ }
1104
+ function isRecord4(value) {
1105
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1106
+ }
1107
+ function safeText(value, max) {
1108
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1109
+ }
1110
+ function safeHttpsUrl(value) {
1111
+ if (typeof value !== "string" || value.length > 2048) return false;
1112
+ try {
1113
+ const url = new URL(value);
1114
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
1115
+ } catch {
1116
+ return false;
1117
+ }
988
1118
  }
989
1119
  function validId(value) {
990
1120
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
@@ -1003,11 +1133,6 @@ function unique(values) {
1003
1133
  return [...new Set(values.filter(Boolean))];
1004
1134
  }
1005
1135
 
1006
- // src/doctor-checks.ts
1007
- var import_node_child_process3 = require("child_process");
1008
- var import_node_fs5 = require("fs");
1009
- var import_node_path4 = require("path");
1010
-
1011
1136
  // src/redact.ts
1012
1137
  var REPLACEMENTS = [
1013
1138
  [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
@@ -1027,6 +1152,427 @@ function looksSecret(value) {
1027
1152
  return redactSecrets(value) !== value || value.includes("-----BEGIN");
1028
1153
  }
1029
1154
 
1155
+ // src/calendar-http.ts
1156
+ var CALENDAR_STATES = [
1157
+ "not_connected",
1158
+ "authorizing",
1159
+ "needs_sync",
1160
+ "initial_sync",
1161
+ "healthy",
1162
+ "degraded",
1163
+ "disconnected",
1164
+ "failed"
1165
+ ];
1166
+ async function readCalendarStatus(ctx) {
1167
+ return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
1168
+ }
1169
+ async function discoverGoogleCalendars(ctx) {
1170
+ const raw = await calendarJson(ctx, "/calendars", {});
1171
+ const value = record(raw);
1172
+ if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
1173
+ return value.calendars.map((item, index) => {
1174
+ const calendar = record(item);
1175
+ const id = textField(calendar?.id, 1024);
1176
+ if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
1177
+ const role = calendar.accessRole;
1178
+ if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
1179
+ throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
1180
+ }
1181
+ return {
1182
+ id,
1183
+ ...optionalText("summary", calendar.summary, 500),
1184
+ ...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
1185
+ ...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
1186
+ ...typeof role === "string" ? { accessRole: role } : {}
1187
+ };
1188
+ });
1189
+ }
1190
+ async function applyCalendarSettings(ctx, bookingPageUrl) {
1191
+ return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
1192
+ }
1193
+ async function startCalendarConnection(ctx) {
1194
+ const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
1195
+ const value = wrapped(raw, "attempt");
1196
+ const attemptId = textField(value.attemptId, 180);
1197
+ const consentUrl = textField(value.consentUrl, 4096);
1198
+ const expiresAt = timestamp3(value.expiresAt);
1199
+ if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
1200
+ return { attemptId, consentUrl, expiresAt };
1201
+ }
1202
+ async function pollCalendarConnection(ctx, attemptId) {
1203
+ return parseCalendarStatus(
1204
+ await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
1205
+ ctx.env
1206
+ );
1207
+ }
1208
+ async function requestCalendarResync(ctx) {
1209
+ return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
1210
+ }
1211
+ async function requestCalendarDisconnect(ctx) {
1212
+ return parseCalendarStatus(
1213
+ await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
1214
+ ctx.env
1215
+ );
1216
+ }
1217
+ function parseCalendarStatus(raw, env) {
1218
+ const outer = wrapped(raw, "calendar");
1219
+ const value = record(outer.attempt) ?? record(outer.status) ?? outer;
1220
+ const connection = record(value.connection) ?? {};
1221
+ const sync = record(value.sync) ?? record(connection.sync) ?? {};
1222
+ const config = record(value.config) ?? record(outer.config) ?? {};
1223
+ const googleConfig = record(config.google) ?? config;
1224
+ const watches = record(value.watches) ?? {};
1225
+ const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
1226
+ if (!stateValue) {
1227
+ throw new Error("calendar status returned an invalid connection state");
1228
+ }
1229
+ const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
1230
+ if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
1231
+ throw new Error("calendar status returned an unsupported provider");
1232
+ }
1233
+ const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
1234
+ if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
1235
+ const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
1236
+ if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
1237
+ throw new Error("calendar status returned an invalid attendee policy");
1238
+ }
1239
+ const errorValue = record(value.error) ?? record(connection.error);
1240
+ const errorCode = textField(value.lastErrorCode, 128);
1241
+ const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
1242
+ return {
1243
+ env,
1244
+ enabled: typeof value.enabled === "boolean" ? value.enabled : true,
1245
+ connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
1246
+ provider: providerValue === "google" ? "google" : null,
1247
+ status: stateValue,
1248
+ ...accessValue === "read" ? { access: "read" } : {},
1249
+ calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
1250
+ ...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
1251
+ ...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
1252
+ ...optionalNullableUrl("bookingPageUrl", bookingPageValue),
1253
+ grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
1254
+ ...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
1255
+ ...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
1256
+ ...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
1257
+ ...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
1258
+ ...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
1259
+ ...errorValue || errorCode ? { error: {
1260
+ ...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
1261
+ ...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
1262
+ ...!errorValue && errorCode ? { code: errorCode } : {}
1263
+ } } : {}
1264
+ };
1265
+ }
1266
+ function calendarState(value) {
1267
+ if (typeof value !== "string") return null;
1268
+ if (CALENDAR_STATES.includes(value)) return value;
1269
+ const legacy = {
1270
+ disabled: "not_connected",
1271
+ disconnected: "disconnected",
1272
+ connecting: "authorizing",
1273
+ syncing: "initial_sync",
1274
+ ready: "healthy",
1275
+ error: "failed"
1276
+ };
1277
+ return legacy[value] ?? null;
1278
+ }
1279
+ async function calendarJson(ctx, suffix, init) {
1280
+ const platform = platformAudience(ctx.platform);
1281
+ const appId = identifier(ctx.appId, "appId");
1282
+ const env = identifier(ctx.env, "env");
1283
+ const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
1284
+ url.searchParams.set("env", env);
1285
+ const token = credential(ctx.token);
1286
+ let response;
1287
+ try {
1288
+ response = await (ctx.fetch ?? fetch)(url, {
1289
+ ...init,
1290
+ redirect: "error",
1291
+ credentials: "omit",
1292
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
1293
+ });
1294
+ } catch (error) {
1295
+ throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
1296
+ }
1297
+ const body = await response.json().catch(() => ({}));
1298
+ if (!response.ok) {
1299
+ const code = textField(body.error?.code, 128);
1300
+ const message = textField(body.error?.message, 500);
1301
+ throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
1302
+ }
1303
+ return body;
1304
+ }
1305
+ function wrapped(raw, key) {
1306
+ const outer = record(raw);
1307
+ if (!outer) throw new Error("calendar returned an invalid response");
1308
+ return record(outer[key]) ?? outer;
1309
+ }
1310
+ function record(value) {
1311
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1312
+ }
1313
+ function textField(value, max) {
1314
+ return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
1315
+ }
1316
+ function stringList(value) {
1317
+ return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
1318
+ }
1319
+ function calendarIds(value) {
1320
+ if (!Array.isArray(value)) return [];
1321
+ return [...new Set(value.flatMap((item) => {
1322
+ if (typeof item === "string") return textField(item, 4096) ? [item] : [];
1323
+ const calendar = record(item);
1324
+ const id = textField(calendar?.id, 4096);
1325
+ return id && calendar?.selected !== false ? [id] : [];
1326
+ }))];
1327
+ }
1328
+ function timestamp3(value) {
1329
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
1330
+ if (typeof value === "string") {
1331
+ const parsed = Date.parse(value);
1332
+ if (Number.isFinite(parsed)) return parsed;
1333
+ }
1334
+ return void 0;
1335
+ }
1336
+ function optionalText(key, value, max) {
1337
+ const parsed = textField(value, max);
1338
+ return parsed ? { [key]: parsed } : {};
1339
+ }
1340
+ function optionalNumber(key, value) {
1341
+ const parsed = timestamp3(value);
1342
+ return parsed === void 0 ? {} : { [key]: parsed };
1343
+ }
1344
+ function optionalInteger(key, value) {
1345
+ return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
1346
+ }
1347
+ function optionalNullableUrl(key, value) {
1348
+ if (value === null) return { [key]: null };
1349
+ const parsed = textField(value, 4096);
1350
+ return parsed ? { [key]: parsed } : {};
1351
+ }
1352
+ function identifier(value, name) {
1353
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
1354
+ return value;
1355
+ }
1356
+ function credential(value) {
1357
+ if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
1358
+ throw new Error("calendar requires an odla developer token");
1359
+ }
1360
+ return value;
1361
+ }
1362
+
1363
+ // src/calendar-poll.ts
1364
+ async function waitForCalendarPoll(milliseconds, signal) {
1365
+ if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
1366
+ await new Promise((resolve7, reject) => {
1367
+ const timer = setTimeout(resolve7, milliseconds);
1368
+ signal?.addEventListener("abort", () => {
1369
+ clearTimeout(timer);
1370
+ reject(signal.reason ?? new Error("calendar connection aborted"));
1371
+ }, { once: true });
1372
+ });
1373
+ }
1374
+
1375
+ // src/calendar.ts
1376
+ async function calendarStatus(options) {
1377
+ const { ctx, out } = await lifecycleContext(options);
1378
+ const status = await readCalendarStatus(ctx);
1379
+ printStatus(status, options.json === true, out);
1380
+ return status;
1381
+ }
1382
+ async function calendarCalendars(options) {
1383
+ const { ctx, out } = await lifecycleContext(options);
1384
+ const calendars = await discoverGoogleCalendars(ctx);
1385
+ if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
1386
+ else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
1387
+ else for (const calendar of calendars) {
1388
+ out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
1389
+ }
1390
+ return calendars;
1391
+ }
1392
+ async function calendarConnect(options) {
1393
+ const { cfg, ctx, out } = await lifecycleContext(options);
1394
+ productionConsent(ctx.env, options.yes, "connect calendar");
1395
+ const page = calendarBookingPageUrl(cfg, ctx.env);
1396
+ const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
1397
+ const connectOptions = connectionOptions(options, out);
1398
+ return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
1399
+ }
1400
+ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
1401
+ if (bookingPageUrl === void 0) return null;
1402
+ const status = await applyCalendarSettings(ctx, bookingPageUrl);
1403
+ out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
1404
+ return status;
1405
+ }
1406
+ async function calendarResync(options) {
1407
+ const { ctx, out } = await lifecycleContext(options);
1408
+ productionConsent(ctx.env, options.yes, "resync calendar");
1409
+ const status = await requestCalendarResync(ctx);
1410
+ out.log(`${ctx.env}: calendar resync requested (${status.status})`);
1411
+ return status;
1412
+ }
1413
+ async function calendarDisconnect(options) {
1414
+ if (!options.yes) throw new Error("calendar disconnect requires --yes");
1415
+ const { ctx, out } = await lifecycleContext(options);
1416
+ const status = await requestCalendarDisconnect(ctx);
1417
+ out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
1418
+ return status;
1419
+ }
1420
+ async function ensureCalendarConnected(ctx, options) {
1421
+ const out = options.stdout ?? console;
1422
+ const current = await readCalendarStatus(ctx);
1423
+ const connectOptions = connectionOptions(options, out);
1424
+ return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
1425
+ }
1426
+ async function lifecycleContext(options) {
1427
+ const cfg = await loadProjectConfig(options.configPath);
1428
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
1429
+ const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
1430
+ if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
1431
+ calendarServiceConfig(cfg, env);
1432
+ const out = options.stdout ?? console;
1433
+ const doFetch = options.fetch ?? fetch;
1434
+ const token = await getDeveloperToken(
1435
+ cfg,
1436
+ {
1437
+ configPath: cfg.configPath,
1438
+ token: options.token,
1439
+ email: options.email,
1440
+ open: options.open,
1441
+ interactive: options.interactive,
1442
+ openApprovalUrl: options.openConsentUrl
1443
+ },
1444
+ doFetch,
1445
+ out
1446
+ );
1447
+ return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
1448
+ }
1449
+ function connectionOptions(options, out) {
1450
+ const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
1451
+ return {
1452
+ out,
1453
+ open: browser.open,
1454
+ openConsentUrl: options.openConsentUrl,
1455
+ wait: options.wait,
1456
+ pollTimeoutMs: options.pollTimeoutMs,
1457
+ now: options.now,
1458
+ signal: options.signal
1459
+ };
1460
+ }
1461
+ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
1462
+ if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
1463
+ options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
1464
+ return current;
1465
+ }
1466
+ if (current.status === "degraded") return null;
1467
+ if (current.status === "needs_sync") {
1468
+ if (!current.connected) return null;
1469
+ options.out.log(`${ctx.env}: calendar configuration needs sync`);
1470
+ const synced = await requestCalendarResync(ctx);
1471
+ options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
1472
+ return synced;
1473
+ }
1474
+ if (current.status === "failed" && current.connected) {
1475
+ const reason = current.error?.message ?? current.error?.code;
1476
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1477
+ }
1478
+ const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
1479
+ if (!waitingForExisting) return null;
1480
+ const now = options.now ?? Date.now;
1481
+ const deadline = now() + pollTimeout(options.pollTimeoutMs);
1482
+ const wait = options.wait ?? waitForCalendarPoll;
1483
+ let prior = "";
1484
+ while (now() < deadline) {
1485
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1486
+ const status = await readCalendarStatus(ctx);
1487
+ if (status.status !== prior) {
1488
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1489
+ prior = status.status;
1490
+ }
1491
+ if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
1492
+ if (status.status === "degraded") return null;
1493
+ if (status.status === "needs_sync") return requestCalendarResync(ctx);
1494
+ if (status.status === "failed" && status.connected) {
1495
+ const reason = status.error?.message ?? status.error?.code;
1496
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1497
+ }
1498
+ if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
1499
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1500
+ }
1501
+ throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
1502
+ }
1503
+ async function connectWithContext(ctx, options) {
1504
+ const attempt = await startCalendarConnection(ctx);
1505
+ const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
1506
+ options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
1507
+ if (options.open) {
1508
+ await (options.openConsentUrl ?? openUrl)(consentUrl);
1509
+ options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
1510
+ }
1511
+ const now = options.now ?? Date.now;
1512
+ const timeout = pollTimeout(options.pollTimeoutMs);
1513
+ const deadline = Math.min(attempt.expiresAt, now() + timeout);
1514
+ const wait = options.wait ?? waitForCalendarPoll;
1515
+ let prior = "";
1516
+ while (now() < deadline) {
1517
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1518
+ const status = await pollCalendarConnection(ctx, attempt.attemptId);
1519
+ if (status.status !== prior) {
1520
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1521
+ prior = status.status;
1522
+ }
1523
+ if (status.status === "healthy" || status.status === "degraded") return status;
1524
+ if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
1525
+ const reason = status.error?.message ?? status.error?.code;
1526
+ throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
1527
+ }
1528
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1529
+ }
1530
+ throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
1531
+ }
1532
+ function trustedCalendarConsentUrl(platform, value) {
1533
+ const origin = platformAudience(platform);
1534
+ let url;
1535
+ try {
1536
+ url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
1537
+ } catch {
1538
+ throw new Error("odla.ai returned an invalid calendar consent URL");
1539
+ }
1540
+ const platformPage = url.origin === origin;
1541
+ const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
1542
+ if (!platformPage && !googlePage || url.username || url.password || url.hash) {
1543
+ throw new Error("odla.ai returned an untrusted calendar consent URL");
1544
+ }
1545
+ return url.toString();
1546
+ }
1547
+ function pollTimeout(value = 10 * 6e4) {
1548
+ if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
1549
+ return value;
1550
+ }
1551
+ function productionConsent(env, yes, action) {
1552
+ if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
1553
+ }
1554
+ function printStatus(status, json, out) {
1555
+ if (json) {
1556
+ out.log(JSON.stringify(status, null, 2));
1557
+ return;
1558
+ }
1559
+ out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
1560
+ out.log(` access: ${status.access ?? "not granted"}`);
1561
+ out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
1562
+ if (status.attendeePolicy) {
1563
+ out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
1564
+ }
1565
+ if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
1566
+ out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
1567
+ if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
1568
+ if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
1569
+ }
1570
+
1571
+ // src/doctor-checks.ts
1572
+ var import_node_child_process3 = require("child_process");
1573
+ var import_node_fs5 = require("fs");
1574
+ var import_node_path4 = require("path");
1575
+
1030
1576
  // src/wrangler.ts
1031
1577
  var import_node_child_process2 = require("child_process");
1032
1578
  var import_node_fs4 = require("fs");
@@ -1207,6 +1753,15 @@ function o11yProjectWarnings(rootDir) {
1207
1753
  }
1208
1754
  return warnings;
1209
1755
  }
1756
+ function calendarProjectWarnings(rootDir) {
1757
+ const pkg = readPackageJson(rootDir);
1758
+ const dependencies = pkg?.dependencies;
1759
+ const devDependencies = pkg?.devDependencies;
1760
+ if (dependencies?.["@odla-ai/calendar"]) return [];
1761
+ return [
1762
+ devDependencies?.["@odla-ai/calendar"] ? "@odla-ai/calendar is a devDependency \u2014 move it to dependencies so trusted Worker code can import it" : "calendar service is enabled but @odla-ai/calendar is not installed in dependencies"
1763
+ ];
1764
+ }
1210
1765
  function readPackageJson(rootDir) {
1211
1766
  try {
1212
1767
  return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
@@ -1232,6 +1787,14 @@ async function doctor(options) {
1232
1787
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1233
1788
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1234
1789
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1790
+ if (cfg.services.includes("calendar")) {
1791
+ const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
1792
+ out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
1793
+ const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
1794
+ out.log(`booking pages: ${pages}`);
1795
+ } else {
1796
+ out.log("calendar: not enabled");
1797
+ }
1235
1798
  const warnings = [];
1236
1799
  if (schema && entities.length === 0) warnings.push("schema has no entities");
1237
1800
  if (rules) {
@@ -1259,6 +1822,8 @@ async function doctor(options) {
1259
1822
  }
1260
1823
  warnings.push(...o11yProjectWarnings(cfg.rootDir));
1261
1824
  }
1825
+ if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
1826
+ else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
1262
1827
  warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
1263
1828
  warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
1264
1829
  cfg.local.tokenFile,
@@ -1286,10 +1851,15 @@ function printHelp() {
1286
1851
 
1287
1852
  Usage:
1288
1853
  odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
1289
- odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
1854
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
1290
1855
  odla-ai doctor [--config odla.config.mjs]
1856
+ odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
1857
+ odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
1858
+ odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
1859
+ odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
1860
+ odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
1291
1861
  odla-ai capabilities [--json]
1292
- odla-ai admin ai show [--platform https://odla.ai] [--json]
1862
+ odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
1293
1863
  odla-ai admin ai models [--provider <id>] [--json]
1294
1864
  odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
1295
1865
  odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
@@ -1298,7 +1868,7 @@ Usage:
1298
1868
  odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
1299
1869
  odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
1300
1870
  odla-ai admin ai audit [--limit <1-200>] [--json]
1301
- odla-ai security github connect [--repo owner/name] [--env dev] [--no-open]
1871
+ odla-ai security github connect [--repo owner/name] [--env dev] [--email <odla-account>] [--no-open]
1302
1872
  odla-ai security github disconnect --source <id> [--env dev] [--yes]
1303
1873
  odla-ai security plan [--env dev] [--json]
1304
1874
  odla-ai security sources [--env dev] [--json]
@@ -1307,18 +1877,19 @@ Usage:
1307
1877
  odla-ai security report <job-id> [--json]
1308
1878
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1309
1879
  odla-ai security run [target] --self --ack-redacted-source
1310
- odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
1311
- odla-ai smoke [--config odla.config.mjs] [--env dev]
1880
+ odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
1881
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
1312
1882
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
1313
1883
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1314
- odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
1315
- odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
1884
+ odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
1885
+ odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
1316
1886
  odla-ai version
1317
1887
 
1318
1888
  Commands:
1319
1889
  setup Install offline odla runbooks for common coding-agent harnesses.
1320
1890
  init Create a generic odla.config.mjs plus starter schema/rules files.
1321
1891
  doctor Validate and summarize the project config without network calls.
1892
+ calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
1322
1893
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1323
1894
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1324
1895
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
@@ -1339,6 +1910,12 @@ Safety:
1339
1910
  preflights Wrangler before any shown-once issuance or destructive rotation.
1340
1911
  Provision opens the approval page automatically in interactive terminals;
1341
1912
  use --open to force browser launch or --no-open to suppress it.
1913
+ A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
1914
+ The email is a non-secret identity hint: never provide a password or session
1915
+ token. The matching account must already exist, be signed in, explicitly
1916
+ review the exact code, and finish any current request before claiming another.
1917
+ Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
1918
+ OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
1342
1919
  GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
1343
1920
  for a PAT or provider key. GitHub read access is separate from the explicit
1344
1921
  --ack-redacted-source consent and the exact --plan-digest printed by security
@@ -1383,6 +1960,7 @@ function initProject(options) {
1383
1960
  }
1384
1961
  const envs = options.envs?.length ? options.envs : ["dev"];
1385
1962
  const services = options.services?.length ? options.services : ["db", "ai"];
1963
+ if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
1386
1964
  const aiProvider = options.aiProvider ?? "anthropic";
1387
1965
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
1388
1966
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
@@ -1400,6 +1978,16 @@ function writeIfMissing(path, text) {
1400
1978
  (0, import_node_fs7.writeFileSync)(path, text);
1401
1979
  }
1402
1980
  function configTemplate(input) {
1981
+ const calendar = input.services.includes("calendar") ? ` calendar: {
1982
+ google: {
1983
+ calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
1984
+ bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
1985
+ match: { organizerSelf: true, requireAttendees: true },
1986
+ attendeePolicy: "full",
1987
+ // Read-only in this release. Google consent happens in Studio during provision.
1988
+ },
1989
+ },
1990
+ ` : "";
1403
1991
  return `export default {
1404
1992
  platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
1405
1993
  dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
@@ -1421,6 +2009,7 @@ function configTemplate(input) {
1421
2009
  // key in the platform vault for each tenant.
1422
2010
  keyEnv: "${defaultKeyEnv(input.aiProvider)}",
1423
2011
  },
2012
+ ${calendar}
1424
2013
  auth: {
1425
2014
  clerk: {
1426
2015
  // dev: "$CLERK_PUBLISHABLE_KEY",
@@ -1547,14 +2136,14 @@ async function mintDbKey(opts, tenantId) {
1547
2136
  appId: tenantId
1548
2137
  })
1549
2138
  });
1550
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
2139
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
1551
2140
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1552
2141
  method: "POST",
1553
2142
  headers,
1554
2143
  body: "{}"
1555
2144
  });
1556
2145
  }
1557
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
2146
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
1558
2147
  const body = await res.json();
1559
2148
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
1560
2149
  return body.key;
@@ -1570,12 +2159,12 @@ async function issueO11yToken(opts) {
1570
2159
  `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`
1571
2160
  );
1572
2161
  }
1573
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
2162
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
1574
2163
  const body = await res.json();
1575
2164
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
1576
2165
  return body.token;
1577
2166
  }
1578
- async function safeText(res) {
2167
+ async function safeText2(res) {
1579
2168
  try {
1580
2169
  return redactSecrets((await res.text()).slice(0, 500));
1581
2170
  } catch {
@@ -1688,6 +2277,14 @@ async function provision(options) {
1688
2277
  out.log(` schema: ${schema ? "yes" : "no"}`);
1689
2278
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
1690
2279
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2280
+ if (cfg.services.includes("calendar")) {
2281
+ for (const env of cfg.envs) {
2282
+ const calendar = calendarServiceConfig(cfg, env);
2283
+ out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
2284
+ const bookingPage = calendarBookingPageUrl(cfg, env);
2285
+ out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
2286
+ }
2287
+ }
1691
2288
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
1692
2289
  return;
1693
2290
  }
@@ -1722,8 +2319,9 @@ async function provision(options) {
1722
2319
  await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
1723
2320
  out.log(`app: created ${cfg.app.id}`);
1724
2321
  }
2322
+ const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
1725
2323
  for (const env of cfg.envs) {
1726
- for (const service of cfg.services) {
2324
+ for (const service of serviceOrder) {
1727
2325
  if (service === "ai") {
1728
2326
  if (cfg.ai?.provider) {
1729
2327
  await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
@@ -1733,7 +2331,8 @@ async function provision(options) {
1733
2331
  out.log(`${env}: ai enabled`);
1734
2332
  }
1735
2333
  } else {
1736
- await apps.setService(cfg.app.id, service, true, { env });
2334
+ const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
2335
+ await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
1737
2336
  out.log(`${env}: ${service} enabled`);
1738
2337
  }
1739
2338
  }
@@ -1747,6 +2346,21 @@ async function provision(options) {
1747
2346
  await apps.setLink(cfg.app.id, env, link ?? null);
1748
2347
  out.log(`${env}: link ${link ? "set" : "cleared"}`);
1749
2348
  }
2349
+ if (cfg.services.includes("calendar")) {
2350
+ const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
2351
+ await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
2352
+ await ensureCalendarConnected(
2353
+ calendarCtx,
2354
+ {
2355
+ open: options.open,
2356
+ interactive: options.interactive,
2357
+ openConsentUrl: options.openApprovalUrl,
2358
+ wait: options.calendarPollWait,
2359
+ pollTimeoutMs: options.calendarPollTimeoutMs,
2360
+ stdout: out
2361
+ }
2362
+ );
2363
+ }
1750
2364
  }
1751
2365
  for (const env of cfg.envs) {
1752
2366
  const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
@@ -1810,6 +2424,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1810
2424
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
1811
2425
  }
1812
2426
  }
2427
+ function serviceRank(service) {
2428
+ if (service === "db") return 0;
2429
+ if (service === "calendar") return 2;
2430
+ return 1;
2431
+ }
1813
2432
  async function readRegistryApp(cfg, token, doFetch) {
1814
2433
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
1815
2434
  headers: { authorization: `Bearer ${token}` }
@@ -1825,7 +2444,7 @@ async function postJson(doFetch, url, bearer, body) {
1825
2444
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
1826
2445
  body: JSON.stringify(body)
1827
2446
  });
1828
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
2447
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
1829
2448
  }
1830
2449
  function normalizeClerkConfig(value) {
1831
2450
  if (!value) return null;
@@ -1842,7 +2461,7 @@ function defaultSecretName(provider) {
1842
2461
  const names = import_ai.DEFAULT_SECRET_NAMES;
1843
2462
  return names[provider] ?? `${provider}_api_key`;
1844
2463
  }
1845
- async function safeText2(res) {
2464
+ async function safeText3(res) {
1846
2465
  try {
1847
2466
  return redactSecrets((await res.text()).slice(0, 500));
1848
2467
  } catch {
@@ -1925,7 +2544,7 @@ async function hostedSecurityContext(parsed, dependencies) {
1925
2544
  const open = parsed.options.open === false ? false : parsed.options.open === true ? true : void 0;
1926
2545
  const token = await getDeveloperToken(
1927
2546
  cfg,
1928
- { configPath, open, openApprovalUrl: dependencies.openUrl },
2547
+ { configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
1929
2548
  doFetch,
1930
2549
  stdout
1931
2550
  );
@@ -2554,6 +3173,7 @@ async function runSourceSecurityCommand(parsed, dependencies, sourceId) {
2554
3173
  "source",
2555
3174
  "ref",
2556
3175
  "follow",
3176
+ "email",
2557
3177
  "open",
2558
3178
  "json",
2559
3179
  "ack-redacted-source",
@@ -2642,6 +3262,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
2642
3262
  "platform",
2643
3263
  "self",
2644
3264
  "ack-redacted-source",
3265
+ "email",
2645
3266
  "open",
2646
3267
  "fail-on",
2647
3268
  "fail-on-candidates",
@@ -2671,6 +3292,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
2671
3292
  return getScopedPlatformToken({
2672
3293
  platform: request.platform,
2673
3294
  scope: request.scope,
3295
+ email: stringOpt(parsed.options.email),
2674
3296
  open,
2675
3297
  fetch: doFetch,
2676
3298
  stdout: out,
@@ -2683,7 +3305,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
2683
3305
  }
2684
3306
  return getDeveloperToken(
2685
3307
  cfg,
2686
- { configPath, open, openApprovalUrl: dependencies.openUrl },
3308
+ { configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
2687
3309
  doFetch,
2688
3310
  out
2689
3311
  );
@@ -2717,7 +3339,7 @@ async function securityCommand(parsed, dependencies) {
2717
3339
  return;
2718
3340
  }
2719
3341
  if (sub === "plan") {
2720
- assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
3342
+ assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
2721
3343
  const context = await hostedSecurityContext(parsed, dependencies);
2722
3344
  const plan = await getHostedSecurityPlan(context);
2723
3345
  if (parsed.options.json === true) context.stdout.log(JSON.stringify(plan, null, 2));
@@ -2733,7 +3355,7 @@ async function securityCommand(parsed, dependencies) {
2733
3355
  return;
2734
3356
  }
2735
3357
  if (sub === "report") {
2736
- assertArgs(parsed, ["config", "env", "platform", "open", "json"], 3);
3358
+ assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 3);
2737
3359
  const jobId = requiredSecurityPositional(parsed, 2, "job id");
2738
3360
  const context = await hostedSecurityContext(parsed, dependencies);
2739
3361
  const report = await getHostedSecurityReport({ ...context, jobId });
@@ -2751,7 +3373,7 @@ async function securityCommand(parsed, dependencies) {
2751
3373
  async function githubSecurityCommand(parsed, dependencies) {
2752
3374
  const action = parsed.positionals[2];
2753
3375
  if (action === "disconnect") {
2754
- assertArgs(parsed, ["config", "env", "platform", "source", "open", "yes"], 3);
3376
+ assertArgs(parsed, ["config", "env", "platform", "source", "email", "open", "yes"], 3);
2755
3377
  const context2 = await hostedSecurityContext(parsed, dependencies);
2756
3378
  const sourceId = requiredString(parsed.options.source, "--source");
2757
3379
  const confirmed = parsed.options.yes === true || await interactiveConfirmation(
@@ -2768,7 +3390,7 @@ async function githubSecurityCommand(parsed, dependencies) {
2768
3390
  if (action !== "connect") {
2769
3391
  throw new Error('unknown security github command. Try "odla-ai security github connect".');
2770
3392
  }
2771
- assertArgs(parsed, ["config", "env", "platform", "repo", "open"], 3);
3393
+ assertArgs(parsed, ["config", "env", "platform", "repo", "email", "open"], 3);
2772
3394
  const context = await hostedSecurityContext(parsed, dependencies);
2773
3395
  const repository = stringOpt(parsed.options.repo) ?? await inferGitHubRepository(process.cwd(), dependencies.readGitOrigin);
2774
3396
  const connection = await connectGitHubSecuritySource({
@@ -2783,7 +3405,7 @@ async function githubSecurityCommand(parsed, dependencies) {
2783
3405
  context.stdout.log("github: odla.ai stores the installation; no PAT or GitHub token is written locally");
2784
3406
  }
2785
3407
  async function listSecuritySources(parsed, dependencies) {
2786
- assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
3408
+ assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
2787
3409
  const context = await hostedSecurityContext(parsed, dependencies);
2788
3410
  const [plan, sources] = await Promise.all([
2789
3411
  getHostedSecurityPlan(context),
@@ -2805,7 +3427,7 @@ source repository default ref status`);
2805
3427
  }
2806
3428
  }
2807
3429
  async function securityStatus(parsed, dependencies) {
2808
- assertArgs(parsed, ["config", "env", "platform", "open", "json", "follow"], 3);
3430
+ assertArgs(parsed, ["config", "env", "platform", "email", "open", "json", "follow"], 3);
2809
3431
  const jobId = requiredSecurityPositional(parsed, 2, "job id");
2810
3432
  const context = await hostedSecurityContext(parsed, dependencies);
2811
3433
  const job = parsed.options.follow === true ? await followHostedSecurityJob({
@@ -3102,6 +3724,24 @@ async function smoke(options) {
3102
3724
  }
3103
3725
  out.log(` ai: ${provider}`);
3104
3726
  }
3727
+ if (cfg.services.includes("calendar")) {
3728
+ const token = await getDeveloperToken(
3729
+ cfg,
3730
+ {
3731
+ configPath: cfg.configPath,
3732
+ token: options.token,
3733
+ email: options.email,
3734
+ open: options.open,
3735
+ interactive: options.interactive,
3736
+ openApprovalUrl: options.openApprovalUrl
3737
+ },
3738
+ doFetch,
3739
+ out
3740
+ );
3741
+ const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
3742
+ assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
3743
+ out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
3744
+ }
3105
3745
  const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
3106
3746
  const expectedEntities = serializedEntities(expectedSchema);
3107
3747
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
@@ -3123,13 +3763,35 @@ async function smoke(options) {
3123
3763
  } else {
3124
3764
  out.log(` aggregate: skipped (schema has no entities)`);
3125
3765
  }
3766
+ if (cfg.services.includes("calendar")) {
3767
+ const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3768
+ ns: "$bookings",
3769
+ aggregate: { count: true }
3770
+ });
3771
+ const count = bookings.aggregate?.count;
3772
+ out.log(` bookings: ${String(count ?? "ok")}`);
3773
+ }
3126
3774
  out.log("ok");
3127
3775
  }
3776
+ function assertCalendarHealthy(status, expected) {
3777
+ if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
3778
+ if (status.status !== "healthy") {
3779
+ throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
3780
+ }
3781
+ if (status.access !== "read") throw new Error("calendar connection is not read-only");
3782
+ const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
3783
+ if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
3784
+ const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
3785
+ if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
3786
+ if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
3787
+ if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
3788
+ if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
3789
+ }
3128
3790
  async function getJson(doFetch, url, bearer) {
3129
3791
  const res = await doFetch(url, {
3130
3792
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
3131
3793
  });
3132
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3794
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3133
3795
  return res.json();
3134
3796
  }
3135
3797
  async function postJson2(doFetch, url, bearer, body) {
@@ -3138,7 +3800,7 @@ async function postJson2(doFetch, url, bearer, body) {
3138
3800
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
3139
3801
  body: JSON.stringify(body)
3140
3802
  });
3141
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3803
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3142
3804
  return res.json();
3143
3805
  }
3144
3806
  function publicConfigUrl(platformUrl, appId, env) {
@@ -3146,7 +3808,7 @@ function publicConfigUrl(platformUrl, appId, env) {
3146
3808
  url.searchParams.set("env", env);
3147
3809
  return url.toString();
3148
3810
  }
3149
- async function safeText3(res) {
3811
+ async function safeText4(res) {
3150
3812
  try {
3151
3813
  return (await res.text()).slice(0, 500);
3152
3814
  } catch {
@@ -3194,6 +3856,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3194
3856
  await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
3195
3857
  return;
3196
3858
  }
3859
+ if (command === "calendar") {
3860
+ await calendarCommand(parsed, dependencies);
3861
+ return;
3862
+ }
3197
3863
  if (command === "capabilities") {
3198
3864
  assertArgs(parsed, ["json"], 1);
3199
3865
  printCapabilities(parsed.options.json === true);
@@ -3208,14 +3874,20 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3208
3874
  return;
3209
3875
  }
3210
3876
  if (command === "provision") {
3211
- await provisionCommand(parsed);
3877
+ await provisionCommand(parsed, dependencies);
3212
3878
  return;
3213
3879
  }
3214
3880
  if (command === "smoke") {
3215
- assertArgs(parsed, ["config", "env"], 1);
3881
+ assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
3216
3882
  await smoke({
3217
3883
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3218
- env: stringOpt(parsed.options.env)
3884
+ env: stringOpt(parsed.options.env),
3885
+ token: stringOpt(parsed.options.token),
3886
+ email: stringOpt(parsed.options.email),
3887
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3888
+ openApprovalUrl: dependencies.openUrl,
3889
+ fetch: dependencies.fetch,
3890
+ stdout: dependencies.stdout
3219
3891
  });
3220
3892
  return;
3221
3893
  }
@@ -3249,7 +3921,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3249
3921
  if (command === "secrets") {
3250
3922
  const sub = parsed.positionals[1];
3251
3923
  if (sub === "set" || sub === "set-clerk-key") {
3252
- assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
3924
+ assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "email", "yes"], sub === "set" ? 3 : 2);
3253
3925
  const options = {
3254
3926
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3255
3927
  name: parsed.positionals[2],
@@ -3257,6 +3929,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3257
3929
  fromEnv: stringOpt(parsed.options["from-env"]),
3258
3930
  stdin: parsed.options.stdin === true,
3259
3931
  token: stringOpt(parsed.options.token),
3932
+ email: stringOpt(parsed.options.email),
3260
3933
  yes: parsed.options.yes === true,
3261
3934
  fetch: dependencies.fetch,
3262
3935
  stdout: dependencies.stdout
@@ -3280,7 +3953,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3280
3953
  }
3281
3954
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
3282
3955
  }
3283
- async function provisionCommand(parsed) {
3956
+ async function provisionCommand(parsed, dependencies) {
3284
3957
  assertArgs(parsed, [
3285
3958
  "config",
3286
3959
  "dry-run",
@@ -3290,6 +3963,7 @@ async function provisionCommand(parsed) {
3290
3963
  "write-credentials",
3291
3964
  "write-dev-vars",
3292
3965
  "token",
3966
+ "email",
3293
3967
  "open",
3294
3968
  "yes"
3295
3969
  ], 1);
@@ -3303,17 +3977,55 @@ async function provisionCommand(parsed) {
3303
3977
  writeCredentials: parsed.options["write-credentials"] !== false,
3304
3978
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
3305
3979
  token: stringOpt(parsed.options.token),
3980
+ email: stringOpt(parsed.options.email),
3306
3981
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3307
- yes: parsed.options.yes === true
3982
+ yes: parsed.options.yes === true,
3983
+ fetch: dependencies.fetch,
3984
+ openApprovalUrl: dependencies.openUrl,
3985
+ stdout: dependencies.stdout
3308
3986
  };
3309
3987
  await provision(options);
3310
3988
  }
3989
+ async function calendarCommand(parsed, dependencies) {
3990
+ const sub = parsed.positionals[1];
3991
+ if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
3992
+ throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
3993
+ }
3994
+ assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
3995
+ if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
3996
+ if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
3997
+ const options = {
3998
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3999
+ env: stringOpt(parsed.options.env),
4000
+ token: stringOpt(parsed.options.token),
4001
+ email: stringOpt(parsed.options.email),
4002
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
4003
+ yes: parsed.options.yes === true,
4004
+ json: parsed.options.json === true,
4005
+ fetch: dependencies.fetch,
4006
+ stdout: dependencies.stdout,
4007
+ openConsentUrl: dependencies.openUrl
4008
+ };
4009
+ if (sub === "status") await calendarStatus(options);
4010
+ else if (sub === "calendars") await calendarCalendars(options);
4011
+ else if (sub === "connect") await calendarConnect(options);
4012
+ else if (sub === "resync") await calendarResync(options);
4013
+ else await calendarDisconnect(options);
4014
+ }
3311
4015
  // Annotate the CommonJS export names for ESM import in node:
3312
4016
  0 && (module.exports = {
3313
4017
  AGENT_HARNESSES,
3314
4018
  CAPABILITIES,
4019
+ GOOGLE_CALENDAR_READ_SCOPE,
3315
4020
  SYSTEM_AI_PURPOSES,
3316
4021
  adminAi,
4022
+ calendarBookingPageUrl,
4023
+ calendarCalendars,
4024
+ calendarConnect,
4025
+ calendarDisconnect,
4026
+ calendarResync,
4027
+ calendarServiceConfig,
4028
+ calendarStatus,
3317
4029
  connectGitHubSecuritySource,
3318
4030
  disconnectGitHubSecuritySource,
3319
4031
  doctor,