@odla-ai/cli 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -12
- package/REQUIREMENTS.md +31 -1
- package/dist/bin.cjs +743 -47
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-MJYQ7YDH.js → chunk-MWVKOIGR.js} +766 -62
- package/dist/chunk-MWVKOIGR.js.map +1 -0
- package/dist/index.cjs +759 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +204 -67
- package/dist/index.d.ts +204 -67
- package/dist/index.js +17 -1
- package/llms.txt +300 -80
- package/package.json +4 -4
- package/skills/odla/SKILL.md +19 -5
- package/skills/odla/references/build.md +24 -3
- package/skills/odla/references/sdks.md +29 -0
- package/skills/odla-migrate/SKILL.md +11 -2
- package/skills/odla-migrate/references/phase-2-db.md +8 -6
- package/skills/odla-migrate/references/phase-2b-calendar.md +42 -0
- package/skills/odla-migrate/references/secrets-map.md +2 -1
- package/skills/odla-migrate/references/troubleshooting.md +3 -1
- package/skills/odla-o11y-debug/SKILL.md +7 -3
- package/dist/chunk-MJYQ7YDH.js.map +0 -1
package/dist/bin.cjs
CHANGED
|
@@ -203,14 +203,16 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
203
203
|
return cached.token;
|
|
204
204
|
}
|
|
205
205
|
const browser = approvalBrowser(options);
|
|
206
|
+
const email = handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0);
|
|
206
207
|
const { token, expiresAt } = await (0, import_db.requestToken)({
|
|
207
208
|
endpoint: cfg.platformUrl,
|
|
209
|
+
email,
|
|
208
210
|
label: `${cfg.app.id} provisioner`,
|
|
209
211
|
fetch: doFetch,
|
|
210
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
211
|
-
const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
|
|
212
|
+
onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
|
|
213
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(cfg.platformUrl, userCode);
|
|
212
214
|
out.log("");
|
|
213
|
-
out.log(`
|
|
215
|
+
out.log(`Review, then approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
214
216
|
if (browser.open) {
|
|
215
217
|
try {
|
|
216
218
|
await (options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
@@ -224,10 +226,17 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
224
226
|
out.log("");
|
|
225
227
|
}
|
|
226
228
|
});
|
|
227
|
-
writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
|
|
229
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, email, token, expiresAt });
|
|
228
230
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
229
231
|
return token;
|
|
230
232
|
}
|
|
233
|
+
function handshakeEmail(value, cached) {
|
|
234
|
+
const email = (value ?? import_node_process2.default.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
|
|
235
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
236
|
+
throw new Error("a fresh odla handshake requires --email <account> or ODLA_USER_EMAIL");
|
|
237
|
+
}
|
|
238
|
+
return email;
|
|
239
|
+
}
|
|
231
240
|
function approvalBrowser(options) {
|
|
232
241
|
if (options.open === true) return { open: true, mode: "forced" };
|
|
233
242
|
if (options.open === false) return { open: false, reason: "disabled by --no-open" };
|
|
@@ -320,14 +329,16 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
320
329
|
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
321
330
|
return cached.token;
|
|
322
331
|
}
|
|
332
|
+
const email = handshakeEmail(options.email, cache?.platform === audience ? cache.email : void 0);
|
|
323
333
|
const { token, expiresAt } = await (0, import_db2.requestToken)({
|
|
324
334
|
endpoint: audience,
|
|
335
|
+
email,
|
|
325
336
|
label: `odla CLI admin AI (${scope})`,
|
|
326
337
|
scopes: [scope],
|
|
327
338
|
fetch: doFetch,
|
|
328
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
329
|
-
const approvalUrl = handshakeUrl(audience, userCode);
|
|
330
|
-
out.log(`
|
|
339
|
+
onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
|
|
340
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(audience, userCode);
|
|
341
|
+
out.log(`Review, then approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
331
342
|
const shouldOpen = options.open === true || options.open !== false && !import_node_process4.default.env.CI && Boolean(import_node_process4.default.stdin.isTTY && import_node_process4.default.stdout.isTTY);
|
|
332
343
|
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
333
344
|
}
|
|
@@ -335,7 +346,7 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
335
346
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
336
347
|
tokens[scope] = { token, expiresAt };
|
|
337
348
|
if ((0, import_node_fs2.existsSync)(`${import_node_process4.default.cwd()}/.git`)) ensureGitignore(import_node_process4.default.cwd(), [tokenFile]);
|
|
338
|
-
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
349
|
+
writePrivateJson(tokenFile, { platform: audience, email, tokens });
|
|
339
350
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
340
351
|
return token;
|
|
341
352
|
}
|
|
@@ -505,6 +516,7 @@ async function adminAi(options) {
|
|
|
505
516
|
platform,
|
|
506
517
|
scope,
|
|
507
518
|
token: options.token,
|
|
519
|
+
email: options.email,
|
|
508
520
|
open: options.open,
|
|
509
521
|
fetch: doFetch,
|
|
510
522
|
stdout: out,
|
|
@@ -764,6 +776,7 @@ async function adminCommand(parsed) {
|
|
|
764
776
|
"platform",
|
|
765
777
|
"json",
|
|
766
778
|
"open",
|
|
779
|
+
"email",
|
|
767
780
|
"provider",
|
|
768
781
|
"model",
|
|
769
782
|
"enabled",
|
|
@@ -791,6 +804,7 @@ async function adminCommand(parsed) {
|
|
|
791
804
|
maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
|
|
792
805
|
maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
|
|
793
806
|
platform: stringOpt(parsed.options.platform),
|
|
807
|
+
email: stringOpt(parsed.options.email),
|
|
794
808
|
json: parsed.options.json === true,
|
|
795
809
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
796
810
|
credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
|
|
@@ -810,11 +824,13 @@ async function adminCommand(parsed) {
|
|
|
810
824
|
// src/capabilities.ts
|
|
811
825
|
var CAPABILITIES = {
|
|
812
826
|
cli: [
|
|
827
|
+
"start an email-bound device request without accepting a password or user session token",
|
|
813
828
|
"register the app and enable configured services per environment",
|
|
814
829
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
815
830
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
816
831
|
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
817
832
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
833
|
+
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
818
834
|
"validate config offline and smoke-test a provisioned db environment",
|
|
819
835
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
820
836
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
@@ -823,10 +839,12 @@ var CAPABILITIES = {
|
|
|
823
839
|
agent: [
|
|
824
840
|
"install and import the selected odla SDKs",
|
|
825
841
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
826
|
-
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
842
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
843
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
827
844
|
],
|
|
828
845
|
human: [
|
|
829
|
-
"
|
|
846
|
+
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
847
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
830
848
|
"consent to production changes with --yes",
|
|
831
849
|
"request destructive credential rotation explicitly",
|
|
832
850
|
"review application semantics, security findings, releases, and merges",
|
|
@@ -835,6 +853,8 @@ var CAPABILITIES = {
|
|
|
835
853
|
],
|
|
836
854
|
studio: [
|
|
837
855
|
"view telemetry and environment state",
|
|
856
|
+
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
857
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
838
858
|
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
839
859
|
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
840
860
|
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
@@ -864,6 +884,7 @@ var import_node_url = require("url");
|
|
|
864
884
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
865
885
|
var DEFAULT_ENVS = ["dev"];
|
|
866
886
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
887
|
+
var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
867
888
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
868
889
|
const resolved = (0, import_node_path2.resolve)(configPath);
|
|
869
890
|
if (!(0, import_node_fs3.existsSync)(resolved)) {
|
|
@@ -876,6 +897,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
876
897
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
877
898
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
878
899
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
900
|
+
validateCalendarConfig(raw, envs, services, resolved);
|
|
879
901
|
const local = {
|
|
880
902
|
tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
881
903
|
credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
@@ -920,6 +942,28 @@ function buildPlan(cfg) {
|
|
|
920
942
|
aiProvider: cfg.ai?.provider
|
|
921
943
|
};
|
|
922
944
|
}
|
|
945
|
+
function calendarServiceConfig(cfg, env) {
|
|
946
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
947
|
+
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
948
|
+
const google = cfg.calendar?.google;
|
|
949
|
+
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
950
|
+
return {
|
|
951
|
+
provider: "google",
|
|
952
|
+
access: "read",
|
|
953
|
+
calendars: unique(google.calendars[env].map((id) => id.trim())),
|
|
954
|
+
match: {
|
|
955
|
+
organizerSelf: google.match?.organizerSelf ?? true,
|
|
956
|
+
requireAttendees: google.match?.requireAttendees ?? true,
|
|
957
|
+
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
958
|
+
},
|
|
959
|
+
attendeePolicy: google.attendeePolicy ?? "full"
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function calendarBookingPageUrl(cfg, env) {
|
|
963
|
+
const value = cfg.calendar?.google.bookingPageUrl?.[env];
|
|
964
|
+
if (value === void 0 || value === null) return value;
|
|
965
|
+
return new URL(value).toString();
|
|
966
|
+
}
|
|
923
967
|
function rulesFromSchema(schema) {
|
|
924
968
|
const entities = serializedEntities(schema);
|
|
925
969
|
return Object.fromEntries(
|
|
@@ -943,7 +987,85 @@ function validateRawConfig(raw, path) {
|
|
|
943
987
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
944
988
|
if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
945
989
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
990
|
+
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
991
|
+
throw new Error(`${path}: envs must be an array of names`);
|
|
992
|
+
}
|
|
946
993
|
if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
994
|
+
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
995
|
+
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function validateCalendarConfig(cfg, envs, services, path) {
|
|
999
|
+
const enabled = services.includes("calendar");
|
|
1000
|
+
if (!cfg.calendar) {
|
|
1001
|
+
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
1005
|
+
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
1006
|
+
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
1007
|
+
const google = cfg.calendar.google;
|
|
1008
|
+
assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
|
|
1009
|
+
if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
|
|
1010
|
+
const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
|
|
1011
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
|
|
1012
|
+
for (const env of envs) {
|
|
1013
|
+
const ids = google.calendars[env];
|
|
1014
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
1015
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
|
|
1016
|
+
}
|
|
1017
|
+
if (ids.length > 10) {
|
|
1018
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
|
|
1019
|
+
}
|
|
1020
|
+
if (ids.some((id) => !safeText(id, 1024))) {
|
|
1021
|
+
throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (google.bookingPageUrl !== void 0) {
|
|
1025
|
+
if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
1026
|
+
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
1027
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
1028
|
+
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
1029
|
+
if (value !== null && !safeHttpsUrl(value)) {
|
|
1030
|
+
throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
1035
|
+
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
1036
|
+
}
|
|
1037
|
+
if (google.match !== void 0) {
|
|
1038
|
+
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
1039
|
+
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
1040
|
+
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
1041
|
+
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
1042
|
+
}
|
|
1043
|
+
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
1044
|
+
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
1045
|
+
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
1050
|
+
}
|
|
1051
|
+
function assertOnly(value, allowed, label) {
|
|
1052
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
1053
|
+
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
1054
|
+
}
|
|
1055
|
+
function isRecord4(value) {
|
|
1056
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1057
|
+
}
|
|
1058
|
+
function safeText(value, max) {
|
|
1059
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1060
|
+
}
|
|
1061
|
+
function safeHttpsUrl(value) {
|
|
1062
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
1063
|
+
try {
|
|
1064
|
+
const url = new URL(value);
|
|
1065
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
947
1069
|
}
|
|
948
1070
|
function validId(value) {
|
|
949
1071
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
@@ -962,11 +1084,6 @@ function unique(values) {
|
|
|
962
1084
|
return [...new Set(values.filter(Boolean))];
|
|
963
1085
|
}
|
|
964
1086
|
|
|
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
1087
|
// src/redact.ts
|
|
971
1088
|
var REPLACEMENTS = [
|
|
972
1089
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -986,6 +1103,427 @@ function looksSecret(value) {
|
|
|
986
1103
|
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
987
1104
|
}
|
|
988
1105
|
|
|
1106
|
+
// src/calendar-http.ts
|
|
1107
|
+
var CALENDAR_STATES = [
|
|
1108
|
+
"not_connected",
|
|
1109
|
+
"authorizing",
|
|
1110
|
+
"needs_sync",
|
|
1111
|
+
"initial_sync",
|
|
1112
|
+
"healthy",
|
|
1113
|
+
"degraded",
|
|
1114
|
+
"disconnected",
|
|
1115
|
+
"failed"
|
|
1116
|
+
];
|
|
1117
|
+
async function readCalendarStatus(ctx) {
|
|
1118
|
+
return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
|
|
1119
|
+
}
|
|
1120
|
+
async function discoverGoogleCalendars(ctx) {
|
|
1121
|
+
const raw = await calendarJson(ctx, "/calendars", {});
|
|
1122
|
+
const value = record(raw);
|
|
1123
|
+
if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
|
|
1124
|
+
return value.calendars.map((item, index) => {
|
|
1125
|
+
const calendar = record(item);
|
|
1126
|
+
const id = textField(calendar?.id, 1024);
|
|
1127
|
+
if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
|
|
1128
|
+
const role = calendar.accessRole;
|
|
1129
|
+
if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
|
|
1130
|
+
throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
id,
|
|
1134
|
+
...optionalText("summary", calendar.summary, 500),
|
|
1135
|
+
...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
|
|
1136
|
+
...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
|
|
1137
|
+
...typeof role === "string" ? { accessRole: role } : {}
|
|
1138
|
+
};
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
async function applyCalendarSettings(ctx, bookingPageUrl) {
|
|
1142
|
+
return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
|
|
1143
|
+
}
|
|
1144
|
+
async function startCalendarConnection(ctx) {
|
|
1145
|
+
const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
|
|
1146
|
+
const value = wrapped(raw, "attempt");
|
|
1147
|
+
const attemptId = textField(value.attemptId, 180);
|
|
1148
|
+
const consentUrl = textField(value.consentUrl, 4096);
|
|
1149
|
+
const expiresAt = timestamp3(value.expiresAt);
|
|
1150
|
+
if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
|
|
1151
|
+
return { attemptId, consentUrl, expiresAt };
|
|
1152
|
+
}
|
|
1153
|
+
async function pollCalendarConnection(ctx, attemptId) {
|
|
1154
|
+
return parseCalendarStatus(
|
|
1155
|
+
await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
|
|
1156
|
+
ctx.env
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
async function requestCalendarResync(ctx) {
|
|
1160
|
+
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1161
|
+
}
|
|
1162
|
+
async function requestCalendarDisconnect(ctx) {
|
|
1163
|
+
return parseCalendarStatus(
|
|
1164
|
+
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
1165
|
+
ctx.env
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
function parseCalendarStatus(raw, env) {
|
|
1169
|
+
const outer = wrapped(raw, "calendar");
|
|
1170
|
+
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1171
|
+
const connection = record(value.connection) ?? {};
|
|
1172
|
+
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1173
|
+
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1174
|
+
const googleConfig = record(config.google) ?? config;
|
|
1175
|
+
const watches = record(value.watches) ?? {};
|
|
1176
|
+
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1177
|
+
if (!stateValue) {
|
|
1178
|
+
throw new Error("calendar status returned an invalid connection state");
|
|
1179
|
+
}
|
|
1180
|
+
const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
|
|
1181
|
+
if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
|
|
1182
|
+
throw new Error("calendar status returned an unsupported provider");
|
|
1183
|
+
}
|
|
1184
|
+
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1185
|
+
if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
|
|
1186
|
+
const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
|
|
1187
|
+
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1188
|
+
throw new Error("calendar status returned an invalid attendee policy");
|
|
1189
|
+
}
|
|
1190
|
+
const errorValue = record(value.error) ?? record(connection.error);
|
|
1191
|
+
const errorCode = textField(value.lastErrorCode, 128);
|
|
1192
|
+
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1193
|
+
return {
|
|
1194
|
+
env,
|
|
1195
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1196
|
+
connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
|
|
1197
|
+
provider: providerValue === "google" ? "google" : null,
|
|
1198
|
+
status: stateValue,
|
|
1199
|
+
...accessValue === "read" ? { access: "read" } : {},
|
|
1200
|
+
calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
|
|
1201
|
+
...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
|
|
1202
|
+
...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
|
|
1203
|
+
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1204
|
+
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1205
|
+
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1206
|
+
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1207
|
+
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1208
|
+
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1209
|
+
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1210
|
+
...errorValue || errorCode ? { error: {
|
|
1211
|
+
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1212
|
+
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
1213
|
+
...!errorValue && errorCode ? { code: errorCode } : {}
|
|
1214
|
+
} } : {}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
function calendarState(value) {
|
|
1218
|
+
if (typeof value !== "string") return null;
|
|
1219
|
+
if (CALENDAR_STATES.includes(value)) return value;
|
|
1220
|
+
const legacy = {
|
|
1221
|
+
disabled: "not_connected",
|
|
1222
|
+
disconnected: "disconnected",
|
|
1223
|
+
connecting: "authorizing",
|
|
1224
|
+
syncing: "initial_sync",
|
|
1225
|
+
ready: "healthy",
|
|
1226
|
+
error: "failed"
|
|
1227
|
+
};
|
|
1228
|
+
return legacy[value] ?? null;
|
|
1229
|
+
}
|
|
1230
|
+
async function calendarJson(ctx, suffix, init) {
|
|
1231
|
+
const platform = platformAudience(ctx.platform);
|
|
1232
|
+
const appId = identifier(ctx.appId, "appId");
|
|
1233
|
+
const env = identifier(ctx.env, "env");
|
|
1234
|
+
const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
|
|
1235
|
+
url.searchParams.set("env", env);
|
|
1236
|
+
const token = credential(ctx.token);
|
|
1237
|
+
let response;
|
|
1238
|
+
try {
|
|
1239
|
+
response = await (ctx.fetch ?? fetch)(url, {
|
|
1240
|
+
...init,
|
|
1241
|
+
redirect: "error",
|
|
1242
|
+
credentials: "omit",
|
|
1243
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
|
|
1244
|
+
});
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1247
|
+
}
|
|
1248
|
+
const body = await response.json().catch(() => ({}));
|
|
1249
|
+
if (!response.ok) {
|
|
1250
|
+
const code = textField(body.error?.code, 128);
|
|
1251
|
+
const message = textField(body.error?.message, 500);
|
|
1252
|
+
throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
|
|
1253
|
+
}
|
|
1254
|
+
return body;
|
|
1255
|
+
}
|
|
1256
|
+
function wrapped(raw, key) {
|
|
1257
|
+
const outer = record(raw);
|
|
1258
|
+
if (!outer) throw new Error("calendar returned an invalid response");
|
|
1259
|
+
return record(outer[key]) ?? outer;
|
|
1260
|
+
}
|
|
1261
|
+
function record(value) {
|
|
1262
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1263
|
+
}
|
|
1264
|
+
function textField(value, max) {
|
|
1265
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
|
|
1266
|
+
}
|
|
1267
|
+
function stringList(value) {
|
|
1268
|
+
return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
|
|
1269
|
+
}
|
|
1270
|
+
function calendarIds(value) {
|
|
1271
|
+
if (!Array.isArray(value)) return [];
|
|
1272
|
+
return [...new Set(value.flatMap((item) => {
|
|
1273
|
+
if (typeof item === "string") return textField(item, 4096) ? [item] : [];
|
|
1274
|
+
const calendar = record(item);
|
|
1275
|
+
const id = textField(calendar?.id, 4096);
|
|
1276
|
+
return id && calendar?.selected !== false ? [id] : [];
|
|
1277
|
+
}))];
|
|
1278
|
+
}
|
|
1279
|
+
function timestamp3(value) {
|
|
1280
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
1281
|
+
if (typeof value === "string") {
|
|
1282
|
+
const parsed = Date.parse(value);
|
|
1283
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
1284
|
+
}
|
|
1285
|
+
return void 0;
|
|
1286
|
+
}
|
|
1287
|
+
function optionalText(key, value, max) {
|
|
1288
|
+
const parsed = textField(value, max);
|
|
1289
|
+
return parsed ? { [key]: parsed } : {};
|
|
1290
|
+
}
|
|
1291
|
+
function optionalNumber(key, value) {
|
|
1292
|
+
const parsed = timestamp3(value);
|
|
1293
|
+
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1294
|
+
}
|
|
1295
|
+
function optionalInteger(key, value) {
|
|
1296
|
+
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1297
|
+
}
|
|
1298
|
+
function optionalNullableUrl(key, value) {
|
|
1299
|
+
if (value === null) return { [key]: null };
|
|
1300
|
+
const parsed = textField(value, 4096);
|
|
1301
|
+
return parsed ? { [key]: parsed } : {};
|
|
1302
|
+
}
|
|
1303
|
+
function identifier(value, name) {
|
|
1304
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
1305
|
+
return value;
|
|
1306
|
+
}
|
|
1307
|
+
function credential(value) {
|
|
1308
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1309
|
+
throw new Error("calendar requires an odla developer token");
|
|
1310
|
+
}
|
|
1311
|
+
return value;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/calendar-poll.ts
|
|
1315
|
+
async function waitForCalendarPoll(milliseconds, signal) {
|
|
1316
|
+
if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
|
|
1317
|
+
await new Promise((resolve7, reject) => {
|
|
1318
|
+
const timer = setTimeout(resolve7, milliseconds);
|
|
1319
|
+
signal?.addEventListener("abort", () => {
|
|
1320
|
+
clearTimeout(timer);
|
|
1321
|
+
reject(signal.reason ?? new Error("calendar connection aborted"));
|
|
1322
|
+
}, { once: true });
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// src/calendar.ts
|
|
1327
|
+
async function calendarStatus(options) {
|
|
1328
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1329
|
+
const status = await readCalendarStatus(ctx);
|
|
1330
|
+
printStatus(status, options.json === true, out);
|
|
1331
|
+
return status;
|
|
1332
|
+
}
|
|
1333
|
+
async function calendarCalendars(options) {
|
|
1334
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1335
|
+
const calendars = await discoverGoogleCalendars(ctx);
|
|
1336
|
+
if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
|
|
1337
|
+
else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
|
|
1338
|
+
else for (const calendar of calendars) {
|
|
1339
|
+
out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
|
|
1340
|
+
}
|
|
1341
|
+
return calendars;
|
|
1342
|
+
}
|
|
1343
|
+
async function calendarConnect(options) {
|
|
1344
|
+
const { cfg, ctx, out } = await lifecycleContext(options);
|
|
1345
|
+
productionConsent(ctx.env, options.yes, "connect calendar");
|
|
1346
|
+
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1347
|
+
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1348
|
+
const connectOptions = connectionOptions(options, out);
|
|
1349
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
|
|
1350
|
+
}
|
|
1351
|
+
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1352
|
+
if (bookingPageUrl === void 0) return null;
|
|
1353
|
+
const status = await applyCalendarSettings(ctx, bookingPageUrl);
|
|
1354
|
+
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1355
|
+
return status;
|
|
1356
|
+
}
|
|
1357
|
+
async function calendarResync(options) {
|
|
1358
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1359
|
+
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1360
|
+
const status = await requestCalendarResync(ctx);
|
|
1361
|
+
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1362
|
+
return status;
|
|
1363
|
+
}
|
|
1364
|
+
async function calendarDisconnect(options) {
|
|
1365
|
+
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1366
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1367
|
+
const status = await requestCalendarDisconnect(ctx);
|
|
1368
|
+
out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
|
|
1369
|
+
return status;
|
|
1370
|
+
}
|
|
1371
|
+
async function ensureCalendarConnected(ctx, options) {
|
|
1372
|
+
const out = options.stdout ?? console;
|
|
1373
|
+
const current = await readCalendarStatus(ctx);
|
|
1374
|
+
const connectOptions = connectionOptions(options, out);
|
|
1375
|
+
return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
|
|
1376
|
+
}
|
|
1377
|
+
async function lifecycleContext(options) {
|
|
1378
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1379
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
1380
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1381
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1382
|
+
calendarServiceConfig(cfg, env);
|
|
1383
|
+
const out = options.stdout ?? console;
|
|
1384
|
+
const doFetch = options.fetch ?? fetch;
|
|
1385
|
+
const token = await getDeveloperToken(
|
|
1386
|
+
cfg,
|
|
1387
|
+
{
|
|
1388
|
+
configPath: cfg.configPath,
|
|
1389
|
+
token: options.token,
|
|
1390
|
+
email: options.email,
|
|
1391
|
+
open: options.open,
|
|
1392
|
+
interactive: options.interactive,
|
|
1393
|
+
openApprovalUrl: options.openConsentUrl
|
|
1394
|
+
},
|
|
1395
|
+
doFetch,
|
|
1396
|
+
out
|
|
1397
|
+
);
|
|
1398
|
+
return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
|
|
1399
|
+
}
|
|
1400
|
+
function connectionOptions(options, out) {
|
|
1401
|
+
const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
|
|
1402
|
+
return {
|
|
1403
|
+
out,
|
|
1404
|
+
open: browser.open,
|
|
1405
|
+
openConsentUrl: options.openConsentUrl,
|
|
1406
|
+
wait: options.wait,
|
|
1407
|
+
pollTimeoutMs: options.pollTimeoutMs,
|
|
1408
|
+
now: options.now,
|
|
1409
|
+
signal: options.signal
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
1413
|
+
if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
|
|
1414
|
+
options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
|
|
1415
|
+
return current;
|
|
1416
|
+
}
|
|
1417
|
+
if (current.status === "degraded") return null;
|
|
1418
|
+
if (current.status === "needs_sync") {
|
|
1419
|
+
if (!current.connected) return null;
|
|
1420
|
+
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1421
|
+
const synced = await requestCalendarResync(ctx);
|
|
1422
|
+
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1423
|
+
return synced;
|
|
1424
|
+
}
|
|
1425
|
+
if (current.status === "failed" && current.connected) {
|
|
1426
|
+
const reason = current.error?.message ?? current.error?.code;
|
|
1427
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1428
|
+
}
|
|
1429
|
+
const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
|
|
1430
|
+
if (!waitingForExisting) return null;
|
|
1431
|
+
const now = options.now ?? Date.now;
|
|
1432
|
+
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1433
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1434
|
+
let prior = "";
|
|
1435
|
+
while (now() < deadline) {
|
|
1436
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1437
|
+
const status = await readCalendarStatus(ctx);
|
|
1438
|
+
if (status.status !== prior) {
|
|
1439
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1440
|
+
prior = status.status;
|
|
1441
|
+
}
|
|
1442
|
+
if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
|
|
1443
|
+
if (status.status === "degraded") return null;
|
|
1444
|
+
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1445
|
+
if (status.status === "failed" && status.connected) {
|
|
1446
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1447
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1448
|
+
}
|
|
1449
|
+
if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
|
|
1450
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1451
|
+
}
|
|
1452
|
+
throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
|
|
1453
|
+
}
|
|
1454
|
+
async function connectWithContext(ctx, options) {
|
|
1455
|
+
const attempt = await startCalendarConnection(ctx);
|
|
1456
|
+
const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
|
|
1457
|
+
options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
|
|
1458
|
+
if (options.open) {
|
|
1459
|
+
await (options.openConsentUrl ?? openUrl)(consentUrl);
|
|
1460
|
+
options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
|
|
1461
|
+
}
|
|
1462
|
+
const now = options.now ?? Date.now;
|
|
1463
|
+
const timeout = pollTimeout(options.pollTimeoutMs);
|
|
1464
|
+
const deadline = Math.min(attempt.expiresAt, now() + timeout);
|
|
1465
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1466
|
+
let prior = "";
|
|
1467
|
+
while (now() < deadline) {
|
|
1468
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1469
|
+
const status = await pollCalendarConnection(ctx, attempt.attemptId);
|
|
1470
|
+
if (status.status !== prior) {
|
|
1471
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1472
|
+
prior = status.status;
|
|
1473
|
+
}
|
|
1474
|
+
if (status.status === "healthy" || status.status === "degraded") return status;
|
|
1475
|
+
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1476
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1477
|
+
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1478
|
+
}
|
|
1479
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1480
|
+
}
|
|
1481
|
+
throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
|
|
1482
|
+
}
|
|
1483
|
+
function trustedCalendarConsentUrl(platform, value) {
|
|
1484
|
+
const origin = platformAudience(platform);
|
|
1485
|
+
let url;
|
|
1486
|
+
try {
|
|
1487
|
+
url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
|
|
1488
|
+
} catch {
|
|
1489
|
+
throw new Error("odla.ai returned an invalid calendar consent URL");
|
|
1490
|
+
}
|
|
1491
|
+
const platformPage = url.origin === origin;
|
|
1492
|
+
const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
1493
|
+
if (!platformPage && !googlePage || url.username || url.password || url.hash) {
|
|
1494
|
+
throw new Error("odla.ai returned an untrusted calendar consent URL");
|
|
1495
|
+
}
|
|
1496
|
+
return url.toString();
|
|
1497
|
+
}
|
|
1498
|
+
function pollTimeout(value = 10 * 6e4) {
|
|
1499
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
|
|
1500
|
+
return value;
|
|
1501
|
+
}
|
|
1502
|
+
function productionConsent(env, yes, action) {
|
|
1503
|
+
if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
|
|
1504
|
+
}
|
|
1505
|
+
function printStatus(status, json, out) {
|
|
1506
|
+
if (json) {
|
|
1507
|
+
out.log(JSON.stringify(status, null, 2));
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1511
|
+
out.log(` access: ${status.access ?? "not granted"}`);
|
|
1512
|
+
out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1513
|
+
if (status.attendeePolicy) {
|
|
1514
|
+
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1515
|
+
}
|
|
1516
|
+
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1517
|
+
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1518
|
+
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1519
|
+
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/doctor-checks.ts
|
|
1523
|
+
var import_node_child_process3 = require("child_process");
|
|
1524
|
+
var import_node_fs5 = require("fs");
|
|
1525
|
+
var import_node_path4 = require("path");
|
|
1526
|
+
|
|
989
1527
|
// src/wrangler.ts
|
|
990
1528
|
var import_node_child_process2 = require("child_process");
|
|
991
1529
|
var import_node_fs4 = require("fs");
|
|
@@ -1166,6 +1704,15 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1166
1704
|
}
|
|
1167
1705
|
return warnings;
|
|
1168
1706
|
}
|
|
1707
|
+
function calendarProjectWarnings(rootDir) {
|
|
1708
|
+
const pkg = readPackageJson(rootDir);
|
|
1709
|
+
const dependencies = pkg?.dependencies;
|
|
1710
|
+
const devDependencies = pkg?.devDependencies;
|
|
1711
|
+
if (dependencies?.["@odla-ai/calendar"]) return [];
|
|
1712
|
+
return [
|
|
1713
|
+
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"
|
|
1714
|
+
];
|
|
1715
|
+
}
|
|
1169
1716
|
function readPackageJson(rootDir) {
|
|
1170
1717
|
try {
|
|
1171
1718
|
return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
|
|
@@ -1191,6 +1738,14 @@ async function doctor(options) {
|
|
|
1191
1738
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1192
1739
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1193
1740
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1741
|
+
if (cfg.services.includes("calendar")) {
|
|
1742
|
+
const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
|
|
1743
|
+
out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
|
|
1744
|
+
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1745
|
+
out.log(`booking pages: ${pages}`);
|
|
1746
|
+
} else {
|
|
1747
|
+
out.log("calendar: not enabled");
|
|
1748
|
+
}
|
|
1194
1749
|
const warnings = [];
|
|
1195
1750
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
1196
1751
|
if (rules) {
|
|
@@ -1218,6 +1773,8 @@ async function doctor(options) {
|
|
|
1218
1773
|
}
|
|
1219
1774
|
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
1220
1775
|
}
|
|
1776
|
+
if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
|
|
1777
|
+
else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
|
|
1221
1778
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
1222
1779
|
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
1223
1780
|
cfg.local.tokenFile,
|
|
@@ -1245,10 +1802,15 @@ function printHelp() {
|
|
|
1245
1802
|
|
|
1246
1803
|
Usage:
|
|
1247
1804
|
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]
|
|
1805
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
|
|
1249
1806
|
odla-ai doctor [--config odla.config.mjs]
|
|
1807
|
+
odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
|
|
1808
|
+
odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
|
|
1809
|
+
odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
|
|
1810
|
+
odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
|
|
1811
|
+
odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
|
|
1250
1812
|
odla-ai capabilities [--json]
|
|
1251
|
-
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
1813
|
+
odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
|
|
1252
1814
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
1253
1815
|
odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
|
|
1254
1816
|
odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
|
|
@@ -1257,7 +1819,7 @@ Usage:
|
|
|
1257
1819
|
odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
|
|
1258
1820
|
odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
|
|
1259
1821
|
odla-ai admin ai audit [--limit <1-200>] [--json]
|
|
1260
|
-
odla-ai security github connect [--repo owner/name] [--env dev] [--no-open]
|
|
1822
|
+
odla-ai security github connect [--repo owner/name] [--env dev] [--email <odla-account>] [--no-open]
|
|
1261
1823
|
odla-ai security github disconnect --source <id> [--env dev] [--yes]
|
|
1262
1824
|
odla-ai security plan [--env dev] [--json]
|
|
1263
1825
|
odla-ai security sources [--env dev] [--json]
|
|
@@ -1266,18 +1828,19 @@ Usage:
|
|
|
1266
1828
|
odla-ai security report <job-id> [--json]
|
|
1267
1829
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1268
1830
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1269
|
-
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]
|
|
1831
|
+
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1832
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
|
|
1271
1833
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1272
1834
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1273
|
-
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1274
|
-
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1835
|
+
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
|
|
1836
|
+
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
|
|
1275
1837
|
odla-ai version
|
|
1276
1838
|
|
|
1277
1839
|
Commands:
|
|
1278
1840
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1279
1841
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1280
1842
|
doctor Validate and summarize the project config without network calls.
|
|
1843
|
+
calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
|
|
1281
1844
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1282
1845
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1283
1846
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -1298,6 +1861,12 @@ Safety:
|
|
|
1298
1861
|
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1299
1862
|
Provision opens the approval page automatically in interactive terminals;
|
|
1300
1863
|
use --open to force browser launch or --no-open to suppress it.
|
|
1864
|
+
A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
|
|
1865
|
+
The email is a non-secret identity hint: never provide a password or session
|
|
1866
|
+
token. The matching account must already exist, be signed in, explicitly
|
|
1867
|
+
review the exact code, and finish any current request before claiming another.
|
|
1868
|
+
Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
|
|
1869
|
+
OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
|
|
1301
1870
|
GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
|
|
1302
1871
|
for a PAT or provider key. GitHub read access is separate from the explicit
|
|
1303
1872
|
--ack-redacted-source consent and the exact --plan-digest printed by security
|
|
@@ -1342,6 +1911,7 @@ function initProject(options) {
|
|
|
1342
1911
|
}
|
|
1343
1912
|
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
1344
1913
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1914
|
+
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1345
1915
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1346
1916
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
|
|
1347
1917
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
|
|
@@ -1359,6 +1929,16 @@ function writeIfMissing(path, text) {
|
|
|
1359
1929
|
(0, import_node_fs7.writeFileSync)(path, text);
|
|
1360
1930
|
}
|
|
1361
1931
|
function configTemplate(input) {
|
|
1932
|
+
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1933
|
+
google: {
|
|
1934
|
+
calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1935
|
+
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1936
|
+
match: { organizerSelf: true, requireAttendees: true },
|
|
1937
|
+
attendeePolicy: "full",
|
|
1938
|
+
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1939
|
+
},
|
|
1940
|
+
},
|
|
1941
|
+
` : "";
|
|
1362
1942
|
return `export default {
|
|
1363
1943
|
platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
|
|
1364
1944
|
dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
|
|
@@ -1380,6 +1960,7 @@ function configTemplate(input) {
|
|
|
1380
1960
|
// key in the platform vault for each tenant.
|
|
1381
1961
|
keyEnv: "${defaultKeyEnv(input.aiProvider)}",
|
|
1382
1962
|
},
|
|
1963
|
+
${calendar}
|
|
1383
1964
|
auth: {
|
|
1384
1965
|
clerk: {
|
|
1385
1966
|
// dev: "$CLERK_PUBLISHABLE_KEY",
|
|
@@ -1506,14 +2087,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1506
2087
|
appId: tenantId
|
|
1507
2088
|
})
|
|
1508
2089
|
});
|
|
1509
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2090
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
|
|
1510
2091
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1511
2092
|
method: "POST",
|
|
1512
2093
|
headers,
|
|
1513
2094
|
body: "{}"
|
|
1514
2095
|
});
|
|
1515
2096
|
}
|
|
1516
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2097
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1517
2098
|
const body = await res.json();
|
|
1518
2099
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1519
2100
|
return body.key;
|
|
@@ -1529,12 +2110,12 @@ async function issueO11yToken(opts) {
|
|
|
1529
2110
|
`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
2111
|
);
|
|
1531
2112
|
}
|
|
1532
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2113
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1533
2114
|
const body = await res.json();
|
|
1534
2115
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1535
2116
|
return body.token;
|
|
1536
2117
|
}
|
|
1537
|
-
async function
|
|
2118
|
+
async function safeText2(res) {
|
|
1538
2119
|
try {
|
|
1539
2120
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1540
2121
|
} catch {
|
|
@@ -1647,6 +2228,14 @@ async function provision(options) {
|
|
|
1647
2228
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1648
2229
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
1649
2230
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
2231
|
+
if (cfg.services.includes("calendar")) {
|
|
2232
|
+
for (const env of cfg.envs) {
|
|
2233
|
+
const calendar = calendarServiceConfig(cfg, env);
|
|
2234
|
+
out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
|
|
2235
|
+
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
2236
|
+
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
1650
2239
|
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
1651
2240
|
return;
|
|
1652
2241
|
}
|
|
@@ -1681,8 +2270,9 @@ async function provision(options) {
|
|
|
1681
2270
|
await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
|
|
1682
2271
|
out.log(`app: created ${cfg.app.id}`);
|
|
1683
2272
|
}
|
|
2273
|
+
const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
|
|
1684
2274
|
for (const env of cfg.envs) {
|
|
1685
|
-
for (const service of
|
|
2275
|
+
for (const service of serviceOrder) {
|
|
1686
2276
|
if (service === "ai") {
|
|
1687
2277
|
if (cfg.ai?.provider) {
|
|
1688
2278
|
await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
|
|
@@ -1692,7 +2282,8 @@ async function provision(options) {
|
|
|
1692
2282
|
out.log(`${env}: ai enabled`);
|
|
1693
2283
|
}
|
|
1694
2284
|
} else {
|
|
1695
|
-
|
|
2285
|
+
const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
|
|
2286
|
+
await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
|
|
1696
2287
|
out.log(`${env}: ${service} enabled`);
|
|
1697
2288
|
}
|
|
1698
2289
|
}
|
|
@@ -1706,6 +2297,21 @@ async function provision(options) {
|
|
|
1706
2297
|
await apps.setLink(cfg.app.id, env, link ?? null);
|
|
1707
2298
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
1708
2299
|
}
|
|
2300
|
+
if (cfg.services.includes("calendar")) {
|
|
2301
|
+
const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
|
|
2302
|
+
await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
|
|
2303
|
+
await ensureCalendarConnected(
|
|
2304
|
+
calendarCtx,
|
|
2305
|
+
{
|
|
2306
|
+
open: options.open,
|
|
2307
|
+
interactive: options.interactive,
|
|
2308
|
+
openConsentUrl: options.openApprovalUrl,
|
|
2309
|
+
wait: options.calendarPollWait,
|
|
2310
|
+
pollTimeoutMs: options.calendarPollTimeoutMs,
|
|
2311
|
+
stdout: out
|
|
2312
|
+
}
|
|
2313
|
+
);
|
|
2314
|
+
}
|
|
1709
2315
|
}
|
|
1710
2316
|
for (const env of cfg.envs) {
|
|
1711
2317
|
const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
|
|
@@ -1769,6 +2375,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1769
2375
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
1770
2376
|
}
|
|
1771
2377
|
}
|
|
2378
|
+
function serviceRank(service) {
|
|
2379
|
+
if (service === "db") return 0;
|
|
2380
|
+
if (service === "calendar") return 2;
|
|
2381
|
+
return 1;
|
|
2382
|
+
}
|
|
1772
2383
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
1773
2384
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
1774
2385
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -1784,7 +2395,7 @@ async function postJson(doFetch, url, bearer, body) {
|
|
|
1784
2395
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1785
2396
|
body: JSON.stringify(body)
|
|
1786
2397
|
});
|
|
1787
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2398
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
|
|
1788
2399
|
}
|
|
1789
2400
|
function normalizeClerkConfig(value) {
|
|
1790
2401
|
if (!value) return null;
|
|
@@ -1801,7 +2412,7 @@ function defaultSecretName(provider) {
|
|
|
1801
2412
|
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
1802
2413
|
return names[provider] ?? `${provider}_api_key`;
|
|
1803
2414
|
}
|
|
1804
|
-
async function
|
|
2415
|
+
async function safeText3(res) {
|
|
1805
2416
|
try {
|
|
1806
2417
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1807
2418
|
} catch {
|
|
@@ -1884,7 +2495,7 @@ async function hostedSecurityContext(parsed, dependencies) {
|
|
|
1884
2495
|
const open = parsed.options.open === false ? false : parsed.options.open === true ? true : void 0;
|
|
1885
2496
|
const token = await getDeveloperToken(
|
|
1886
2497
|
cfg,
|
|
1887
|
-
{ configPath, open, openApprovalUrl: dependencies.openUrl },
|
|
2498
|
+
{ configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
|
|
1888
2499
|
doFetch,
|
|
1889
2500
|
stdout
|
|
1890
2501
|
);
|
|
@@ -2502,6 +3113,7 @@ async function runSourceSecurityCommand(parsed, dependencies, sourceId) {
|
|
|
2502
3113
|
"source",
|
|
2503
3114
|
"ref",
|
|
2504
3115
|
"follow",
|
|
3116
|
+
"email",
|
|
2505
3117
|
"open",
|
|
2506
3118
|
"json",
|
|
2507
3119
|
"ack-redacted-source",
|
|
@@ -2590,6 +3202,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2590
3202
|
"platform",
|
|
2591
3203
|
"self",
|
|
2592
3204
|
"ack-redacted-source",
|
|
3205
|
+
"email",
|
|
2593
3206
|
"open",
|
|
2594
3207
|
"fail-on",
|
|
2595
3208
|
"fail-on-candidates",
|
|
@@ -2619,6 +3232,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2619
3232
|
return getScopedPlatformToken({
|
|
2620
3233
|
platform: request.platform,
|
|
2621
3234
|
scope: request.scope,
|
|
3235
|
+
email: stringOpt(parsed.options.email),
|
|
2622
3236
|
open,
|
|
2623
3237
|
fetch: doFetch,
|
|
2624
3238
|
stdout: out,
|
|
@@ -2631,7 +3245,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2631
3245
|
}
|
|
2632
3246
|
return getDeveloperToken(
|
|
2633
3247
|
cfg,
|
|
2634
|
-
{ configPath, open, openApprovalUrl: dependencies.openUrl },
|
|
3248
|
+
{ configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
|
|
2635
3249
|
doFetch,
|
|
2636
3250
|
out
|
|
2637
3251
|
);
|
|
@@ -2665,7 +3279,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
2665
3279
|
return;
|
|
2666
3280
|
}
|
|
2667
3281
|
if (sub === "plan") {
|
|
2668
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
|
|
3282
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
|
|
2669
3283
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2670
3284
|
const plan = await getHostedSecurityPlan(context);
|
|
2671
3285
|
if (parsed.options.json === true) context.stdout.log(JSON.stringify(plan, null, 2));
|
|
@@ -2681,7 +3295,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
2681
3295
|
return;
|
|
2682
3296
|
}
|
|
2683
3297
|
if (sub === "report") {
|
|
2684
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 3);
|
|
3298
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 3);
|
|
2685
3299
|
const jobId = requiredSecurityPositional(parsed, 2, "job id");
|
|
2686
3300
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2687
3301
|
const report = await getHostedSecurityReport({ ...context, jobId });
|
|
@@ -2699,7 +3313,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
2699
3313
|
async function githubSecurityCommand(parsed, dependencies) {
|
|
2700
3314
|
const action = parsed.positionals[2];
|
|
2701
3315
|
if (action === "disconnect") {
|
|
2702
|
-
assertArgs(parsed, ["config", "env", "platform", "source", "open", "yes"], 3);
|
|
3316
|
+
assertArgs(parsed, ["config", "env", "platform", "source", "email", "open", "yes"], 3);
|
|
2703
3317
|
const context2 = await hostedSecurityContext(parsed, dependencies);
|
|
2704
3318
|
const sourceId = requiredString(parsed.options.source, "--source");
|
|
2705
3319
|
const confirmed = parsed.options.yes === true || await interactiveConfirmation(
|
|
@@ -2716,7 +3330,7 @@ async function githubSecurityCommand(parsed, dependencies) {
|
|
|
2716
3330
|
if (action !== "connect") {
|
|
2717
3331
|
throw new Error('unknown security github command. Try "odla-ai security github connect".');
|
|
2718
3332
|
}
|
|
2719
|
-
assertArgs(parsed, ["config", "env", "platform", "repo", "open"], 3);
|
|
3333
|
+
assertArgs(parsed, ["config", "env", "platform", "repo", "email", "open"], 3);
|
|
2720
3334
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2721
3335
|
const repository = stringOpt(parsed.options.repo) ?? await inferGitHubRepository(process.cwd(), dependencies.readGitOrigin);
|
|
2722
3336
|
const connection = await connectGitHubSecuritySource({
|
|
@@ -2731,7 +3345,7 @@ async function githubSecurityCommand(parsed, dependencies) {
|
|
|
2731
3345
|
context.stdout.log("github: odla.ai stores the installation; no PAT or GitHub token is written locally");
|
|
2732
3346
|
}
|
|
2733
3347
|
async function listSecuritySources(parsed, dependencies) {
|
|
2734
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
|
|
3348
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
|
|
2735
3349
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2736
3350
|
const [plan, sources] = await Promise.all([
|
|
2737
3351
|
getHostedSecurityPlan(context),
|
|
@@ -2753,7 +3367,7 @@ source repository default ref status`);
|
|
|
2753
3367
|
}
|
|
2754
3368
|
}
|
|
2755
3369
|
async function securityStatus(parsed, dependencies) {
|
|
2756
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json", "follow"], 3);
|
|
3370
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json", "follow"], 3);
|
|
2757
3371
|
const jobId = requiredSecurityPositional(parsed, 2, "job id");
|
|
2758
3372
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2759
3373
|
const job = parsed.options.follow === true ? await followHostedSecurityJob({
|
|
@@ -3050,6 +3664,24 @@ async function smoke(options) {
|
|
|
3050
3664
|
}
|
|
3051
3665
|
out.log(` ai: ${provider}`);
|
|
3052
3666
|
}
|
|
3667
|
+
if (cfg.services.includes("calendar")) {
|
|
3668
|
+
const token = await getDeveloperToken(
|
|
3669
|
+
cfg,
|
|
3670
|
+
{
|
|
3671
|
+
configPath: cfg.configPath,
|
|
3672
|
+
token: options.token,
|
|
3673
|
+
email: options.email,
|
|
3674
|
+
open: options.open,
|
|
3675
|
+
interactive: options.interactive,
|
|
3676
|
+
openApprovalUrl: options.openApprovalUrl
|
|
3677
|
+
},
|
|
3678
|
+
doFetch,
|
|
3679
|
+
out
|
|
3680
|
+
);
|
|
3681
|
+
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3682
|
+
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3683
|
+
out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
3684
|
+
}
|
|
3053
3685
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
3054
3686
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
3055
3687
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
@@ -3071,13 +3703,35 @@ async function smoke(options) {
|
|
|
3071
3703
|
} else {
|
|
3072
3704
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3073
3705
|
}
|
|
3706
|
+
if (cfg.services.includes("calendar")) {
|
|
3707
|
+
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3708
|
+
ns: "$bookings",
|
|
3709
|
+
aggregate: { count: true }
|
|
3710
|
+
});
|
|
3711
|
+
const count = bookings.aggregate?.count;
|
|
3712
|
+
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3713
|
+
}
|
|
3074
3714
|
out.log("ok");
|
|
3075
3715
|
}
|
|
3716
|
+
function assertCalendarHealthy(status, expected) {
|
|
3717
|
+
if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
|
|
3718
|
+
if (status.status !== "healthy") {
|
|
3719
|
+
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3720
|
+
}
|
|
3721
|
+
if (status.access !== "read") throw new Error("calendar connection is not read-only");
|
|
3722
|
+
const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
|
|
3723
|
+
if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
|
|
3724
|
+
const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
|
|
3725
|
+
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3726
|
+
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3727
|
+
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3728
|
+
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3729
|
+
}
|
|
3076
3730
|
async function getJson(doFetch, url, bearer) {
|
|
3077
3731
|
const res = await doFetch(url, {
|
|
3078
3732
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
3079
3733
|
});
|
|
3080
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3734
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3081
3735
|
return res.json();
|
|
3082
3736
|
}
|
|
3083
3737
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -3086,7 +3740,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
3086
3740
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
3087
3741
|
body: JSON.stringify(body)
|
|
3088
3742
|
});
|
|
3089
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3743
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3090
3744
|
return res.json();
|
|
3091
3745
|
}
|
|
3092
3746
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -3094,7 +3748,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
3094
3748
|
url.searchParams.set("env", env);
|
|
3095
3749
|
return url.toString();
|
|
3096
3750
|
}
|
|
3097
|
-
async function
|
|
3751
|
+
async function safeText4(res) {
|
|
3098
3752
|
try {
|
|
3099
3753
|
return (await res.text()).slice(0, 500);
|
|
3100
3754
|
} catch {
|
|
@@ -3142,6 +3796,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3142
3796
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
3143
3797
|
return;
|
|
3144
3798
|
}
|
|
3799
|
+
if (command === "calendar") {
|
|
3800
|
+
await calendarCommand(parsed, dependencies);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3145
3803
|
if (command === "capabilities") {
|
|
3146
3804
|
assertArgs(parsed, ["json"], 1);
|
|
3147
3805
|
printCapabilities(parsed.options.json === true);
|
|
@@ -3156,14 +3814,20 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3156
3814
|
return;
|
|
3157
3815
|
}
|
|
3158
3816
|
if (command === "provision") {
|
|
3159
|
-
await provisionCommand(parsed);
|
|
3817
|
+
await provisionCommand(parsed, dependencies);
|
|
3160
3818
|
return;
|
|
3161
3819
|
}
|
|
3162
3820
|
if (command === "smoke") {
|
|
3163
|
-
assertArgs(parsed, ["config", "env"], 1);
|
|
3821
|
+
assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
|
|
3164
3822
|
await smoke({
|
|
3165
3823
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3166
|
-
env: stringOpt(parsed.options.env)
|
|
3824
|
+
env: stringOpt(parsed.options.env),
|
|
3825
|
+
token: stringOpt(parsed.options.token),
|
|
3826
|
+
email: stringOpt(parsed.options.email),
|
|
3827
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3828
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3829
|
+
fetch: dependencies.fetch,
|
|
3830
|
+
stdout: dependencies.stdout
|
|
3167
3831
|
});
|
|
3168
3832
|
return;
|
|
3169
3833
|
}
|
|
@@ -3197,7 +3861,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3197
3861
|
if (command === "secrets") {
|
|
3198
3862
|
const sub = parsed.positionals[1];
|
|
3199
3863
|
if (sub === "set" || sub === "set-clerk-key") {
|
|
3200
|
-
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
|
|
3864
|
+
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "email", "yes"], sub === "set" ? 3 : 2);
|
|
3201
3865
|
const options = {
|
|
3202
3866
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3203
3867
|
name: parsed.positionals[2],
|
|
@@ -3205,6 +3869,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3205
3869
|
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
3206
3870
|
stdin: parsed.options.stdin === true,
|
|
3207
3871
|
token: stringOpt(parsed.options.token),
|
|
3872
|
+
email: stringOpt(parsed.options.email),
|
|
3208
3873
|
yes: parsed.options.yes === true,
|
|
3209
3874
|
fetch: dependencies.fetch,
|
|
3210
3875
|
stdout: dependencies.stdout
|
|
@@ -3228,7 +3893,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3228
3893
|
}
|
|
3229
3894
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
3230
3895
|
}
|
|
3231
|
-
async function provisionCommand(parsed) {
|
|
3896
|
+
async function provisionCommand(parsed, dependencies) {
|
|
3232
3897
|
assertArgs(parsed, [
|
|
3233
3898
|
"config",
|
|
3234
3899
|
"dry-run",
|
|
@@ -3238,6 +3903,7 @@ async function provisionCommand(parsed) {
|
|
|
3238
3903
|
"write-credentials",
|
|
3239
3904
|
"write-dev-vars",
|
|
3240
3905
|
"token",
|
|
3906
|
+
"email",
|
|
3241
3907
|
"open",
|
|
3242
3908
|
"yes"
|
|
3243
3909
|
], 1);
|
|
@@ -3251,11 +3917,41 @@ async function provisionCommand(parsed) {
|
|
|
3251
3917
|
writeCredentials: parsed.options["write-credentials"] !== false,
|
|
3252
3918
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
3253
3919
|
token: stringOpt(parsed.options.token),
|
|
3920
|
+
email: stringOpt(parsed.options.email),
|
|
3254
3921
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3255
|
-
yes: parsed.options.yes === true
|
|
3922
|
+
yes: parsed.options.yes === true,
|
|
3923
|
+
fetch: dependencies.fetch,
|
|
3924
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3925
|
+
stdout: dependencies.stdout
|
|
3256
3926
|
};
|
|
3257
3927
|
await provision(options);
|
|
3258
3928
|
}
|
|
3929
|
+
async function calendarCommand(parsed, dependencies) {
|
|
3930
|
+
const sub = parsed.positionals[1];
|
|
3931
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
|
|
3932
|
+
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3933
|
+
}
|
|
3934
|
+
assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
|
|
3935
|
+
if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
|
|
3936
|
+
if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
|
|
3937
|
+
const options = {
|
|
3938
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3939
|
+
env: stringOpt(parsed.options.env),
|
|
3940
|
+
token: stringOpt(parsed.options.token),
|
|
3941
|
+
email: stringOpt(parsed.options.email),
|
|
3942
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3943
|
+
yes: parsed.options.yes === true,
|
|
3944
|
+
json: parsed.options.json === true,
|
|
3945
|
+
fetch: dependencies.fetch,
|
|
3946
|
+
stdout: dependencies.stdout,
|
|
3947
|
+
openConsentUrl: dependencies.openUrl
|
|
3948
|
+
};
|
|
3949
|
+
if (sub === "status") await calendarStatus(options);
|
|
3950
|
+
else if (sub === "calendars") await calendarCalendars(options);
|
|
3951
|
+
else if (sub === "connect") await calendarConnect(options);
|
|
3952
|
+
else if (sub === "resync") await calendarResync(options);
|
|
3953
|
+
else await calendarDisconnect(options);
|
|
3954
|
+
}
|
|
3259
3955
|
|
|
3260
3956
|
// src/bin.ts
|
|
3261
3957
|
runCli().catch((err) => {
|