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