@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/bin.cjs CHANGED
@@ -815,6 +815,7 @@ var CAPABILITIES = {
815
815
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
816
816
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
817
817
  "push db schema/rules and configure platform AI, auth, and deployment links",
818
+ "apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
818
819
  "validate config offline and smoke-test a provisioned db environment",
819
820
  "run app-attributed hosted security discovery and independent validation without provider keys",
820
821
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
@@ -823,10 +824,12 @@ var CAPABILITIES = {
823
824
  agent: [
824
825
  "install and import the selected odla SDKs",
825
826
  "wrap the Worker with withObservability and choose useful telemetry",
826
- "make application-specific schema, rules, auth, UI, and migration decisions"
827
+ "make application-specific schema, rules, auth, UI, and migration decisions",
828
+ "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
827
829
  ],
828
830
  human: [
829
831
  "approve the odla device code and one-off third-party logins",
832
+ "review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
830
833
  "consent to production changes with --yes",
831
834
  "request destructive credential rotation explicitly",
832
835
  "review application semantics, security findings, releases, and merges",
@@ -835,6 +838,7 @@ var CAPABILITIES = {
835
838
  ],
836
839
  studio: [
837
840
  "view telemetry and environment state",
841
+ "review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
838
842
  "perform manual credential recovery when the CLI's local shown-once copy is unavailable",
839
843
  "configure system AI purposes and view app/environment/run-attributed usage",
840
844
  "connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
@@ -864,6 +868,7 @@ var import_node_url = require("url");
864
868
  var DEFAULT_PLATFORM = "https://odla.ai";
865
869
  var DEFAULT_ENVS = ["dev"];
866
870
  var DEFAULT_SERVICES = ["db", "ai"];
871
+ var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
867
872
  async function loadProjectConfig(configPath = "odla.config.mjs") {
868
873
  const resolved = (0, import_node_path2.resolve)(configPath);
869
874
  if (!(0, import_node_fs3.existsSync)(resolved)) {
@@ -876,6 +881,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
876
881
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
877
882
  const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
878
883
  const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
884
+ validateCalendarConfig(raw, envs, services, resolved);
879
885
  const local = {
880
886
  tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
881
887
  credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
@@ -920,6 +926,28 @@ function buildPlan(cfg) {
920
926
  aiProvider: cfg.ai?.provider
921
927
  };
922
928
  }
929
+ function calendarServiceConfig(cfg, env) {
930
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
931
+ if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
932
+ const google = cfg.calendar?.google;
933
+ if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
934
+ return {
935
+ provider: "google",
936
+ access: "read",
937
+ calendars: unique(google.calendars[env].map((id) => id.trim())),
938
+ match: {
939
+ organizerSelf: google.match?.organizerSelf ?? true,
940
+ requireAttendees: google.match?.requireAttendees ?? true,
941
+ ...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
942
+ },
943
+ attendeePolicy: google.attendeePolicy ?? "full"
944
+ };
945
+ }
946
+ function calendarBookingPageUrl(cfg, env) {
947
+ const value = cfg.calendar?.google.bookingPageUrl?.[env];
948
+ if (value === void 0 || value === null) return value;
949
+ return new URL(value).toString();
950
+ }
923
951
  function rulesFromSchema(schema) {
924
952
  const entities = serializedEntities(schema);
925
953
  return Object.fromEntries(
@@ -943,7 +971,85 @@ function validateRawConfig(raw, path) {
943
971
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
944
972
  if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
945
973
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
974
+ if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
975
+ throw new Error(`${path}: envs must be an array of names`);
976
+ }
946
977
  if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
978
+ if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
979
+ throw new Error(`${path}: services must be an array of non-empty names`);
980
+ }
981
+ }
982
+ function validateCalendarConfig(cfg, envs, services, path) {
983
+ const enabled = services.includes("calendar");
984
+ if (!cfg.calendar) {
985
+ if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
986
+ return;
987
+ }
988
+ if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
989
+ assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
990
+ if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
991
+ const google = cfg.calendar.google;
992
+ assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
993
+ if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
994
+ const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
995
+ if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
996
+ for (const env of envs) {
997
+ const ids = google.calendars[env];
998
+ if (!Array.isArray(ids) || ids.length === 0) {
999
+ throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
1000
+ }
1001
+ if (ids.length > 10) {
1002
+ throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
1003
+ }
1004
+ if (ids.some((id) => !safeText(id, 1024))) {
1005
+ throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
1006
+ }
1007
+ }
1008
+ if (google.bookingPageUrl !== void 0) {
1009
+ if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1010
+ const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1011
+ if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1012
+ for (const [env, value] of Object.entries(google.bookingPageUrl)) {
1013
+ if (value !== null && !safeHttpsUrl(value)) {
1014
+ throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
1015
+ }
1016
+ }
1017
+ }
1018
+ if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
1019
+ throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
1020
+ }
1021
+ if (google.match !== void 0) {
1022
+ if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
1023
+ assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
1024
+ if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
1025
+ throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
1026
+ }
1027
+ for (const name of ["organizerSelf", "requireAttendees"]) {
1028
+ if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
1029
+ throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
1030
+ }
1031
+ }
1032
+ }
1033
+ if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
1034
+ }
1035
+ function assertOnly(value, allowed, label) {
1036
+ const extra = Object.keys(value).find((key) => !allowed.includes(key));
1037
+ if (extra) throw new Error(`${label}.${extra} is not supported`);
1038
+ }
1039
+ function isRecord4(value) {
1040
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1041
+ }
1042
+ function safeText(value, max) {
1043
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1044
+ }
1045
+ function safeHttpsUrl(value) {
1046
+ if (typeof value !== "string" || value.length > 2048) return false;
1047
+ try {
1048
+ const url = new URL(value);
1049
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
1050
+ } catch {
1051
+ return false;
1052
+ }
947
1053
  }
948
1054
  function validId(value) {
949
1055
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
@@ -962,11 +1068,6 @@ function unique(values) {
962
1068
  return [...new Set(values.filter(Boolean))];
963
1069
  }
964
1070
 
965
- // src/doctor-checks.ts
966
- var import_node_child_process3 = require("child_process");
967
- var import_node_fs5 = require("fs");
968
- var import_node_path4 = require("path");
969
-
970
1071
  // src/redact.ts
971
1072
  var REPLACEMENTS = [
972
1073
  [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
@@ -986,6 +1087,426 @@ function looksSecret(value) {
986
1087
  return redactSecrets(value) !== value || value.includes("-----BEGIN");
987
1088
  }
988
1089
 
1090
+ // src/calendar-http.ts
1091
+ var CALENDAR_STATES = [
1092
+ "not_connected",
1093
+ "authorizing",
1094
+ "needs_sync",
1095
+ "initial_sync",
1096
+ "healthy",
1097
+ "degraded",
1098
+ "disconnected",
1099
+ "failed"
1100
+ ];
1101
+ async function readCalendarStatus(ctx) {
1102
+ return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
1103
+ }
1104
+ async function discoverGoogleCalendars(ctx) {
1105
+ const raw = await calendarJson(ctx, "/calendars", {});
1106
+ const value = record(raw);
1107
+ if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
1108
+ return value.calendars.map((item, index) => {
1109
+ const calendar = record(item);
1110
+ const id = textField(calendar?.id, 1024);
1111
+ if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
1112
+ const role = calendar.accessRole;
1113
+ if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
1114
+ throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
1115
+ }
1116
+ return {
1117
+ id,
1118
+ ...optionalText("summary", calendar.summary, 500),
1119
+ ...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
1120
+ ...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
1121
+ ...typeof role === "string" ? { accessRole: role } : {}
1122
+ };
1123
+ });
1124
+ }
1125
+ async function applyCalendarSettings(ctx, bookingPageUrl) {
1126
+ return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
1127
+ }
1128
+ async function startCalendarConnection(ctx) {
1129
+ const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
1130
+ const value = wrapped(raw, "attempt");
1131
+ const attemptId = textField(value.attemptId, 180);
1132
+ const consentUrl = textField(value.consentUrl, 4096);
1133
+ const expiresAt = timestamp3(value.expiresAt);
1134
+ if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
1135
+ return { attemptId, consentUrl, expiresAt };
1136
+ }
1137
+ async function pollCalendarConnection(ctx, attemptId) {
1138
+ return parseCalendarStatus(
1139
+ await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
1140
+ ctx.env
1141
+ );
1142
+ }
1143
+ async function requestCalendarResync(ctx) {
1144
+ return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
1145
+ }
1146
+ async function requestCalendarDisconnect(ctx) {
1147
+ return parseCalendarStatus(
1148
+ await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
1149
+ ctx.env
1150
+ );
1151
+ }
1152
+ function parseCalendarStatus(raw, env) {
1153
+ const outer = wrapped(raw, "calendar");
1154
+ const value = record(outer.attempt) ?? record(outer.status) ?? outer;
1155
+ const connection = record(value.connection) ?? {};
1156
+ const sync = record(value.sync) ?? record(connection.sync) ?? {};
1157
+ const config = record(value.config) ?? record(outer.config) ?? {};
1158
+ const googleConfig = record(config.google) ?? config;
1159
+ const watches = record(value.watches) ?? {};
1160
+ const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
1161
+ if (!stateValue) {
1162
+ throw new Error("calendar status returned an invalid connection state");
1163
+ }
1164
+ const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
1165
+ if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
1166
+ throw new Error("calendar status returned an unsupported provider");
1167
+ }
1168
+ const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
1169
+ if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
1170
+ const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
1171
+ if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
1172
+ throw new Error("calendar status returned an invalid attendee policy");
1173
+ }
1174
+ const errorValue = record(value.error) ?? record(connection.error);
1175
+ const errorCode = textField(value.lastErrorCode, 128);
1176
+ const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
1177
+ return {
1178
+ env,
1179
+ enabled: typeof value.enabled === "boolean" ? value.enabled : true,
1180
+ connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
1181
+ provider: providerValue === "google" ? "google" : null,
1182
+ status: stateValue,
1183
+ ...accessValue === "read" ? { access: "read" } : {},
1184
+ calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
1185
+ ...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
1186
+ ...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
1187
+ ...optionalNullableUrl("bookingPageUrl", bookingPageValue),
1188
+ grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
1189
+ ...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
1190
+ ...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
1191
+ ...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
1192
+ ...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
1193
+ ...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
1194
+ ...errorValue || errorCode ? { error: {
1195
+ ...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
1196
+ ...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
1197
+ ...!errorValue && errorCode ? { code: errorCode } : {}
1198
+ } } : {}
1199
+ };
1200
+ }
1201
+ function calendarState(value) {
1202
+ if (typeof value !== "string") return null;
1203
+ if (CALENDAR_STATES.includes(value)) return value;
1204
+ const legacy = {
1205
+ disabled: "not_connected",
1206
+ disconnected: "disconnected",
1207
+ connecting: "authorizing",
1208
+ syncing: "initial_sync",
1209
+ ready: "healthy",
1210
+ error: "failed"
1211
+ };
1212
+ return legacy[value] ?? null;
1213
+ }
1214
+ async function calendarJson(ctx, suffix, init) {
1215
+ const platform = platformAudience(ctx.platform);
1216
+ const appId = identifier(ctx.appId, "appId");
1217
+ const env = identifier(ctx.env, "env");
1218
+ const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
1219
+ url.searchParams.set("env", env);
1220
+ const token = credential(ctx.token);
1221
+ let response;
1222
+ try {
1223
+ response = await (ctx.fetch ?? fetch)(url, {
1224
+ ...init,
1225
+ redirect: "error",
1226
+ credentials: "omit",
1227
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
1228
+ });
1229
+ } catch (error) {
1230
+ throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
1231
+ }
1232
+ const body = await response.json().catch(() => ({}));
1233
+ if (!response.ok) {
1234
+ const code = textField(body.error?.code, 128);
1235
+ const message = textField(body.error?.message, 500);
1236
+ throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
1237
+ }
1238
+ return body;
1239
+ }
1240
+ function wrapped(raw, key) {
1241
+ const outer = record(raw);
1242
+ if (!outer) throw new Error("calendar returned an invalid response");
1243
+ return record(outer[key]) ?? outer;
1244
+ }
1245
+ function record(value) {
1246
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1247
+ }
1248
+ function textField(value, max) {
1249
+ return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
1250
+ }
1251
+ function stringList(value) {
1252
+ return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
1253
+ }
1254
+ function calendarIds(value) {
1255
+ if (!Array.isArray(value)) return [];
1256
+ return [...new Set(value.flatMap((item) => {
1257
+ if (typeof item === "string") return textField(item, 4096) ? [item] : [];
1258
+ const calendar = record(item);
1259
+ const id = textField(calendar?.id, 4096);
1260
+ return id && calendar?.selected !== false ? [id] : [];
1261
+ }))];
1262
+ }
1263
+ function timestamp3(value) {
1264
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
1265
+ if (typeof value === "string") {
1266
+ const parsed = Date.parse(value);
1267
+ if (Number.isFinite(parsed)) return parsed;
1268
+ }
1269
+ return void 0;
1270
+ }
1271
+ function optionalText(key, value, max) {
1272
+ const parsed = textField(value, max);
1273
+ return parsed ? { [key]: parsed } : {};
1274
+ }
1275
+ function optionalNumber(key, value) {
1276
+ const parsed = timestamp3(value);
1277
+ return parsed === void 0 ? {} : { [key]: parsed };
1278
+ }
1279
+ function optionalInteger(key, value) {
1280
+ return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
1281
+ }
1282
+ function optionalNullableUrl(key, value) {
1283
+ if (value === null) return { [key]: null };
1284
+ const parsed = textField(value, 4096);
1285
+ return parsed ? { [key]: parsed } : {};
1286
+ }
1287
+ function identifier(value, name) {
1288
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
1289
+ return value;
1290
+ }
1291
+ function credential(value) {
1292
+ if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
1293
+ throw new Error("calendar requires an odla developer token");
1294
+ }
1295
+ return value;
1296
+ }
1297
+
1298
+ // src/calendar-poll.ts
1299
+ async function waitForCalendarPoll(milliseconds, signal) {
1300
+ if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
1301
+ await new Promise((resolve7, reject) => {
1302
+ const timer = setTimeout(resolve7, milliseconds);
1303
+ signal?.addEventListener("abort", () => {
1304
+ clearTimeout(timer);
1305
+ reject(signal.reason ?? new Error("calendar connection aborted"));
1306
+ }, { once: true });
1307
+ });
1308
+ }
1309
+
1310
+ // src/calendar.ts
1311
+ async function calendarStatus(options) {
1312
+ const { ctx, out } = await lifecycleContext(options);
1313
+ const status = await readCalendarStatus(ctx);
1314
+ printStatus(status, options.json === true, out);
1315
+ return status;
1316
+ }
1317
+ async function calendarCalendars(options) {
1318
+ const { ctx, out } = await lifecycleContext(options);
1319
+ const calendars = await discoverGoogleCalendars(ctx);
1320
+ if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
1321
+ else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
1322
+ else for (const calendar of calendars) {
1323
+ out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
1324
+ }
1325
+ return calendars;
1326
+ }
1327
+ async function calendarConnect(options) {
1328
+ const { cfg, ctx, out } = await lifecycleContext(options);
1329
+ productionConsent(ctx.env, options.yes, "connect calendar");
1330
+ const page = calendarBookingPageUrl(cfg, ctx.env);
1331
+ const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
1332
+ const connectOptions = connectionOptions(options, out);
1333
+ return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
1334
+ }
1335
+ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
1336
+ if (bookingPageUrl === void 0) return null;
1337
+ const status = await applyCalendarSettings(ctx, bookingPageUrl);
1338
+ out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
1339
+ return status;
1340
+ }
1341
+ async function calendarResync(options) {
1342
+ const { ctx, out } = await lifecycleContext(options);
1343
+ productionConsent(ctx.env, options.yes, "resync calendar");
1344
+ const status = await requestCalendarResync(ctx);
1345
+ out.log(`${ctx.env}: calendar resync requested (${status.status})`);
1346
+ return status;
1347
+ }
1348
+ async function calendarDisconnect(options) {
1349
+ if (!options.yes) throw new Error("calendar disconnect requires --yes");
1350
+ const { ctx, out } = await lifecycleContext(options);
1351
+ const status = await requestCalendarDisconnect(ctx);
1352
+ out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
1353
+ return status;
1354
+ }
1355
+ async function ensureCalendarConnected(ctx, options) {
1356
+ const out = options.stdout ?? console;
1357
+ const current = await readCalendarStatus(ctx);
1358
+ const connectOptions = connectionOptions(options, out);
1359
+ return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
1360
+ }
1361
+ async function lifecycleContext(options) {
1362
+ const cfg = await loadProjectConfig(options.configPath);
1363
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
1364
+ const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
1365
+ if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
1366
+ calendarServiceConfig(cfg, env);
1367
+ const out = options.stdout ?? console;
1368
+ const doFetch = options.fetch ?? fetch;
1369
+ const token = await getDeveloperToken(
1370
+ cfg,
1371
+ {
1372
+ configPath: cfg.configPath,
1373
+ token: options.token,
1374
+ open: options.open,
1375
+ interactive: options.interactive,
1376
+ openApprovalUrl: options.openConsentUrl
1377
+ },
1378
+ doFetch,
1379
+ out
1380
+ );
1381
+ return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
1382
+ }
1383
+ function connectionOptions(options, out) {
1384
+ const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
1385
+ return {
1386
+ out,
1387
+ open: browser.open,
1388
+ openConsentUrl: options.openConsentUrl,
1389
+ wait: options.wait,
1390
+ pollTimeoutMs: options.pollTimeoutMs,
1391
+ now: options.now,
1392
+ signal: options.signal
1393
+ };
1394
+ }
1395
+ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
1396
+ if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
1397
+ options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
1398
+ return current;
1399
+ }
1400
+ if (current.status === "degraded") return null;
1401
+ if (current.status === "needs_sync") {
1402
+ if (!current.connected) return null;
1403
+ options.out.log(`${ctx.env}: calendar configuration needs sync`);
1404
+ const synced = await requestCalendarResync(ctx);
1405
+ options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
1406
+ return synced;
1407
+ }
1408
+ if (current.status === "failed" && current.connected) {
1409
+ const reason = current.error?.message ?? current.error?.code;
1410
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1411
+ }
1412
+ const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
1413
+ if (!waitingForExisting) return null;
1414
+ const now = options.now ?? Date.now;
1415
+ const deadline = now() + pollTimeout(options.pollTimeoutMs);
1416
+ const wait = options.wait ?? waitForCalendarPoll;
1417
+ let prior = "";
1418
+ while (now() < deadline) {
1419
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1420
+ const status = await readCalendarStatus(ctx);
1421
+ if (status.status !== prior) {
1422
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1423
+ prior = status.status;
1424
+ }
1425
+ if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
1426
+ if (status.status === "degraded") return null;
1427
+ if (status.status === "needs_sync") return requestCalendarResync(ctx);
1428
+ if (status.status === "failed" && status.connected) {
1429
+ const reason = status.error?.message ?? status.error?.code;
1430
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1431
+ }
1432
+ if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
1433
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1434
+ }
1435
+ throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
1436
+ }
1437
+ async function connectWithContext(ctx, options) {
1438
+ const attempt = await startCalendarConnection(ctx);
1439
+ const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
1440
+ options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
1441
+ if (options.open) {
1442
+ await (options.openConsentUrl ?? openUrl)(consentUrl);
1443
+ options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
1444
+ }
1445
+ const now = options.now ?? Date.now;
1446
+ const timeout = pollTimeout(options.pollTimeoutMs);
1447
+ const deadline = Math.min(attempt.expiresAt, now() + timeout);
1448
+ const wait = options.wait ?? waitForCalendarPoll;
1449
+ let prior = "";
1450
+ while (now() < deadline) {
1451
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1452
+ const status = await pollCalendarConnection(ctx, attempt.attemptId);
1453
+ if (status.status !== prior) {
1454
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1455
+ prior = status.status;
1456
+ }
1457
+ if (status.status === "healthy" || status.status === "degraded") return status;
1458
+ if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
1459
+ const reason = status.error?.message ?? status.error?.code;
1460
+ throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
1461
+ }
1462
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1463
+ }
1464
+ throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
1465
+ }
1466
+ function trustedCalendarConsentUrl(platform, value) {
1467
+ const origin = platformAudience(platform);
1468
+ let url;
1469
+ try {
1470
+ url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
1471
+ } catch {
1472
+ throw new Error("odla.ai returned an invalid calendar consent URL");
1473
+ }
1474
+ const platformPage = url.origin === origin;
1475
+ const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
1476
+ if (!platformPage && !googlePage || url.username || url.password || url.hash) {
1477
+ throw new Error("odla.ai returned an untrusted calendar consent URL");
1478
+ }
1479
+ return url.toString();
1480
+ }
1481
+ function pollTimeout(value = 10 * 6e4) {
1482
+ if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
1483
+ return value;
1484
+ }
1485
+ function productionConsent(env, yes, action) {
1486
+ if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
1487
+ }
1488
+ function printStatus(status, json, out) {
1489
+ if (json) {
1490
+ out.log(JSON.stringify(status, null, 2));
1491
+ return;
1492
+ }
1493
+ out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
1494
+ out.log(` access: ${status.access ?? "not granted"}`);
1495
+ out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
1496
+ if (status.attendeePolicy) {
1497
+ out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
1498
+ }
1499
+ if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
1500
+ out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
1501
+ if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
1502
+ if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
1503
+ }
1504
+
1505
+ // src/doctor-checks.ts
1506
+ var import_node_child_process3 = require("child_process");
1507
+ var import_node_fs5 = require("fs");
1508
+ var import_node_path4 = require("path");
1509
+
989
1510
  // src/wrangler.ts
990
1511
  var import_node_child_process2 = require("child_process");
991
1512
  var import_node_fs4 = require("fs");
@@ -1166,6 +1687,15 @@ function o11yProjectWarnings(rootDir) {
1166
1687
  }
1167
1688
  return warnings;
1168
1689
  }
1690
+ function calendarProjectWarnings(rootDir) {
1691
+ const pkg = readPackageJson(rootDir);
1692
+ const dependencies = pkg?.dependencies;
1693
+ const devDependencies = pkg?.devDependencies;
1694
+ if (dependencies?.["@odla-ai/calendar"]) return [];
1695
+ return [
1696
+ 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"
1697
+ ];
1698
+ }
1169
1699
  function readPackageJson(rootDir) {
1170
1700
  try {
1171
1701
  return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
@@ -1191,6 +1721,14 @@ async function doctor(options) {
1191
1721
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1192
1722
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1193
1723
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1724
+ if (cfg.services.includes("calendar")) {
1725
+ const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
1726
+ out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
1727
+ const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
1728
+ out.log(`booking pages: ${pages}`);
1729
+ } else {
1730
+ out.log("calendar: not enabled");
1731
+ }
1194
1732
  const warnings = [];
1195
1733
  if (schema && entities.length === 0) warnings.push("schema has no entities");
1196
1734
  if (rules) {
@@ -1218,6 +1756,8 @@ async function doctor(options) {
1218
1756
  }
1219
1757
  warnings.push(...o11yProjectWarnings(cfg.rootDir));
1220
1758
  }
1759
+ if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
1760
+ else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
1221
1761
  warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
1222
1762
  warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
1223
1763
  cfg.local.tokenFile,
@@ -1245,8 +1785,13 @@ function printHelp() {
1245
1785
 
1246
1786
  Usage:
1247
1787
  odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
1248
- odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
1788
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
1249
1789
  odla-ai doctor [--config odla.config.mjs]
1790
+ odla-ai calendar status [--env dev] [--json]
1791
+ odla-ai calendar calendars [--env dev] [--json]
1792
+ odla-ai calendar connect [--env dev] [--no-open] [--yes]
1793
+ odla-ai calendar resync [--env dev] [--yes]
1794
+ odla-ai calendar disconnect [--env dev] --yes
1250
1795
  odla-ai capabilities [--json]
1251
1796
  odla-ai admin ai show [--platform https://odla.ai] [--json]
1252
1797
  odla-ai admin ai models [--provider <id>] [--json]
@@ -1267,7 +1812,7 @@ Usage:
1267
1812
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1268
1813
  odla-ai security run [target] --self --ack-redacted-source
1269
1814
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
1270
- odla-ai smoke [--config odla.config.mjs] [--env dev]
1815
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
1271
1816
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
1272
1817
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1273
1818
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
@@ -1278,6 +1823,7 @@ Commands:
1278
1823
  setup Install offline odla runbooks for common coding-agent harnesses.
1279
1824
  init Create a generic odla.config.mjs plus starter schema/rules files.
1280
1825
  doctor Validate and summarize the project config without network calls.
1826
+ calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
1281
1827
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1282
1828
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1283
1829
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
@@ -1298,6 +1844,8 @@ Safety:
1298
1844
  preflights Wrangler before any shown-once issuance or destructive rotation.
1299
1845
  Provision opens the approval page automatically in interactive terminals;
1300
1846
  use --open to force browser launch or --no-open to suppress it.
1847
+ Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
1848
+ OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
1301
1849
  GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
1302
1850
  for a PAT or provider key. GitHub read access is separate from the explicit
1303
1851
  --ack-redacted-source consent and the exact --plan-digest printed by security
@@ -1342,6 +1890,7 @@ function initProject(options) {
1342
1890
  }
1343
1891
  const envs = options.envs?.length ? options.envs : ["dev"];
1344
1892
  const services = options.services?.length ? options.services : ["db", "ai"];
1893
+ if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
1345
1894
  const aiProvider = options.aiProvider ?? "anthropic";
1346
1895
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
1347
1896
  (0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
@@ -1359,6 +1908,16 @@ function writeIfMissing(path, text) {
1359
1908
  (0, import_node_fs7.writeFileSync)(path, text);
1360
1909
  }
1361
1910
  function configTemplate(input) {
1911
+ const calendar = input.services.includes("calendar") ? ` calendar: {
1912
+ google: {
1913
+ calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
1914
+ bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
1915
+ match: { organizerSelf: true, requireAttendees: true },
1916
+ attendeePolicy: "full",
1917
+ // Read-only in this release. Google consent happens in Studio during provision.
1918
+ },
1919
+ },
1920
+ ` : "";
1362
1921
  return `export default {
1363
1922
  platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
1364
1923
  dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
@@ -1380,6 +1939,7 @@ function configTemplate(input) {
1380
1939
  // key in the platform vault for each tenant.
1381
1940
  keyEnv: "${defaultKeyEnv(input.aiProvider)}",
1382
1941
  },
1942
+ ${calendar}
1383
1943
  auth: {
1384
1944
  clerk: {
1385
1945
  // dev: "$CLERK_PUBLISHABLE_KEY",
@@ -1506,14 +2066,14 @@ async function mintDbKey(opts, tenantId) {
1506
2066
  appId: tenantId
1507
2067
  })
1508
2068
  });
1509
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
2069
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
1510
2070
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1511
2071
  method: "POST",
1512
2072
  headers,
1513
2073
  body: "{}"
1514
2074
  });
1515
2075
  }
1516
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
2076
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
1517
2077
  const body = await res.json();
1518
2078
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
1519
2079
  return body.key;
@@ -1529,12 +2089,12 @@ async function issueO11yToken(opts) {
1529
2089
  `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`
1530
2090
  );
1531
2091
  }
1532
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
2092
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
1533
2093
  const body = await res.json();
1534
2094
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
1535
2095
  return body.token;
1536
2096
  }
1537
- async function safeText(res) {
2097
+ async function safeText2(res) {
1538
2098
  try {
1539
2099
  return redactSecrets((await res.text()).slice(0, 500));
1540
2100
  } catch {
@@ -1647,6 +2207,14 @@ async function provision(options) {
1647
2207
  out.log(` schema: ${schema ? "yes" : "no"}`);
1648
2208
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
1649
2209
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2210
+ if (cfg.services.includes("calendar")) {
2211
+ for (const env of cfg.envs) {
2212
+ const calendar = calendarServiceConfig(cfg, env);
2213
+ out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
2214
+ const bookingPage = calendarBookingPageUrl(cfg, env);
2215
+ out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
2216
+ }
2217
+ }
1650
2218
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
1651
2219
  return;
1652
2220
  }
@@ -1681,8 +2249,9 @@ async function provision(options) {
1681
2249
  await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
1682
2250
  out.log(`app: created ${cfg.app.id}`);
1683
2251
  }
2252
+ const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
1684
2253
  for (const env of cfg.envs) {
1685
- for (const service of cfg.services) {
2254
+ for (const service of serviceOrder) {
1686
2255
  if (service === "ai") {
1687
2256
  if (cfg.ai?.provider) {
1688
2257
  await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
@@ -1692,7 +2261,8 @@ async function provision(options) {
1692
2261
  out.log(`${env}: ai enabled`);
1693
2262
  }
1694
2263
  } else {
1695
- await apps.setService(cfg.app.id, service, true, { env });
2264
+ const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
2265
+ await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
1696
2266
  out.log(`${env}: ${service} enabled`);
1697
2267
  }
1698
2268
  }
@@ -1706,6 +2276,21 @@ async function provision(options) {
1706
2276
  await apps.setLink(cfg.app.id, env, link ?? null);
1707
2277
  out.log(`${env}: link ${link ? "set" : "cleared"}`);
1708
2278
  }
2279
+ if (cfg.services.includes("calendar")) {
2280
+ const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
2281
+ await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
2282
+ await ensureCalendarConnected(
2283
+ calendarCtx,
2284
+ {
2285
+ open: options.open,
2286
+ interactive: options.interactive,
2287
+ openConsentUrl: options.openApprovalUrl,
2288
+ wait: options.calendarPollWait,
2289
+ pollTimeoutMs: options.calendarPollTimeoutMs,
2290
+ stdout: out
2291
+ }
2292
+ );
2293
+ }
1709
2294
  }
1710
2295
  for (const env of cfg.envs) {
1711
2296
  const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
@@ -1769,6 +2354,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1769
2354
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
1770
2355
  }
1771
2356
  }
2357
+ function serviceRank(service) {
2358
+ if (service === "db") return 0;
2359
+ if (service === "calendar") return 2;
2360
+ return 1;
2361
+ }
1772
2362
  async function readRegistryApp(cfg, token, doFetch) {
1773
2363
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
1774
2364
  headers: { authorization: `Bearer ${token}` }
@@ -1784,7 +2374,7 @@ async function postJson(doFetch, url, bearer, body) {
1784
2374
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
1785
2375
  body: JSON.stringify(body)
1786
2376
  });
1787
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
2377
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
1788
2378
  }
1789
2379
  function normalizeClerkConfig(value) {
1790
2380
  if (!value) return null;
@@ -1801,7 +2391,7 @@ function defaultSecretName(provider) {
1801
2391
  const names = import_ai.DEFAULT_SECRET_NAMES;
1802
2392
  return names[provider] ?? `${provider}_api_key`;
1803
2393
  }
1804
- async function safeText2(res) {
2394
+ async function safeText3(res) {
1805
2395
  try {
1806
2396
  return redactSecrets((await res.text()).slice(0, 500));
1807
2397
  } catch {
@@ -3050,6 +3640,23 @@ async function smoke(options) {
3050
3640
  }
3051
3641
  out.log(` ai: ${provider}`);
3052
3642
  }
3643
+ if (cfg.services.includes("calendar")) {
3644
+ const token = await getDeveloperToken(
3645
+ cfg,
3646
+ {
3647
+ configPath: cfg.configPath,
3648
+ token: options.token,
3649
+ open: options.open,
3650
+ interactive: options.interactive,
3651
+ openApprovalUrl: options.openApprovalUrl
3652
+ },
3653
+ doFetch,
3654
+ out
3655
+ );
3656
+ const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
3657
+ assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
3658
+ out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
3659
+ }
3053
3660
  const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
3054
3661
  const expectedEntities = serializedEntities(expectedSchema);
3055
3662
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
@@ -3071,13 +3678,35 @@ async function smoke(options) {
3071
3678
  } else {
3072
3679
  out.log(` aggregate: skipped (schema has no entities)`);
3073
3680
  }
3681
+ if (cfg.services.includes("calendar")) {
3682
+ const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3683
+ ns: "$bookings",
3684
+ aggregate: { count: true }
3685
+ });
3686
+ const count = bookings.aggregate?.count;
3687
+ out.log(` bookings: ${String(count ?? "ok")}`);
3688
+ }
3074
3689
  out.log("ok");
3075
3690
  }
3691
+ function assertCalendarHealthy(status, expected) {
3692
+ if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
3693
+ if (status.status !== "healthy") {
3694
+ throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
3695
+ }
3696
+ if (status.access !== "read") throw new Error("calendar connection is not read-only");
3697
+ const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
3698
+ if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
3699
+ const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
3700
+ if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
3701
+ if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
3702
+ if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
3703
+ if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
3704
+ }
3076
3705
  async function getJson(doFetch, url, bearer) {
3077
3706
  const res = await doFetch(url, {
3078
3707
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
3079
3708
  });
3080
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3709
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3081
3710
  return res.json();
3082
3711
  }
3083
3712
  async function postJson2(doFetch, url, bearer, body) {
@@ -3086,7 +3715,7 @@ async function postJson2(doFetch, url, bearer, body) {
3086
3715
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
3087
3716
  body: JSON.stringify(body)
3088
3717
  });
3089
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3718
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3090
3719
  return res.json();
3091
3720
  }
3092
3721
  function publicConfigUrl(platformUrl, appId, env) {
@@ -3094,7 +3723,7 @@ function publicConfigUrl(platformUrl, appId, env) {
3094
3723
  url.searchParams.set("env", env);
3095
3724
  return url.toString();
3096
3725
  }
3097
- async function safeText3(res) {
3726
+ async function safeText4(res) {
3098
3727
  try {
3099
3728
  return (await res.text()).slice(0, 500);
3100
3729
  } catch {
@@ -3142,6 +3771,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3142
3771
  await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
3143
3772
  return;
3144
3773
  }
3774
+ if (command === "calendar") {
3775
+ await calendarCommand(parsed, dependencies);
3776
+ return;
3777
+ }
3145
3778
  if (command === "capabilities") {
3146
3779
  assertArgs(parsed, ["json"], 1);
3147
3780
  printCapabilities(parsed.options.json === true);
@@ -3156,14 +3789,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3156
3789
  return;
3157
3790
  }
3158
3791
  if (command === "provision") {
3159
- await provisionCommand(parsed);
3792
+ await provisionCommand(parsed, dependencies);
3160
3793
  return;
3161
3794
  }
3162
3795
  if (command === "smoke") {
3163
- assertArgs(parsed, ["config", "env"], 1);
3796
+ assertArgs(parsed, ["config", "env", "token", "open"], 1);
3164
3797
  await smoke({
3165
3798
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3166
- env: stringOpt(parsed.options.env)
3799
+ env: stringOpt(parsed.options.env),
3800
+ token: stringOpt(parsed.options.token),
3801
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3802
+ openApprovalUrl: dependencies.openUrl,
3803
+ fetch: dependencies.fetch,
3804
+ stdout: dependencies.stdout
3167
3805
  });
3168
3806
  return;
3169
3807
  }
@@ -3228,7 +3866,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3228
3866
  }
3229
3867
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
3230
3868
  }
3231
- async function provisionCommand(parsed) {
3869
+ async function provisionCommand(parsed, dependencies) {
3232
3870
  assertArgs(parsed, [
3233
3871
  "config",
3234
3872
  "dry-run",
@@ -3252,10 +3890,38 @@ async function provisionCommand(parsed) {
3252
3890
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
3253
3891
  token: stringOpt(parsed.options.token),
3254
3892
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3255
- yes: parsed.options.yes === true
3893
+ yes: parsed.options.yes === true,
3894
+ fetch: dependencies.fetch,
3895
+ openApprovalUrl: dependencies.openUrl,
3896
+ stdout: dependencies.stdout
3256
3897
  };
3257
3898
  await provision(options);
3258
3899
  }
3900
+ async function calendarCommand(parsed, dependencies) {
3901
+ const sub = parsed.positionals[1];
3902
+ if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
3903
+ throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
3904
+ }
3905
+ assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
3906
+ if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
3907
+ if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
3908
+ const options = {
3909
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3910
+ env: stringOpt(parsed.options.env),
3911
+ token: stringOpt(parsed.options.token),
3912
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3913
+ yes: parsed.options.yes === true,
3914
+ json: parsed.options.json === true,
3915
+ fetch: dependencies.fetch,
3916
+ stdout: dependencies.stdout,
3917
+ openConsentUrl: dependencies.openUrl
3918
+ };
3919
+ if (sub === "status") await calendarStatus(options);
3920
+ else if (sub === "calendars") await calendarCalendars(options);
3921
+ else if (sub === "connect") await calendarConnect(options);
3922
+ else if (sub === "resync") await calendarResync(options);
3923
+ else await calendarDisconnect(options);
3924
+ }
3259
3925
 
3260
3926
  // src/bin.ts
3261
3927
  runCli().catch((err) => {