@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.
@@ -654,6 +654,7 @@ var CAPABILITIES = {
654
654
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
655
655
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
656
656
  "push db schema/rules and configure platform AI, auth, and deployment links",
657
+ "apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
657
658
  "validate config offline and smoke-test a provisioned db environment",
658
659
  "run app-attributed hosted security discovery and independent validation without provider keys",
659
660
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
@@ -662,10 +663,12 @@ var CAPABILITIES = {
662
663
  agent: [
663
664
  "install and import the selected odla SDKs",
664
665
  "wrap the Worker with withObservability and choose useful telemetry",
665
- "make application-specific schema, rules, auth, UI, and migration decisions"
666
+ "make application-specific schema, rules, auth, UI, and migration decisions",
667
+ "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
666
668
  ],
667
669
  human: [
668
670
  "approve the odla device code and one-off third-party logins",
671
+ "review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
669
672
  "consent to production changes with --yes",
670
673
  "request destructive credential rotation explicitly",
671
674
  "review application semantics, security findings, releases, and merges",
@@ -674,6 +677,7 @@ var CAPABILITIES = {
674
677
  ],
675
678
  studio: [
676
679
  "view telemetry and environment state",
680
+ "review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
677
681
  "perform manual credential recovery when the CLI's local shown-once copy is unavailable",
678
682
  "configure system AI purposes and view app/environment/run-attributed usage",
679
683
  "connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
@@ -696,25 +700,6 @@ function printGroup(out, heading, items) {
696
700
  out.log("");
697
701
  }
698
702
 
699
- // src/redact.ts
700
- var REPLACEMENTS = [
701
- [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
702
- [/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
703
- [/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
704
- [/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
705
- [/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
706
- [/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
707
- [/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
708
- ];
709
- function redactSecrets(value) {
710
- let result = value;
711
- for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
712
- return result;
713
- }
714
- function looksSecret(value) {
715
- return redactSecrets(value) !== value || value.includes("-----BEGIN");
716
- }
717
-
718
703
  // src/config.ts
719
704
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
720
705
  import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
@@ -722,6 +707,7 @@ import { pathToFileURL } from "url";
722
707
  var DEFAULT_PLATFORM = "https://odla.ai";
723
708
  var DEFAULT_ENVS = ["dev"];
724
709
  var DEFAULT_SERVICES = ["db", "ai"];
710
+ var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
725
711
  async function loadProjectConfig(configPath = "odla.config.mjs") {
726
712
  const resolved = resolve2(configPath);
727
713
  if (!existsSync3(resolved)) {
@@ -734,6 +720,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
734
720
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
735
721
  const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
736
722
  const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
723
+ validateCalendarConfig(raw, envs, services, resolved);
737
724
  const local = {
738
725
  tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
739
726
  credentialsFile: resolve2(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
@@ -778,6 +765,28 @@ function buildPlan(cfg) {
778
765
  aiProvider: cfg.ai?.provider
779
766
  };
780
767
  }
768
+ function calendarServiceConfig(cfg, env) {
769
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
770
+ if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
771
+ const google = cfg.calendar?.google;
772
+ if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
773
+ return {
774
+ provider: "google",
775
+ access: "read",
776
+ calendars: unique(google.calendars[env].map((id) => id.trim())),
777
+ match: {
778
+ organizerSelf: google.match?.organizerSelf ?? true,
779
+ requireAttendees: google.match?.requireAttendees ?? true,
780
+ ...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
781
+ },
782
+ attendeePolicy: google.attendeePolicy ?? "full"
783
+ };
784
+ }
785
+ function calendarBookingPageUrl(cfg, env) {
786
+ const value = cfg.calendar?.google.bookingPageUrl?.[env];
787
+ if (value === void 0 || value === null) return value;
788
+ return new URL(value).toString();
789
+ }
781
790
  function rulesFromSchema(schema) {
782
791
  const entities = serializedEntities(schema);
783
792
  return Object.fromEntries(
@@ -801,7 +810,85 @@ function validateRawConfig(raw, path) {
801
810
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
802
811
  if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
803
812
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
813
+ if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
814
+ throw new Error(`${path}: envs must be an array of names`);
815
+ }
804
816
  if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
817
+ if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
818
+ throw new Error(`${path}: services must be an array of non-empty names`);
819
+ }
820
+ }
821
+ function validateCalendarConfig(cfg, envs, services, path) {
822
+ const enabled = services.includes("calendar");
823
+ if (!cfg.calendar) {
824
+ if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
825
+ return;
826
+ }
827
+ if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
828
+ assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
829
+ if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
830
+ const google = cfg.calendar.google;
831
+ assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
832
+ if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
833
+ const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
834
+ if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
835
+ for (const env of envs) {
836
+ const ids = google.calendars[env];
837
+ if (!Array.isArray(ids) || ids.length === 0) {
838
+ throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
839
+ }
840
+ if (ids.length > 10) {
841
+ throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
842
+ }
843
+ if (ids.some((id) => !safeText(id, 1024))) {
844
+ throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
845
+ }
846
+ }
847
+ if (google.bookingPageUrl !== void 0) {
848
+ if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
849
+ const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
850
+ if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
851
+ for (const [env, value] of Object.entries(google.bookingPageUrl)) {
852
+ if (value !== null && !safeHttpsUrl(value)) {
853
+ throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
854
+ }
855
+ }
856
+ }
857
+ if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
858
+ throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
859
+ }
860
+ if (google.match !== void 0) {
861
+ if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
862
+ assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
863
+ if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
864
+ throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
865
+ }
866
+ for (const name of ["organizerSelf", "requireAttendees"]) {
867
+ if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
868
+ throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
869
+ }
870
+ }
871
+ }
872
+ if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
873
+ }
874
+ function assertOnly(value, allowed, label) {
875
+ const extra = Object.keys(value).find((key) => !allowed.includes(key));
876
+ if (extra) throw new Error(`${label}.${extra} is not supported`);
877
+ }
878
+ function isRecord4(value) {
879
+ return value !== null && typeof value === "object" && !Array.isArray(value);
880
+ }
881
+ function safeText(value, max) {
882
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
883
+ }
884
+ function safeHttpsUrl(value) {
885
+ if (typeof value !== "string" || value.length > 2048) return false;
886
+ try {
887
+ const url = new URL(value);
888
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
889
+ } catch {
890
+ return false;
891
+ }
805
892
  }
806
893
  function validId(value) {
807
894
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
@@ -820,6 +907,440 @@ function unique(values) {
820
907
  return [...new Set(values.filter(Boolean))];
821
908
  }
822
909
 
910
+ // src/redact.ts
911
+ var REPLACEMENTS = [
912
+ [/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
913
+ [/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
914
+ [/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
915
+ [/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
916
+ [/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
917
+ [/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
918
+ [/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
919
+ ];
920
+ function redactSecrets(value) {
921
+ let result = value;
922
+ for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
923
+ return result;
924
+ }
925
+ function looksSecret(value) {
926
+ return redactSecrets(value) !== value || value.includes("-----BEGIN");
927
+ }
928
+
929
+ // src/calendar-http.ts
930
+ var CALENDAR_STATES = [
931
+ "not_connected",
932
+ "authorizing",
933
+ "needs_sync",
934
+ "initial_sync",
935
+ "healthy",
936
+ "degraded",
937
+ "disconnected",
938
+ "failed"
939
+ ];
940
+ async function readCalendarStatus(ctx) {
941
+ return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
942
+ }
943
+ async function discoverGoogleCalendars(ctx) {
944
+ const raw = await calendarJson(ctx, "/calendars", {});
945
+ const value = record(raw);
946
+ if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
947
+ return value.calendars.map((item, index) => {
948
+ const calendar = record(item);
949
+ const id = textField(calendar?.id, 1024);
950
+ if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
951
+ const role = calendar.accessRole;
952
+ if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
953
+ throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
954
+ }
955
+ return {
956
+ id,
957
+ ...optionalText("summary", calendar.summary, 500),
958
+ ...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
959
+ ...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
960
+ ...typeof role === "string" ? { accessRole: role } : {}
961
+ };
962
+ });
963
+ }
964
+ async function applyCalendarSettings(ctx, bookingPageUrl) {
965
+ return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
966
+ }
967
+ async function startCalendarConnection(ctx) {
968
+ const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
969
+ const value = wrapped(raw, "attempt");
970
+ const attemptId = textField(value.attemptId, 180);
971
+ const consentUrl = textField(value.consentUrl, 4096);
972
+ const expiresAt = timestamp3(value.expiresAt);
973
+ if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
974
+ return { attemptId, consentUrl, expiresAt };
975
+ }
976
+ async function pollCalendarConnection(ctx, attemptId) {
977
+ return parseCalendarStatus(
978
+ await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
979
+ ctx.env
980
+ );
981
+ }
982
+ async function requestCalendarResync(ctx) {
983
+ return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
984
+ }
985
+ async function requestCalendarDisconnect(ctx) {
986
+ return parseCalendarStatus(
987
+ await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
988
+ ctx.env
989
+ );
990
+ }
991
+ function parseCalendarStatus(raw, env) {
992
+ const outer = wrapped(raw, "calendar");
993
+ const value = record(outer.attempt) ?? record(outer.status) ?? outer;
994
+ const connection = record(value.connection) ?? {};
995
+ const sync = record(value.sync) ?? record(connection.sync) ?? {};
996
+ const config = record(value.config) ?? record(outer.config) ?? {};
997
+ const googleConfig = record(config.google) ?? config;
998
+ const watches = record(value.watches) ?? {};
999
+ const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
1000
+ if (!stateValue) {
1001
+ throw new Error("calendar status returned an invalid connection state");
1002
+ }
1003
+ const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
1004
+ if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
1005
+ throw new Error("calendar status returned an unsupported provider");
1006
+ }
1007
+ const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
1008
+ if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
1009
+ const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
1010
+ if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
1011
+ throw new Error("calendar status returned an invalid attendee policy");
1012
+ }
1013
+ const errorValue = record(value.error) ?? record(connection.error);
1014
+ const errorCode = textField(value.lastErrorCode, 128);
1015
+ const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
1016
+ return {
1017
+ env,
1018
+ enabled: typeof value.enabled === "boolean" ? value.enabled : true,
1019
+ connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
1020
+ provider: providerValue === "google" ? "google" : null,
1021
+ status: stateValue,
1022
+ ...accessValue === "read" ? { access: "read" } : {},
1023
+ calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
1024
+ ...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
1025
+ ...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
1026
+ ...optionalNullableUrl("bookingPageUrl", bookingPageValue),
1027
+ grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
1028
+ ...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
1029
+ ...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
1030
+ ...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
1031
+ ...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
1032
+ ...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
1033
+ ...errorValue || errorCode ? { error: {
1034
+ ...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
1035
+ ...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
1036
+ ...!errorValue && errorCode ? { code: errorCode } : {}
1037
+ } } : {}
1038
+ };
1039
+ }
1040
+ function calendarState(value) {
1041
+ if (typeof value !== "string") return null;
1042
+ if (CALENDAR_STATES.includes(value)) return value;
1043
+ const legacy = {
1044
+ disabled: "not_connected",
1045
+ disconnected: "disconnected",
1046
+ connecting: "authorizing",
1047
+ syncing: "initial_sync",
1048
+ ready: "healthy",
1049
+ error: "failed"
1050
+ };
1051
+ return legacy[value] ?? null;
1052
+ }
1053
+ async function calendarJson(ctx, suffix, init) {
1054
+ const platform = platformAudience(ctx.platform);
1055
+ const appId = identifier(ctx.appId, "appId");
1056
+ const env = identifier(ctx.env, "env");
1057
+ const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
1058
+ url.searchParams.set("env", env);
1059
+ const token = credential(ctx.token);
1060
+ let response;
1061
+ try {
1062
+ response = await (ctx.fetch ?? fetch)(url, {
1063
+ ...init,
1064
+ redirect: "error",
1065
+ credentials: "omit",
1066
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
1067
+ });
1068
+ } catch (error) {
1069
+ throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
1070
+ }
1071
+ const body = await response.json().catch(() => ({}));
1072
+ if (!response.ok) {
1073
+ const code = textField(body.error?.code, 128);
1074
+ const message = textField(body.error?.message, 500);
1075
+ throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
1076
+ }
1077
+ return body;
1078
+ }
1079
+ function wrapped(raw, key) {
1080
+ const outer = record(raw);
1081
+ if (!outer) throw new Error("calendar returned an invalid response");
1082
+ return record(outer[key]) ?? outer;
1083
+ }
1084
+ function record(value) {
1085
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1086
+ }
1087
+ function textField(value, max) {
1088
+ return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
1089
+ }
1090
+ function stringList(value) {
1091
+ return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
1092
+ }
1093
+ function calendarIds(value) {
1094
+ if (!Array.isArray(value)) return [];
1095
+ return [...new Set(value.flatMap((item) => {
1096
+ if (typeof item === "string") return textField(item, 4096) ? [item] : [];
1097
+ const calendar = record(item);
1098
+ const id = textField(calendar?.id, 4096);
1099
+ return id && calendar?.selected !== false ? [id] : [];
1100
+ }))];
1101
+ }
1102
+ function timestamp3(value) {
1103
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
1104
+ if (typeof value === "string") {
1105
+ const parsed = Date.parse(value);
1106
+ if (Number.isFinite(parsed)) return parsed;
1107
+ }
1108
+ return void 0;
1109
+ }
1110
+ function optionalText(key, value, max) {
1111
+ const parsed = textField(value, max);
1112
+ return parsed ? { [key]: parsed } : {};
1113
+ }
1114
+ function optionalNumber(key, value) {
1115
+ const parsed = timestamp3(value);
1116
+ return parsed === void 0 ? {} : { [key]: parsed };
1117
+ }
1118
+ function optionalInteger(key, value) {
1119
+ return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
1120
+ }
1121
+ function optionalNullableUrl(key, value) {
1122
+ if (value === null) return { [key]: null };
1123
+ const parsed = textField(value, 4096);
1124
+ return parsed ? { [key]: parsed } : {};
1125
+ }
1126
+ function identifier(value, name) {
1127
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
1128
+ return value;
1129
+ }
1130
+ function credential(value) {
1131
+ if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
1132
+ throw new Error("calendar requires an odla developer token");
1133
+ }
1134
+ return value;
1135
+ }
1136
+
1137
+ // src/calendar-poll.ts
1138
+ async function waitForCalendarPoll(milliseconds, signal) {
1139
+ if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
1140
+ await new Promise((resolve7, reject) => {
1141
+ const timer = setTimeout(resolve7, milliseconds);
1142
+ signal?.addEventListener("abort", () => {
1143
+ clearTimeout(timer);
1144
+ reject(signal.reason ?? new Error("calendar connection aborted"));
1145
+ }, { once: true });
1146
+ });
1147
+ }
1148
+
1149
+ // src/calendar.ts
1150
+ async function calendarStatus(options) {
1151
+ const { ctx, out } = await lifecycleContext(options);
1152
+ const status = await readCalendarStatus(ctx);
1153
+ printStatus(status, options.json === true, out);
1154
+ return status;
1155
+ }
1156
+ async function calendarCalendars(options) {
1157
+ const { ctx, out } = await lifecycleContext(options);
1158
+ const calendars = await discoverGoogleCalendars(ctx);
1159
+ if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
1160
+ else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
1161
+ else for (const calendar of calendars) {
1162
+ out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
1163
+ }
1164
+ return calendars;
1165
+ }
1166
+ async function calendarConnect(options) {
1167
+ const { cfg, ctx, out } = await lifecycleContext(options);
1168
+ productionConsent(ctx.env, options.yes, "connect calendar");
1169
+ const page = calendarBookingPageUrl(cfg, ctx.env);
1170
+ const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
1171
+ const connectOptions = connectionOptions(options, out);
1172
+ return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
1173
+ }
1174
+ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
1175
+ if (bookingPageUrl === void 0) return null;
1176
+ const status = await applyCalendarSettings(ctx, bookingPageUrl);
1177
+ out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
1178
+ return status;
1179
+ }
1180
+ async function calendarResync(options) {
1181
+ const { ctx, out } = await lifecycleContext(options);
1182
+ productionConsent(ctx.env, options.yes, "resync calendar");
1183
+ const status = await requestCalendarResync(ctx);
1184
+ out.log(`${ctx.env}: calendar resync requested (${status.status})`);
1185
+ return status;
1186
+ }
1187
+ async function calendarDisconnect(options) {
1188
+ if (!options.yes) throw new Error("calendar disconnect requires --yes");
1189
+ const { ctx, out } = await lifecycleContext(options);
1190
+ const status = await requestCalendarDisconnect(ctx);
1191
+ out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
1192
+ return status;
1193
+ }
1194
+ async function ensureCalendarConnected(ctx, options) {
1195
+ const out = options.stdout ?? console;
1196
+ const current = await readCalendarStatus(ctx);
1197
+ const connectOptions = connectionOptions(options, out);
1198
+ return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
1199
+ }
1200
+ async function lifecycleContext(options) {
1201
+ const cfg = await loadProjectConfig(options.configPath);
1202
+ if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
1203
+ const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
1204
+ if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
1205
+ calendarServiceConfig(cfg, env);
1206
+ const out = options.stdout ?? console;
1207
+ const doFetch = options.fetch ?? fetch;
1208
+ const token = await getDeveloperToken(
1209
+ cfg,
1210
+ {
1211
+ configPath: cfg.configPath,
1212
+ token: options.token,
1213
+ open: options.open,
1214
+ interactive: options.interactive,
1215
+ openApprovalUrl: options.openConsentUrl
1216
+ },
1217
+ doFetch,
1218
+ out
1219
+ );
1220
+ return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
1221
+ }
1222
+ function connectionOptions(options, out) {
1223
+ const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
1224
+ return {
1225
+ out,
1226
+ open: browser.open,
1227
+ openConsentUrl: options.openConsentUrl,
1228
+ wait: options.wait,
1229
+ pollTimeoutMs: options.pollTimeoutMs,
1230
+ now: options.now,
1231
+ signal: options.signal
1232
+ };
1233
+ }
1234
+ async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
1235
+ if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
1236
+ options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
1237
+ return current;
1238
+ }
1239
+ if (current.status === "degraded") return null;
1240
+ if (current.status === "needs_sync") {
1241
+ if (!current.connected) return null;
1242
+ options.out.log(`${ctx.env}: calendar configuration needs sync`);
1243
+ const synced = await requestCalendarResync(ctx);
1244
+ options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
1245
+ return synced;
1246
+ }
1247
+ if (current.status === "failed" && current.connected) {
1248
+ const reason = current.error?.message ?? current.error?.code;
1249
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1250
+ }
1251
+ const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
1252
+ if (!waitingForExisting) return null;
1253
+ const now = options.now ?? Date.now;
1254
+ const deadline = now() + pollTimeout(options.pollTimeoutMs);
1255
+ const wait = options.wait ?? waitForCalendarPoll;
1256
+ let prior = "";
1257
+ while (now() < deadline) {
1258
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1259
+ const status = await readCalendarStatus(ctx);
1260
+ if (status.status !== prior) {
1261
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1262
+ prior = status.status;
1263
+ }
1264
+ if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
1265
+ if (status.status === "degraded") return null;
1266
+ if (status.status === "needs_sync") return requestCalendarResync(ctx);
1267
+ if (status.status === "failed" && status.connected) {
1268
+ const reason = status.error?.message ?? status.error?.code;
1269
+ throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
1270
+ }
1271
+ if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
1272
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1273
+ }
1274
+ throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
1275
+ }
1276
+ async function connectWithContext(ctx, options) {
1277
+ const attempt = await startCalendarConnection(ctx);
1278
+ const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
1279
+ options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
1280
+ if (options.open) {
1281
+ await (options.openConsentUrl ?? openUrl)(consentUrl);
1282
+ options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
1283
+ }
1284
+ const now = options.now ?? Date.now;
1285
+ const timeout = pollTimeout(options.pollTimeoutMs);
1286
+ const deadline = Math.min(attempt.expiresAt, now() + timeout);
1287
+ const wait = options.wait ?? waitForCalendarPoll;
1288
+ let prior = "";
1289
+ while (now() < deadline) {
1290
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
1291
+ const status = await pollCalendarConnection(ctx, attempt.attemptId);
1292
+ if (status.status !== prior) {
1293
+ options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
1294
+ prior = status.status;
1295
+ }
1296
+ if (status.status === "healthy" || status.status === "degraded") return status;
1297
+ if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
1298
+ const reason = status.error?.message ?? status.error?.code;
1299
+ throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
1300
+ }
1301
+ await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
1302
+ }
1303
+ throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
1304
+ }
1305
+ function trustedCalendarConsentUrl(platform, value) {
1306
+ const origin = platformAudience(platform);
1307
+ let url;
1308
+ try {
1309
+ url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
1310
+ } catch {
1311
+ throw new Error("odla.ai returned an invalid calendar consent URL");
1312
+ }
1313
+ const platformPage = url.origin === origin;
1314
+ const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
1315
+ if (!platformPage && !googlePage || url.username || url.password || url.hash) {
1316
+ throw new Error("odla.ai returned an untrusted calendar consent URL");
1317
+ }
1318
+ return url.toString();
1319
+ }
1320
+ function pollTimeout(value = 10 * 6e4) {
1321
+ if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
1322
+ return value;
1323
+ }
1324
+ function productionConsent(env, yes, action) {
1325
+ if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
1326
+ }
1327
+ function printStatus(status, json, out) {
1328
+ if (json) {
1329
+ out.log(JSON.stringify(status, null, 2));
1330
+ return;
1331
+ }
1332
+ out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
1333
+ out.log(` access: ${status.access ?? "not granted"}`);
1334
+ out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
1335
+ if (status.attendeePolicy) {
1336
+ out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
1337
+ }
1338
+ if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
1339
+ out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
1340
+ if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
1341
+ if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
1342
+ }
1343
+
823
1344
  // src/doctor-checks.ts
824
1345
  import { execFileSync } from "child_process";
825
1346
  import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
@@ -1005,6 +1526,15 @@ function o11yProjectWarnings(rootDir) {
1005
1526
  }
1006
1527
  return warnings;
1007
1528
  }
1529
+ function calendarProjectWarnings(rootDir) {
1530
+ const pkg = readPackageJson(rootDir);
1531
+ const dependencies = pkg?.dependencies;
1532
+ const devDependencies = pkg?.devDependencies;
1533
+ if (dependencies?.["@odla-ai/calendar"]) return [];
1534
+ return [
1535
+ 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"
1536
+ ];
1537
+ }
1008
1538
  function readPackageJson(rootDir) {
1009
1539
  try {
1010
1540
  return JSON.parse(readFileSync4(join2(rootDir, "package.json"), "utf8"));
@@ -1030,6 +1560,14 @@ async function doctor(options) {
1030
1560
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1031
1561
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1032
1562
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1563
+ if (cfg.services.includes("calendar")) {
1564
+ const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
1565
+ out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
1566
+ const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
1567
+ out.log(`booking pages: ${pages}`);
1568
+ } else {
1569
+ out.log("calendar: not enabled");
1570
+ }
1033
1571
  const warnings = [];
1034
1572
  if (schema && entities.length === 0) warnings.push("schema has no entities");
1035
1573
  if (rules) {
@@ -1057,6 +1595,8 @@ async function doctor(options) {
1057
1595
  }
1058
1596
  warnings.push(...o11yProjectWarnings(cfg.rootDir));
1059
1597
  }
1598
+ if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
1599
+ else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
1060
1600
  warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
1061
1601
  warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
1062
1602
  cfg.local.tokenFile,
@@ -1088,6 +1628,7 @@ function initProject(options) {
1088
1628
  }
1089
1629
  const envs = options.envs?.length ? options.envs : ["dev"];
1090
1630
  const services = options.services?.length ? options.services : ["db", "ai"];
1631
+ if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
1091
1632
  const aiProvider = options.aiProvider ?? "anthropic";
1092
1633
  mkdirSync2(dirname3(configPath), { recursive: true });
1093
1634
  mkdirSync2(resolve4(rootDir, "src/odla"), { recursive: true });
@@ -1105,6 +1646,16 @@ function writeIfMissing(path, text) {
1105
1646
  writeFileSync2(path, text);
1106
1647
  }
1107
1648
  function configTemplate(input) {
1649
+ const calendar = input.services.includes("calendar") ? ` calendar: {
1650
+ google: {
1651
+ calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
1652
+ bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
1653
+ match: { organizerSelf: true, requireAttendees: true },
1654
+ attendeePolicy: "full",
1655
+ // Read-only in this release. Google consent happens in Studio during provision.
1656
+ },
1657
+ },
1658
+ ` : "";
1108
1659
  return `export default {
1109
1660
  platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
1110
1661
  dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
@@ -1126,6 +1677,7 @@ function configTemplate(input) {
1126
1677
  // key in the platform vault for each tenant.
1127
1678
  keyEnv: "${defaultKeyEnv(input.aiProvider)}",
1128
1679
  },
1680
+ ${calendar}
1129
1681
  auth: {
1130
1682
  clerk: {
1131
1683
  // dev: "$CLERK_PUBLISHABLE_KEY",
@@ -1325,14 +1877,14 @@ async function mintDbKey(opts, tenantId) {
1325
1877
  appId: tenantId
1326
1878
  })
1327
1879
  });
1328
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText(created)}`);
1880
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
1329
1881
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
1330
1882
  method: "POST",
1331
1883
  headers,
1332
1884
  body: "{}"
1333
1885
  });
1334
1886
  }
1335
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText(res)}`);
1887
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
1336
1888
  const body = await res.json();
1337
1889
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
1338
1890
  return body.key;
@@ -1348,12 +1900,12 @@ async function issueO11yToken(opts) {
1348
1900
  `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`
1349
1901
  );
1350
1902
  }
1351
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText(res)}`);
1903
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
1352
1904
  const body = await res.json();
1353
1905
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
1354
1906
  return body.token;
1355
1907
  }
1356
- async function safeText(res) {
1908
+ async function safeText2(res) {
1357
1909
  try {
1358
1910
  return redactSecrets((await res.text()).slice(0, 500));
1359
1911
  } catch {
@@ -1393,6 +1945,14 @@ async function provision(options) {
1393
1945
  out.log(` schema: ${schema ? "yes" : "no"}`);
1394
1946
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
1395
1947
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
1948
+ if (cfg.services.includes("calendar")) {
1949
+ for (const env of cfg.envs) {
1950
+ const calendar = calendarServiceConfig(cfg, env);
1951
+ out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
1952
+ const bookingPage = calendarBookingPageUrl(cfg, env);
1953
+ out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
1954
+ }
1955
+ }
1396
1956
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
1397
1957
  return;
1398
1958
  }
@@ -1427,8 +1987,9 @@ async function provision(options) {
1427
1987
  await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
1428
1988
  out.log(`app: created ${cfg.app.id}`);
1429
1989
  }
1990
+ const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
1430
1991
  for (const env of cfg.envs) {
1431
- for (const service of cfg.services) {
1992
+ for (const service of serviceOrder) {
1432
1993
  if (service === "ai") {
1433
1994
  if (cfg.ai?.provider) {
1434
1995
  await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
@@ -1438,7 +1999,8 @@ async function provision(options) {
1438
1999
  out.log(`${env}: ai enabled`);
1439
2000
  }
1440
2001
  } else {
1441
- await apps.setService(cfg.app.id, service, true, { env });
2002
+ const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
2003
+ await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
1442
2004
  out.log(`${env}: ${service} enabled`);
1443
2005
  }
1444
2006
  }
@@ -1452,6 +2014,21 @@ async function provision(options) {
1452
2014
  await apps.setLink(cfg.app.id, env, link ?? null);
1453
2015
  out.log(`${env}: link ${link ? "set" : "cleared"}`);
1454
2016
  }
2017
+ if (cfg.services.includes("calendar")) {
2018
+ const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
2019
+ await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
2020
+ await ensureCalendarConnected(
2021
+ calendarCtx,
2022
+ {
2023
+ open: options.open,
2024
+ interactive: options.interactive,
2025
+ openConsentUrl: options.openApprovalUrl,
2026
+ wait: options.calendarPollWait,
2027
+ pollTimeoutMs: options.calendarPollTimeoutMs,
2028
+ stdout: out
2029
+ }
2030
+ );
2031
+ }
1455
2032
  }
1456
2033
  for (const env of cfg.envs) {
1457
2034
  const tenantId = tenantIdFor2(cfg.app.id, env);
@@ -1515,6 +2092,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1515
2092
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
1516
2093
  }
1517
2094
  }
2095
+ function serviceRank(service) {
2096
+ if (service === "db") return 0;
2097
+ if (service === "calendar") return 2;
2098
+ return 1;
2099
+ }
1518
2100
  async function readRegistryApp(cfg, token, doFetch) {
1519
2101
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
1520
2102
  headers: { authorization: `Bearer ${token}` }
@@ -1530,7 +2112,7 @@ async function postJson(doFetch, url, bearer, body) {
1530
2112
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
1531
2113
  body: JSON.stringify(body)
1532
2114
  });
1533
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText2(res)}`);
2115
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
1534
2116
  }
1535
2117
  function normalizeClerkConfig(value) {
1536
2118
  if (!value) return null;
@@ -1547,7 +2129,7 @@ function defaultSecretName(provider) {
1547
2129
  const names = DEFAULT_SECRET_NAMES;
1548
2130
  return names[provider] ?? `${provider}_api_key`;
1549
2131
  }
1550
- async function safeText2(res) {
2132
+ async function safeText3(res) {
1551
2133
  try {
1552
2134
  return redactSecrets((await res.text()).slice(0, 500));
1553
2135
  } catch {
@@ -2382,6 +2964,23 @@ async function smoke(options) {
2382
2964
  }
2383
2965
  out.log(` ai: ${provider}`);
2384
2966
  }
2967
+ if (cfg.services.includes("calendar")) {
2968
+ const token = await getDeveloperToken(
2969
+ cfg,
2970
+ {
2971
+ configPath: cfg.configPath,
2972
+ token: options.token,
2973
+ open: options.open,
2974
+ interactive: options.interactive,
2975
+ openApprovalUrl: options.openApprovalUrl
2976
+ },
2977
+ doFetch,
2978
+ out
2979
+ );
2980
+ const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
2981
+ assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
2982
+ out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
2983
+ }
2385
2984
  const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
2386
2985
  const expectedEntities = serializedEntities(expectedSchema);
2387
2986
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
@@ -2403,13 +3002,35 @@ async function smoke(options) {
2403
3002
  } else {
2404
3003
  out.log(` aggregate: skipped (schema has no entities)`);
2405
3004
  }
3005
+ if (cfg.services.includes("calendar")) {
3006
+ const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3007
+ ns: "$bookings",
3008
+ aggregate: { count: true }
3009
+ });
3010
+ const count = bookings.aggregate?.count;
3011
+ out.log(` bookings: ${String(count ?? "ok")}`);
3012
+ }
2406
3013
  out.log("ok");
2407
3014
  }
3015
+ function assertCalendarHealthy(status, expected) {
3016
+ if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
3017
+ if (status.status !== "healthy") {
3018
+ throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
3019
+ }
3020
+ if (status.access !== "read") throw new Error("calendar connection is not read-only");
3021
+ const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
3022
+ if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
3023
+ const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
3024
+ if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
3025
+ if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
3026
+ if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
3027
+ if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
3028
+ }
2408
3029
  async function getJson(doFetch, url, bearer) {
2409
3030
  const res = await doFetch(url, {
2410
3031
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
2411
3032
  });
2412
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3033
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
2413
3034
  return res.json();
2414
3035
  }
2415
3036
  async function postJson2(doFetch, url, bearer, body) {
@@ -2418,7 +3039,7 @@ async function postJson2(doFetch, url, bearer, body) {
2418
3039
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2419
3040
  body: JSON.stringify(body)
2420
3041
  });
2421
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText3(res)}`);
3042
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
2422
3043
  return res.json();
2423
3044
  }
2424
3045
  function publicConfigUrl(platformUrl, appId, env) {
@@ -2426,7 +3047,7 @@ function publicConfigUrl(platformUrl, appId, env) {
2426
3047
  url.searchParams.set("env", env);
2427
3048
  return url.toString();
2428
3049
  }
2429
- async function safeText3(res) {
3050
+ async function safeText4(res) {
2430
3051
  try {
2431
3052
  return (await res.text()).slice(0, 500);
2432
3053
  } catch {
@@ -2579,8 +3200,13 @@ function printHelp() {
2579
3200
 
2580
3201
  Usage:
2581
3202
  odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
2582
- odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
3203
+ odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
2583
3204
  odla-ai doctor [--config odla.config.mjs]
3205
+ odla-ai calendar status [--env dev] [--json]
3206
+ odla-ai calendar calendars [--env dev] [--json]
3207
+ odla-ai calendar connect [--env dev] [--no-open] [--yes]
3208
+ odla-ai calendar resync [--env dev] [--yes]
3209
+ odla-ai calendar disconnect [--env dev] --yes
2584
3210
  odla-ai capabilities [--json]
2585
3211
  odla-ai admin ai show [--platform https://odla.ai] [--json]
2586
3212
  odla-ai admin ai models [--provider <id>] [--json]
@@ -2601,7 +3227,7 @@ Usage:
2601
3227
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
2602
3228
  odla-ai security run [target] --self --ack-redacted-source
2603
3229
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
2604
- odla-ai smoke [--config odla.config.mjs] [--env dev]
3230
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
2605
3231
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
2606
3232
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
2607
3233
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
@@ -2612,6 +3238,7 @@ Commands:
2612
3238
  setup Install offline odla runbooks for common coding-agent harnesses.
2613
3239
  init Create a generic odla.config.mjs plus starter schema/rules files.
2614
3240
  doctor Validate and summarize the project config without network calls.
3241
+ calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
2615
3242
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
2616
3243
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
2617
3244
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
@@ -2632,6 +3259,8 @@ Safety:
2632
3259
  preflights Wrangler before any shown-once issuance or destructive rotation.
2633
3260
  Provision opens the approval page automatically in interactive terminals;
2634
3261
  use --open to force browser launch or --no-open to suppress it.
3262
+ Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
3263
+ OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
2635
3264
  GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
2636
3265
  for a PAT or provider key. GitHub read access is separate from the explicit
2637
3266
  --ack-redacted-source consent and the exact --plan-digest printed by security
@@ -3131,6 +3760,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3131
3760
  await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
3132
3761
  return;
3133
3762
  }
3763
+ if (command === "calendar") {
3764
+ await calendarCommand(parsed, dependencies);
3765
+ return;
3766
+ }
3134
3767
  if (command === "capabilities") {
3135
3768
  assertArgs(parsed, ["json"], 1);
3136
3769
  printCapabilities(parsed.options.json === true);
@@ -3145,14 +3778,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3145
3778
  return;
3146
3779
  }
3147
3780
  if (command === "provision") {
3148
- await provisionCommand(parsed);
3781
+ await provisionCommand(parsed, dependencies);
3149
3782
  return;
3150
3783
  }
3151
3784
  if (command === "smoke") {
3152
- assertArgs(parsed, ["config", "env"], 1);
3785
+ assertArgs(parsed, ["config", "env", "token", "open"], 1);
3153
3786
  await smoke({
3154
3787
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3155
- env: stringOpt(parsed.options.env)
3788
+ env: stringOpt(parsed.options.env),
3789
+ token: stringOpt(parsed.options.token),
3790
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3791
+ openApprovalUrl: dependencies.openUrl,
3792
+ fetch: dependencies.fetch,
3793
+ stdout: dependencies.stdout
3156
3794
  });
3157
3795
  return;
3158
3796
  }
@@ -3217,7 +3855,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3217
3855
  }
3218
3856
  throw new Error(`unknown command "${command}". Run "odla-ai help".`);
3219
3857
  }
3220
- async function provisionCommand(parsed) {
3858
+ async function provisionCommand(parsed, dependencies) {
3221
3859
  assertArgs(parsed, [
3222
3860
  "config",
3223
3861
  "dry-run",
@@ -3241,10 +3879,38 @@ async function provisionCommand(parsed) {
3241
3879
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
3242
3880
  token: stringOpt(parsed.options.token),
3243
3881
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3244
- yes: parsed.options.yes === true
3882
+ yes: parsed.options.yes === true,
3883
+ fetch: dependencies.fetch,
3884
+ openApprovalUrl: dependencies.openUrl,
3885
+ stdout: dependencies.stdout
3245
3886
  };
3246
3887
  await provision(options);
3247
3888
  }
3889
+ async function calendarCommand(parsed, dependencies) {
3890
+ const sub = parsed.positionals[1];
3891
+ if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
3892
+ throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
3893
+ }
3894
+ assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
3895
+ if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
3896
+ if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
3897
+ const options = {
3898
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3899
+ env: stringOpt(parsed.options.env),
3900
+ token: stringOpt(parsed.options.token),
3901
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
3902
+ yes: parsed.options.yes === true,
3903
+ json: parsed.options.json === true,
3904
+ fetch: dependencies.fetch,
3905
+ stdout: dependencies.stdout,
3906
+ openConsentUrl: dependencies.openUrl
3907
+ };
3908
+ if (sub === "status") await calendarStatus(options);
3909
+ else if (sub === "calendars") await calendarCalendars(options);
3910
+ else if (sub === "connect") await calendarConnect(options);
3911
+ else if (sub === "resync") await calendarResync(options);
3912
+ else await calendarDisconnect(options);
3913
+ }
3248
3914
 
3249
3915
  export {
3250
3916
  getScopedPlatformToken,
@@ -3252,7 +3918,15 @@ export {
3252
3918
  adminAi,
3253
3919
  CAPABILITIES,
3254
3920
  printCapabilities,
3921
+ GOOGLE_CALENDAR_READ_SCOPE,
3922
+ calendarServiceConfig,
3923
+ calendarBookingPageUrl,
3255
3924
  redactSecrets,
3925
+ calendarStatus,
3926
+ calendarCalendars,
3927
+ calendarConnect,
3928
+ calendarResync,
3929
+ calendarDisconnect,
3256
3930
  doctor,
3257
3931
  initProject,
3258
3932
  secretsPush,
@@ -3278,4 +3952,4 @@ export {
3278
3952
  smoke,
3279
3953
  runCli
3280
3954
  };
3281
- //# sourceMappingURL=chunk-MJYQ7YDH.js.map
3955
+ //# sourceMappingURL=chunk-WDKBW4BE.js.map