@odla-ai/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -856,6 +864,7 @@ var CAPABILITIES = {
856
864
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
857
865
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
858
866
  "push db schema/rules and configure platform AI, auth, and deployment links",
867
+ "apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
859
868
  "validate config offline and smoke-test a provisioned db environment",
860
869
  "run app-attributed hosted security discovery and independent validation without provider keys",
861
870
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
@@ -864,10 +873,12 @@ var CAPABILITIES = {
864
873
  agent: [
865
874
  "install and import the selected odla SDKs",
866
875
  "wrap the Worker with withObservability and choose useful telemetry",
867
- "make application-specific schema, rules, auth, UI, and migration decisions"
876
+ "make application-specific schema, rules, auth, UI, and migration decisions",
877
+ "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
868
878
  ],
869
879
  human: [
870
880
  "approve the odla device code and one-off third-party logins",
881
+ "review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
871
882
  "consent to production changes with --yes",
872
883
  "request destructive credential rotation explicitly",
873
884
  "review application semantics, security findings, releases, and merges",
@@ -876,6 +887,7 @@ var CAPABILITIES = {
876
887
  ],
877
888
  studio: [
878
889
  "view telemetry and environment state",
890
+ "review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
879
891
  "perform manual credential recovery when the CLI's local shown-once copy is unavailable",
880
892
  "configure system AI purposes and view app/environment/run-attributed usage",
881
893
  "connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
@@ -905,6 +917,7 @@ var import_node_url = require("url");
905
917
  var DEFAULT_PLATFORM = "https://odla.ai";
906
918
  var DEFAULT_ENVS = ["dev"];
907
919
  var DEFAULT_SERVICES = ["db", "ai"];
920
+ var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
908
921
  async function loadProjectConfig(configPath = "odla.config.mjs") {
909
922
  const resolved = (0, import_node_path2.resolve)(configPath);
910
923
  if (!(0, import_node_fs3.existsSync)(resolved)) {
@@ -917,6 +930,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
917
930
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
918
931
  const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
919
932
  const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
933
+ validateCalendarConfig(raw, envs, services, resolved);
920
934
  const local = {
921
935
  tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
922
936
  credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
@@ -961,6 +975,28 @@ function buildPlan(cfg) {
961
975
  aiProvider: cfg.ai?.provider
962
976
  };
963
977
  }
978
+ function calendarServiceConfig(cfg, env) {
979
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
980
+ if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
981
+ const google = cfg.calendar?.google;
982
+ if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
983
+ return {
984
+ provider: "google",
985
+ access: "read",
986
+ calendars: unique(google.calendars[env].map((id) => id.trim())),
987
+ match: {
988
+ organizerSelf: google.match?.organizerSelf ?? true,
989
+ requireAttendees: google.match?.requireAttendees ?? true,
990
+ ...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
991
+ },
992
+ attendeePolicy: google.attendeePolicy ?? "full"
993
+ };
994
+ }
995
+ function calendarBookingPageUrl(cfg, env) {
996
+ const value = cfg.calendar?.google.bookingPageUrl?.[env];
997
+ if (value === void 0 || value === null) return value;
998
+ return new URL(value).toString();
999
+ }
964
1000
  function rulesFromSchema(schema) {
965
1001
  const entities = serializedEntities(schema);
966
1002
  return Object.fromEntries(
@@ -984,7 +1020,85 @@ function validateRawConfig(raw, path) {
984
1020
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
985
1021
  if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
986
1022
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
1023
+ if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
1024
+ throw new Error(`${path}: envs must be an array of names`);
1025
+ }
987
1026
  if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1027
+ if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
1028
+ throw new Error(`${path}: services must be an array of non-empty names`);
1029
+ }
1030
+ }
1031
+ function validateCalendarConfig(cfg, envs, services, path) {
1032
+ const enabled = services.includes("calendar");
1033
+ if (!cfg.calendar) {
1034
+ if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
1035
+ return;
1036
+ }
1037
+ if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1038
+ assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
1039
+ if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1040
+ const google = cfg.calendar.google;
1041
+ assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
1042
+ if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
1043
+ const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
1044
+ if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
1045
+ for (const env of envs) {
1046
+ const ids = google.calendars[env];
1047
+ if (!Array.isArray(ids) || ids.length === 0) {
1048
+ throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
1049
+ }
1050
+ if (ids.length > 10) {
1051
+ throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
1052
+ }
1053
+ if (ids.some((id) => !safeText(id, 1024))) {
1054
+ throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
1055
+ }
1056
+ }
1057
+ if (google.bookingPageUrl !== void 0) {
1058
+ if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1059
+ const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1060
+ if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1061
+ for (const [env, value] of Object.entries(google.bookingPageUrl)) {
1062
+ if (value !== null && !safeHttpsUrl(value)) {
1063
+ throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
1064
+ }
1065
+ }
1066
+ }
1067
+ if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
1068
+ throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
1069
+ }
1070
+ if (google.match !== void 0) {
1071
+ if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
1072
+ assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
1073
+ if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
1074
+ throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
1075
+ }
1076
+ for (const name of ["organizerSelf", "requireAttendees"]) {
1077
+ if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
1078
+ throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
1079
+ }
1080
+ }
1081
+ }
1082
+ if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
1083
+ }
1084
+ function assertOnly(value, allowed, label) {
1085
+ const extra = Object.keys(value).find((key) => !allowed.includes(key));
1086
+ if (extra) throw new Error(`${label}.${extra} is not supported`);
1087
+ }
1088
+ function isRecord4(value) {
1089
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1090
+ }
1091
+ function safeText(value, max) {
1092
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1093
+ }
1094
+ function safeHttpsUrl(value) {
1095
+ if (typeof value !== "string" || value.length > 2048) return false;
1096
+ try {
1097
+ const url = new URL(value);
1098
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
1099
+ } catch {
1100
+ return false;
1101
+ }
988
1102
  }
989
1103
  function validId(value) {
990
1104
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
@@ -1003,11 +1117,6 @@ function unique(values) {
1003
1117
  return [...new Set(values.filter(Boolean))];
1004
1118
  }
1005
1119
 
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
1120
  // src/redact.ts
1012
1121
  var REPLACEMENTS = [
1013
1122
  [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
@@ -1027,6 +1136,426 @@ function looksSecret(value) {
1027
1136
  return redactSecrets(value) !== value || value.includes("-----BEGIN");
1028
1137
  }
1029
1138
 
1139
+ // src/calendar-http.ts
1140
+ var CALENDAR_STATES = [
1141
+ "not_connected",
1142
+ "authorizing",
1143
+ "needs_sync",
1144
+ "initial_sync",
1145
+ "healthy",
1146
+ "degraded",
1147
+ "disconnected",
1148
+ "failed"
1149
+ ];
1150
+ async function readCalendarStatus(ctx) {
1151
+ return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
1152
+ }
1153
+ async function discoverGoogleCalendars(ctx) {
1154
+ const raw = await calendarJson(ctx, "/calendars", {});
1155
+ const value = record(raw);
1156
+ if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
1157
+ return value.calendars.map((item, index) => {
1158
+ const calendar = record(item);
1159
+ const id = textField(calendar?.id, 1024);
1160
+ if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
1161
+ const role = calendar.accessRole;
1162
+ if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
1163
+ throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
1164
+ }
1165
+ return {
1166
+ id,
1167
+ ...optionalText("summary", calendar.summary, 500),
1168
+ ...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
1169
+ ...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
1170
+ ...typeof role === "string" ? { accessRole: role } : {}
1171
+ };
1172
+ });
1173
+ }
1174
+ async function applyCalendarSettings(ctx, bookingPageUrl) {
1175
+ return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
1176
+ }
1177
+ async function startCalendarConnection(ctx) {
1178
+ const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
1179
+ const value = wrapped(raw, "attempt");
1180
+ const attemptId = textField(value.attemptId, 180);
1181
+ const consentUrl = textField(value.consentUrl, 4096);
1182
+ const expiresAt = timestamp3(value.expiresAt);
1183
+ if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
1184
+ return { attemptId, consentUrl, expiresAt };
1185
+ }
1186
+ async function pollCalendarConnection(ctx, attemptId) {
1187
+ return parseCalendarStatus(
1188
+ await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
1189
+ ctx.env
1190
+ );
1191
+ }
1192
+ async function requestCalendarResync(ctx) {
1193
+ return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
1194
+ }
1195
+ async function requestCalendarDisconnect(ctx) {
1196
+ return parseCalendarStatus(
1197
+ await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
1198
+ ctx.env
1199
+ );
1200
+ }
1201
+ function parseCalendarStatus(raw, env) {
1202
+ const outer = wrapped(raw, "calendar");
1203
+ const value = record(outer.attempt) ?? record(outer.status) ?? outer;
1204
+ const connection = record(value.connection) ?? {};
1205
+ const sync = record(value.sync) ?? record(connection.sync) ?? {};
1206
+ const config = record(value.config) ?? record(outer.config) ?? {};
1207
+ const googleConfig = record(config.google) ?? config;
1208
+ const watches = record(value.watches) ?? {};
1209
+ const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
1210
+ if (!stateValue) {
1211
+ throw new Error("calendar status returned an invalid connection state");
1212
+ }
1213
+ const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
1214
+ if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
1215
+ throw new Error("calendar status returned an unsupported provider");
1216
+ }
1217
+ const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
1218
+ if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
1219
+ const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
1220
+ if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
1221
+ throw new Error("calendar status returned an invalid attendee policy");
1222
+ }
1223
+ const errorValue = record(value.error) ?? record(connection.error);
1224
+ const errorCode = textField(value.lastErrorCode, 128);
1225
+ const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
1226
+ return {
1227
+ env,
1228
+ enabled: typeof value.enabled === "boolean" ? value.enabled : true,
1229
+ connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
1230
+ provider: providerValue === "google" ? "google" : null,
1231
+ status: stateValue,
1232
+ ...accessValue === "read" ? { access: "read" } : {},
1233
+ calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
1234
+ ...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
1235
+ ...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
1236
+ ...optionalNullableUrl("bookingPageUrl", bookingPageValue),
1237
+ grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
1238
+ ...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
1239
+ ...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
1240
+ ...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
1241
+ ...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
1242
+ ...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
1243
+ ...errorValue || errorCode ? { error: {
1244
+ ...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
1245
+ ...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
1246
+ ...!errorValue && errorCode ? { code: errorCode } : {}
1247
+ } } : {}
1248
+ };
1249
+ }
1250
+ function calendarState(value) {
1251
+ if (typeof value !== "string") return null;
1252
+ if (CALENDAR_STATES.includes(value)) return value;
1253
+ const legacy = {
1254
+ disabled: "not_connected",
1255
+ disconnected: "disconnected",
1256
+ connecting: "authorizing",
1257
+ syncing: "initial_sync",
1258
+ ready: "healthy",
1259
+ error: "failed"
1260
+ };
1261
+ return legacy[value] ?? null;
1262
+ }
1263
+ async function calendarJson(ctx, suffix, init) {
1264
+ const platform = platformAudience(ctx.platform);
1265
+ const appId = identifier(ctx.appId, "appId");
1266
+ const env = identifier(ctx.env, "env");
1267
+ const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
1268
+ url.searchParams.set("env", env);
1269
+ const token = credential(ctx.token);
1270
+ let response;
1271
+ try {
1272
+ response = await (ctx.fetch ?? fetch)(url, {
1273
+ ...init,
1274
+ redirect: "error",
1275
+ credentials: "omit",
1276
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
1277
+ });
1278
+ } catch (error) {
1279
+ throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
1280
+ }
1281
+ const body = await response.json().catch(() => ({}));
1282
+ if (!response.ok) {
1283
+ const code = textField(body.error?.code, 128);
1284
+ const message = textField(body.error?.message, 500);
1285
+ throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
1286
+ }
1287
+ return body;
1288
+ }
1289
+ function wrapped(raw, key) {
1290
+ const outer = record(raw);
1291
+ if (!outer) throw new Error("calendar returned an invalid response");
1292
+ return record(outer[key]) ?? outer;
1293
+ }
1294
+ function record(value) {
1295
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1296
+ }
1297
+ function textField(value, max) {
1298
+ return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
1299
+ }
1300
+ function stringList(value) {
1301
+ return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
1302
+ }
1303
+ function calendarIds(value) {
1304
+ if (!Array.isArray(value)) return [];
1305
+ return [...new Set(value.flatMap((item) => {
1306
+ if (typeof item === "string") return textField(item, 4096) ? [item] : [];
1307
+ const calendar = record(item);
1308
+ const id = textField(calendar?.id, 4096);
1309
+ return id && calendar?.selected !== false ? [id] : [];
1310
+ }))];
1311
+ }
1312
+ function timestamp3(value) {
1313
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
1314
+ if (typeof value === "string") {
1315
+ const parsed = Date.parse(value);
1316
+ if (Number.isFinite(parsed)) return parsed;
1317
+ }
1318
+ return void 0;
1319
+ }
1320
+ function optionalText(key, value, max) {
1321
+ const parsed = textField(value, max);
1322
+ return parsed ? { [key]: parsed } : {};
1323
+ }
1324
+ function optionalNumber(key, value) {
1325
+ const parsed = timestamp3(value);
1326
+ return parsed === void 0 ? {} : { [key]: parsed };
1327
+ }
1328
+ function optionalInteger(key, value) {
1329
+ return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
1330
+ }
1331
+ function optionalNullableUrl(key, value) {
1332
+ if (value === null) return { [key]: null };
1333
+ const parsed = textField(value, 4096);
1334
+ return parsed ? { [key]: parsed } : {};
1335
+ }
1336
+ function identifier(value, name) {
1337
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
1338
+ return value;
1339
+ }
1340
+ function credential(value) {
1341
+ if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
1342
+ throw new Error("calendar requires an odla developer token");
1343
+ }
1344
+ return value;
1345
+ }
1346
+
1347
+ // src/calendar-poll.ts
1348
+ async function waitForCalendarPoll(milliseconds, signal) {
1349
+ if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
1350
+ await new Promise((resolve7, reject) => {
1351
+ const timer = setTimeout(resolve7, milliseconds);
1352
+ signal?.addEventListener("abort", () => {
1353
+ clearTimeout(timer);
1354
+ reject(signal.reason ?? new Error("calendar connection aborted"));
1355
+ }, { once: true });
1356
+ });
1357
+ }
1358
+
1359
+ // src/calendar.ts
1360
+ async function calendarStatus(options) {
1361
+ const { ctx, out } = await lifecycleContext(options);
1362
+ const status = await readCalendarStatus(ctx);
1363
+ printStatus(status, options.json === true, out);
1364
+ return status;
1365
+ }
1366
+ async function calendarCalendars(options) {
1367
+ const { ctx, out } = await lifecycleContext(options);
1368
+ const calendars = await discoverGoogleCalendars(ctx);
1369
+ if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
1370
+ else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
1371
+ else for (const calendar of calendars) {
1372
+ out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
1373
+ }
1374
+ return calendars;
1375
+ }
1376
+ async function calendarConnect(options) {
1377
+ const { cfg, ctx, out } = await lifecycleContext(options);
1378
+ productionConsent(ctx.env, options.yes, "connect calendar");
1379
+ const page = calendarBookingPageUrl(cfg, ctx.env);
1380
+ const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
1381
+ const connectOptions = connectionOptions(options, out);
1382
+ return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
1383
+ }
1384
+ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
1385
+ if (bookingPageUrl === void 0) return null;
1386
+ const status = await applyCalendarSettings(ctx, bookingPageUrl);
1387
+ out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
1388
+ return status;
1389
+ }
1390
+ async function calendarResync(options) {
1391
+ const { ctx, out } = await lifecycleContext(options);
1392
+ productionConsent(ctx.env, options.yes, "resync calendar");
1393
+ const status = await requestCalendarResync(ctx);
1394
+ out.log(`${ctx.env}: calendar resync requested (${status.status})`);
1395
+ return status;
1396
+ }
1397
+ async function calendarDisconnect(options) {
1398
+ if (!options.yes) throw new Error("calendar disconnect requires --yes");
1399
+ const { ctx, out } = await lifecycleContext(options);
1400
+ const status = await requestCalendarDisconnect(ctx);
1401
+ out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
1402
+ return status;
1403
+ }
1404
+ async function ensureCalendarConnected(ctx, options) {
1405
+ const out = options.stdout ?? console;
1406
+ const current = await readCalendarStatus(ctx);
1407
+ const connectOptions = connectionOptions(options, out);
1408
+ return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
1409
+ }
1410
+ async function lifecycleContext(options) {
1411
+ const cfg = await loadProjectConfig(options.configPath);
1412
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
1413
+ const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
1414
+ if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
1415
+ calendarServiceConfig(cfg, env);
1416
+ const out = options.stdout ?? console;
1417
+ const doFetch = options.fetch ?? fetch;
1418
+ const token = await getDeveloperToken(
1419
+ cfg,
1420
+ {
1421
+ configPath: cfg.configPath,
1422
+ token: options.token,
1423
+ open: options.open,
1424
+ interactive: options.interactive,
1425
+ openApprovalUrl: options.openConsentUrl
1426
+ },
1427
+ doFetch,
1428
+ out
1429
+ );
1430
+ return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
1431
+ }
1432
+ function connectionOptions(options, out) {
1433
+ const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
1434
+ return {
1435
+ out,
1436
+ open: browser.open,
1437
+ openConsentUrl: options.openConsentUrl,
1438
+ wait: options.wait,
1439
+ pollTimeoutMs: options.pollTimeoutMs,
1440
+ now: options.now,
1441
+ signal: options.signal
1442
+ };
1443
+ }
1444
+ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
1445
+ if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
1446
+ options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
1447
+ return current;
1448
+ }
1449
+ if (current.status === "degraded") return null;
1450
+ if (current.status === "needs_sync") {
1451
+ if (!current.connected) return null;
1452
+ options.out.log(`${ctx.env}: calendar configuration needs sync`);
1453
+ const synced = await requestCalendarResync(ctx);
1454
+ options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
1455
+ return synced;
1456
+ }
1457
+ if (current.status === "failed" && current.connected) {
1458
+ const reason = current.error?.message ?? current.error?.code;
1459
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1460
+ }
1461
+ const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
1462
+ if (!waitingForExisting) return null;
1463
+ const now = options.now ?? Date.now;
1464
+ const deadline = now() + pollTimeout(options.pollTimeoutMs);
1465
+ const wait = options.wait ?? waitForCalendarPoll;
1466
+ let prior = "";
1467
+ while (now() < deadline) {
1468
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1469
+ const status = await readCalendarStatus(ctx);
1470
+ if (status.status !== prior) {
1471
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1472
+ prior = status.status;
1473
+ }
1474
+ if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
1475
+ if (status.status === "degraded") return null;
1476
+ if (status.status === "needs_sync") return requestCalendarResync(ctx);
1477
+ if (status.status === "failed" && status.connected) {
1478
+ const reason = status.error?.message ?? status.error?.code;
1479
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1480
+ }
1481
+ if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
1482
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1483
+ }
1484
+ throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
1485
+ }
1486
+ async function connectWithContext(ctx, options) {
1487
+ const attempt = await startCalendarConnection(ctx);
1488
+ const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
1489
+ options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
1490
+ if (options.open) {
1491
+ await (options.openConsentUrl ?? openUrl)(consentUrl);
1492
+ options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
1493
+ }
1494
+ const now = options.now ?? Date.now;
1495
+ const timeout = pollTimeout(options.pollTimeoutMs);
1496
+ const deadline = Math.min(attempt.expiresAt, now() + timeout);
1497
+ const wait = options.wait ?? waitForCalendarPoll;
1498
+ let prior = "";
1499
+ while (now() < deadline) {
1500
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1501
+ const status = await pollCalendarConnection(ctx, attempt.attemptId);
1502
+ if (status.status !== prior) {
1503
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1504
+ prior = status.status;
1505
+ }
1506
+ if (status.status === "healthy" || status.status === "degraded") return status;
1507
+ if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
1508
+ const reason = status.error?.message ?? status.error?.code;
1509
+ throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
1510
+ }
1511
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1512
+ }
1513
+ throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
1514
+ }
1515
+ function trustedCalendarConsentUrl(platform, value) {
1516
+ const origin = platformAudience(platform);
1517
+ let url;
1518
+ try {
1519
+ url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
1520
+ } catch {
1521
+ throw new Error("odla.ai returned an invalid calendar consent URL");
1522
+ }
1523
+ const platformPage = url.origin === origin;
1524
+ const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
1525
+ if (!platformPage && !googlePage || url.username || url.password || url.hash) {
1526
+ throw new Error("odla.ai returned an untrusted calendar consent URL");
1527
+ }
1528
+ return url.toString();
1529
+ }
1530
+ function pollTimeout(value = 10 * 6e4) {
1531
+ if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
1532
+ return value;
1533
+ }
1534
+ function productionConsent(env, yes, action) {
1535
+ if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
1536
+ }
1537
+ function printStatus(status, json, out) {
1538
+ if (json) {
1539
+ out.log(JSON.stringify(status, null, 2));
1540
+ return;
1541
+ }
1542
+ out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
1543
+ out.log(` access: ${status.access ?? "not granted"}`);
1544
+ out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
1545
+ if (status.attendeePolicy) {
1546
+ out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
1547
+ }
1548
+ if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
1549
+ out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
1550
+ if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
1551
+ if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
1552
+ }
1553
+
1554
+ // src/doctor-checks.ts
1555
+ var import_node_child_process3 = require("child_process");
1556
+ var import_node_fs5 = require("fs");
1557
+ var import_node_path4 = require("path");
1558
+
1030
1559
  // src/wrangler.ts
1031
1560
  var import_node_child_process2 = require("child_process");
1032
1561
  var import_node_fs4 = require("fs");
@@ -1207,6 +1736,15 @@ function o11yProjectWarnings(rootDir) {
1207
1736
  }
1208
1737
  return warnings;
1209
1738
  }
1739
+ function calendarProjectWarnings(rootDir) {
1740
+ const pkg = readPackageJson(rootDir);
1741
+ const dependencies = pkg?.dependencies;
1742
+ const devDependencies = pkg?.devDependencies;
1743
+ if (dependencies?.["@odla-ai/calendar"]) return [];
1744
+ return [
1745
+ 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"
1746
+ ];
1747
+ }
1210
1748
  function readPackageJson(rootDir) {
1211
1749
  try {
1212
1750
  return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
@@ -1232,6 +1770,14 @@ async function doctor(options) {
1232
1770
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1233
1771
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1234
1772
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1773
+ if (cfg.services.includes("calendar")) {
1774
+ const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
1775
+ out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
1776
+ const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
1777
+ out.log(`booking pages: ${pages}`);
1778
+ } else {
1779
+ out.log("calendar: not enabled");
1780
+ }
1235
1781
  const warnings = [];
1236
1782
  if (schema && entities.length === 0) warnings.push("schema has no entities");
1237
1783
  if (rules) {
@@ -1259,6 +1805,8 @@ async function doctor(options) {
1259
1805
  }
1260
1806
  warnings.push(...o11yProjectWarnings(cfg.rootDir));
1261
1807
  }
1808
+ if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
1809
+ else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
1262
1810
  warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
1263
1811
  warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
1264
1812
  cfg.local.tokenFile,
@@ -1286,8 +1834,13 @@ function printHelp() {
1286
1834
 
1287
1835
  Usage:
1288
1836
  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]
1837
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
1290
1838
  odla-ai doctor [--config odla.config.mjs]
1839
+ odla-ai calendar status [--env dev] [--json]
1840
+ odla-ai calendar calendars [--env dev] [--json]
1841
+ odla-ai calendar connect [--env dev] [--no-open] [--yes]
1842
+ odla-ai calendar resync [--env dev] [--yes]
1843
+ odla-ai calendar disconnect [--env dev] --yes
1291
1844
  odla-ai capabilities [--json]
1292
1845
  odla-ai admin ai show [--platform https://odla.ai] [--json]
1293
1846
  odla-ai admin ai models [--provider <id>] [--json]
@@ -1308,7 +1861,7 @@ Usage:
1308
1861
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1309
1862
  odla-ai security run [target] --self --ack-redacted-source
1310
1863
  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]
1864
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
1312
1865
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
1313
1866
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1314
1867
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
@@ -1319,6 +1872,7 @@ Commands:
1319
1872
  setup Install offline odla runbooks for common coding-agent harnesses.
1320
1873
  init Create a generic odla.config.mjs plus starter schema/rules files.
1321
1874
  doctor Validate and summarize the project config without network calls.
1875
+ calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
1322
1876
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1323
1877
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1324
1878
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
@@ -1339,6 +1893,8 @@ Safety:
1339
1893
  preflights Wrangler before any shown-once issuance or destructive rotation.
1340
1894
  Provision opens the approval page automatically in interactive terminals;
1341
1895
  use --open to force browser launch or --no-open to suppress it.
1896
+ Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
1897
+ OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
1342
1898
  GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
1343
1899
  for a PAT or provider key. GitHub read access is separate from the explicit
1344
1900
  --ack-redacted-source consent and the exact --plan-digest printed by security
@@ -1383,6 +1939,7 @@ function initProject(options) {
1383
1939
  }
1384
1940
  const envs = options.envs?.length ? options.envs : ["dev"];
1385
1941
  const services = options.services?.length ? options.services : ["db", "ai"];
1942
+ if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
1386
1943
  const aiProvider = options.aiProvider ?? "anthropic";
1387
1944
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
1388
1945
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
@@ -1400,6 +1957,16 @@ function writeIfMissing(path, text) {
1400
1957
  (0, import_node_fs7.writeFileSync)(path, text);
1401
1958
  }
1402
1959
  function configTemplate(input) {
1960
+ const calendar = input.services.includes("calendar") ? ` calendar: {
1961
+ google: {
1962
+ calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
1963
+ bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
1964
+ match: { organizerSelf: true, requireAttendees: true },
1965
+ attendeePolicy: "full",
1966
+ // Read-only in this release. Google consent happens in Studio during provision.
1967
+ },
1968
+ },
1969
+ ` : "";
1403
1970
  return `export default {
1404
1971
  platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
1405
1972
  dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
@@ -1421,6 +1988,7 @@ function configTemplate(input) {
1421
1988
  // key in the platform vault for each tenant.
1422
1989
  keyEnv: "${defaultKeyEnv(input.aiProvider)}",
1423
1990
  },
1991
+ ${calendar}
1424
1992
  auth: {
1425
1993
  clerk: {
1426
1994
  // dev: "$CLERK_PUBLISHABLE_KEY",
@@ -1547,14 +2115,14 @@ async function mintDbKey(opts, tenantId) {
1547
2115
  appId: tenantId
1548
2116
  })
1549
2117
  });
1550
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
2118
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
1551
2119
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1552
2120
  method: "POST",
1553
2121
  headers,
1554
2122
  body: "{}"
1555
2123
  });
1556
2124
  }
1557
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
2125
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
1558
2126
  const body = await res.json();
1559
2127
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
1560
2128
  return body.key;
@@ -1570,12 +2138,12 @@ async function issueO11yToken(opts) {
1570
2138
  `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
2139
  );
1572
2140
  }
1573
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
2141
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
1574
2142
  const body = await res.json();
1575
2143
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
1576
2144
  return body.token;
1577
2145
  }
1578
- async function safeText(res) {
2146
+ async function safeText2(res) {
1579
2147
  try {
1580
2148
  return redactSecrets((await res.text()).slice(0, 500));
1581
2149
  } catch {
@@ -1688,6 +2256,14 @@ async function provision(options) {
1688
2256
  out.log(` schema: ${schema ? "yes" : "no"}`);
1689
2257
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
1690
2258
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2259
+ if (cfg.services.includes("calendar")) {
2260
+ for (const env of cfg.envs) {
2261
+ const calendar = calendarServiceConfig(cfg, env);
2262
+ out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
2263
+ const bookingPage = calendarBookingPageUrl(cfg, env);
2264
+ out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
2265
+ }
2266
+ }
1691
2267
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
1692
2268
  return;
1693
2269
  }
@@ -1722,8 +2298,9 @@ async function provision(options) {
1722
2298
  await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
1723
2299
  out.log(`app: created ${cfg.app.id}`);
1724
2300
  }
2301
+ const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
1725
2302
  for (const env of cfg.envs) {
1726
- for (const service of cfg.services) {
2303
+ for (const service of serviceOrder) {
1727
2304
  if (service === "ai") {
1728
2305
  if (cfg.ai?.provider) {
1729
2306
  await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
@@ -1733,7 +2310,8 @@ async function provision(options) {
1733
2310
  out.log(`${env}: ai enabled`);
1734
2311
  }
1735
2312
  } else {
1736
- await apps.setService(cfg.app.id, service, true, { env });
2313
+ const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
2314
+ await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
1737
2315
  out.log(`${env}: ${service} enabled`);
1738
2316
  }
1739
2317
  }
@@ -1747,6 +2325,21 @@ async function provision(options) {
1747
2325
  await apps.setLink(cfg.app.id, env, link ?? null);
1748
2326
  out.log(`${env}: link ${link ? "set" : "cleared"}`);
1749
2327
  }
2328
+ if (cfg.services.includes("calendar")) {
2329
+ const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
2330
+ await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
2331
+ await ensureCalendarConnected(
2332
+ calendarCtx,
2333
+ {
2334
+ open: options.open,
2335
+ interactive: options.interactive,
2336
+ openConsentUrl: options.openApprovalUrl,
2337
+ wait: options.calendarPollWait,
2338
+ pollTimeoutMs: options.calendarPollTimeoutMs,
2339
+ stdout: out
2340
+ }
2341
+ );
2342
+ }
1750
2343
  }
1751
2344
  for (const env of cfg.envs) {
1752
2345
  const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
@@ -1810,6 +2403,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1810
2403
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
1811
2404
  }
1812
2405
  }
2406
+ function serviceRank(service) {
2407
+ if (service === "db") return 0;
2408
+ if (service === "calendar") return 2;
2409
+ return 1;
2410
+ }
1813
2411
  async function readRegistryApp(cfg, token, doFetch) {
1814
2412
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
1815
2413
  headers: { authorization: `Bearer ${token}` }
@@ -1825,7 +2423,7 @@ async function postJson(doFetch, url, bearer, body) {
1825
2423
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
1826
2424
  body: JSON.stringify(body)
1827
2425
  });
1828
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
2426
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
1829
2427
  }
1830
2428
  function normalizeClerkConfig(value) {
1831
2429
  if (!value) return null;
@@ -1842,7 +2440,7 @@ function defaultSecretName(provider) {
1842
2440
  const names = import_ai.DEFAULT_SECRET_NAMES;
1843
2441
  return names[provider] ?? `${provider}_api_key`;
1844
2442
  }
1845
- async function safeText2(res) {
2443
+ async function safeText3(res) {
1846
2444
  try {
1847
2445
  return redactSecrets((await res.text()).slice(0, 500));
1848
2446
  } catch {
@@ -3102,6 +3700,23 @@ async function smoke(options) {
3102
3700
  }
3103
3701
  out.log(` ai: ${provider}`);
3104
3702
  }
3703
+ if (cfg.services.includes("calendar")) {
3704
+ const token = await getDeveloperToken(
3705
+ cfg,
3706
+ {
3707
+ configPath: cfg.configPath,
3708
+ token: options.token,
3709
+ open: options.open,
3710
+ interactive: options.interactive,
3711
+ openApprovalUrl: options.openApprovalUrl
3712
+ },
3713
+ doFetch,
3714
+ out
3715
+ );
3716
+ const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
3717
+ assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
3718
+ out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
3719
+ }
3105
3720
  const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
3106
3721
  const expectedEntities = serializedEntities(expectedSchema);
3107
3722
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
@@ -3123,13 +3738,35 @@ async function smoke(options) {
3123
3738
  } else {
3124
3739
  out.log(` aggregate: skipped (schema has no entities)`);
3125
3740
  }
3741
+ if (cfg.services.includes("calendar")) {
3742
+ const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3743
+ ns: "$bookings",
3744
+ aggregate: { count: true }
3745
+ });
3746
+ const count = bookings.aggregate?.count;
3747
+ out.log(` bookings: ${String(count ?? "ok")}`);
3748
+ }
3126
3749
  out.log("ok");
3127
3750
  }
3751
+ function assertCalendarHealthy(status, expected) {
3752
+ if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
3753
+ if (status.status !== "healthy") {
3754
+ throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
3755
+ }
3756
+ if (status.access !== "read") throw new Error("calendar connection is not read-only");
3757
+ const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
3758
+ if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
3759
+ const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
3760
+ if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
3761
+ if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
3762
+ if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
3763
+ if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
3764
+ }
3128
3765
  async function getJson(doFetch, url, bearer) {
3129
3766
  const res = await doFetch(url, {
3130
3767
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
3131
3768
  });
3132
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3769
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3133
3770
  return res.json();
3134
3771
  }
3135
3772
  async function postJson2(doFetch, url, bearer, body) {
@@ -3138,7 +3775,7 @@ async function postJson2(doFetch, url, bearer, body) {
3138
3775
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
3139
3776
  body: JSON.stringify(body)
3140
3777
  });
3141
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3778
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3142
3779
  return res.json();
3143
3780
  }
3144
3781
  function publicConfigUrl(platformUrl, appId, env) {
@@ -3146,7 +3783,7 @@ function publicConfigUrl(platformUrl, appId, env) {
3146
3783
  url.searchParams.set("env", env);
3147
3784
  return url.toString();
3148
3785
  }
3149
- async function safeText3(res) {
3786
+ async function safeText4(res) {
3150
3787
  try {
3151
3788
  return (await res.text()).slice(0, 500);
3152
3789
  } catch {
@@ -3194,6 +3831,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3194
3831
  await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
3195
3832
  return;
3196
3833
  }
3834
+ if (command === "calendar") {
3835
+ await calendarCommand(parsed, dependencies);
3836
+ return;
3837
+ }
3197
3838
  if (command === "capabilities") {
3198
3839
  assertArgs(parsed, ["json"], 1);
3199
3840
  printCapabilities(parsed.options.json === true);
@@ -3208,14 +3849,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3208
3849
  return;
3209
3850
  }
3210
3851
  if (command === "provision") {
3211
- await provisionCommand(parsed);
3852
+ await provisionCommand(parsed, dependencies);
3212
3853
  return;
3213
3854
  }
3214
3855
  if (command === "smoke") {
3215
- assertArgs(parsed, ["config", "env"], 1);
3856
+ assertArgs(parsed, ["config", "env", "token", "open"], 1);
3216
3857
  await smoke({
3217
3858
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3218
- env: stringOpt(parsed.options.env)
3859
+ env: stringOpt(parsed.options.env),
3860
+ token: stringOpt(parsed.options.token),
3861
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3862
+ openApprovalUrl: dependencies.openUrl,
3863
+ fetch: dependencies.fetch,
3864
+ stdout: dependencies.stdout
3219
3865
  });
3220
3866
  return;
3221
3867
  }
@@ -3280,7 +3926,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3280
3926
  }
3281
3927
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
3282
3928
  }
3283
- async function provisionCommand(parsed) {
3929
+ async function provisionCommand(parsed, dependencies) {
3284
3930
  assertArgs(parsed, [
3285
3931
  "config",
3286
3932
  "dry-run",
@@ -3304,16 +3950,52 @@ async function provisionCommand(parsed) {
3304
3950
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
3305
3951
  token: stringOpt(parsed.options.token),
3306
3952
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3307
- yes: parsed.options.yes === true
3953
+ yes: parsed.options.yes === true,
3954
+ fetch: dependencies.fetch,
3955
+ openApprovalUrl: dependencies.openUrl,
3956
+ stdout: dependencies.stdout
3308
3957
  };
3309
3958
  await provision(options);
3310
3959
  }
3960
+ async function calendarCommand(parsed, dependencies) {
3961
+ const sub = parsed.positionals[1];
3962
+ if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
3963
+ throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
3964
+ }
3965
+ assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
3966
+ if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
3967
+ if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
3968
+ const options = {
3969
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3970
+ env: stringOpt(parsed.options.env),
3971
+ token: stringOpt(parsed.options.token),
3972
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3973
+ yes: parsed.options.yes === true,
3974
+ json: parsed.options.json === true,
3975
+ fetch: dependencies.fetch,
3976
+ stdout: dependencies.stdout,
3977
+ openConsentUrl: dependencies.openUrl
3978
+ };
3979
+ if (sub === "status") await calendarStatus(options);
3980
+ else if (sub === "calendars") await calendarCalendars(options);
3981
+ else if (sub === "connect") await calendarConnect(options);
3982
+ else if (sub === "resync") await calendarResync(options);
3983
+ else await calendarDisconnect(options);
3984
+ }
3311
3985
  // Annotate the CommonJS export names for ESM import in node:
3312
3986
  0 && (module.exports = {
3313
3987
  AGENT_HARNESSES,
3314
3988
  CAPABILITIES,
3989
+ GOOGLE_CALENDAR_READ_SCOPE,
3315
3990
  SYSTEM_AI_PURPOSES,
3316
3991
  adminAi,
3992
+ calendarBookingPageUrl,
3993
+ calendarCalendars,
3994
+ calendarConnect,
3995
+ calendarDisconnect,
3996
+ calendarResync,
3997
+ calendarServiceConfig,
3998
+ calendarStatus,
3317
3999
  connectGitHubSecuritySource,
3318
4000
  disconnectGitHubSecuritySource,
3319
4001
  doctor,