@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
|
@@ -176,14 +176,16 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
176
176
|
return cached.token;
|
|
177
177
|
}
|
|
178
178
|
const browser = approvalBrowser(options);
|
|
179
|
+
const email = handshakeEmail(options.email, cached?.platform === audience ? cached.email : void 0);
|
|
179
180
|
const { token, expiresAt } = await requestToken({
|
|
180
181
|
endpoint: cfg.platformUrl,
|
|
182
|
+
email,
|
|
181
183
|
label: `${cfg.app.id} provisioner`,
|
|
182
184
|
fetch: doFetch,
|
|
183
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
184
|
-
const approvalUrl = handshakeUrl(cfg.platformUrl, userCode);
|
|
185
|
+
onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
|
|
186
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(cfg.platformUrl, userCode);
|
|
185
187
|
out.log("");
|
|
186
|
-
out.log(`
|
|
188
|
+
out.log(`Review, then approve code ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
187
189
|
if (browser.open) {
|
|
188
190
|
try {
|
|
189
191
|
await (options.openApprovalUrl ?? openUrl)(approvalUrl);
|
|
@@ -197,10 +199,17 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
|
|
|
197
199
|
out.log("");
|
|
198
200
|
}
|
|
199
201
|
});
|
|
200
|
-
writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
|
|
202
|
+
writePrivateJson(cfg.local.tokenFile, { platform: audience, email, token, expiresAt });
|
|
201
203
|
out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
|
|
202
204
|
return token;
|
|
203
205
|
}
|
|
206
|
+
function handshakeEmail(value, cached) {
|
|
207
|
+
const email = (value ?? process3.env.ODLA_USER_EMAIL ?? cached ?? "").trim().toLowerCase();
|
|
208
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
209
|
+
throw new Error("a fresh odla handshake requires --email <account> or ODLA_USER_EMAIL");
|
|
210
|
+
}
|
|
211
|
+
return email;
|
|
212
|
+
}
|
|
204
213
|
function approvalBrowser(options) {
|
|
205
214
|
if (options.open === true) return { open: true, mode: "forced" };
|
|
206
215
|
if (options.open === false) return { open: false, reason: "disabled by --no-open" };
|
|
@@ -267,14 +276,16 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
267
276
|
out.log(`auth: using cached ${scope} grant (${tokenFile})`);
|
|
268
277
|
return cached.token;
|
|
269
278
|
}
|
|
279
|
+
const email = handshakeEmail(options.email, cache?.platform === audience ? cache.email : void 0);
|
|
270
280
|
const { token, expiresAt } = await requestToken2({
|
|
271
281
|
endpoint: audience,
|
|
282
|
+
email,
|
|
272
283
|
label: `odla CLI admin AI (${scope})`,
|
|
273
284
|
scopes: [scope],
|
|
274
285
|
fetch: doFetch,
|
|
275
|
-
onCode: async ({ userCode, expiresIn }) => {
|
|
276
|
-
const approvalUrl = handshakeUrl(audience, userCode);
|
|
277
|
-
out.log(`
|
|
286
|
+
onCode: async ({ userCode, expiresIn, verificationUriComplete }) => {
|
|
287
|
+
const approvalUrl = verificationUriComplete ?? handshakeUrl(audience, userCode);
|
|
288
|
+
out.log(`Review, then approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
278
289
|
const shouldOpen = options.open === true || options.open !== false && !process4.env.CI && Boolean(process4.stdin.isTTY && process4.stdout.isTTY);
|
|
279
290
|
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
280
291
|
}
|
|
@@ -282,7 +293,7 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
282
293
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
283
294
|
tokens[scope] = { token, expiresAt };
|
|
284
295
|
if (existsSync2(`${process4.cwd()}/.git`)) ensureGitignore(process4.cwd(), [tokenFile]);
|
|
285
|
-
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
296
|
+
writePrivateJson(tokenFile, { platform: audience, email, tokens });
|
|
286
297
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
287
298
|
return token;
|
|
288
299
|
}
|
|
@@ -478,6 +489,7 @@ async function adminAi(options) {
|
|
|
478
489
|
platform,
|
|
479
490
|
scope,
|
|
480
491
|
token: options.token,
|
|
492
|
+
email: options.email,
|
|
481
493
|
open: options.open,
|
|
482
494
|
fetch: doFetch,
|
|
483
495
|
stdout: out,
|
|
@@ -649,11 +661,13 @@ function isRecord3(value) {
|
|
|
649
661
|
// src/capabilities.ts
|
|
650
662
|
var CAPABILITIES = {
|
|
651
663
|
cli: [
|
|
664
|
+
"start an email-bound device request without accepting a password or user session token",
|
|
652
665
|
"register the app and enable configured services per environment",
|
|
653
666
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
654
667
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
655
668
|
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
656
669
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
670
|
+
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
657
671
|
"validate config offline and smoke-test a provisioned db environment",
|
|
658
672
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
659
673
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
@@ -662,10 +676,12 @@ var CAPABILITIES = {
|
|
|
662
676
|
agent: [
|
|
663
677
|
"install and import the selected odla SDKs",
|
|
664
678
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
665
|
-
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
679
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
680
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
666
681
|
],
|
|
667
682
|
human: [
|
|
668
|
-
"
|
|
683
|
+
"provide the existing odla account email, then sign in and explicitly review/approve the exact device code",
|
|
684
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
669
685
|
"consent to production changes with --yes",
|
|
670
686
|
"request destructive credential rotation explicitly",
|
|
671
687
|
"review application semantics, security findings, releases, and merges",
|
|
@@ -674,6 +690,8 @@ var CAPABILITIES = {
|
|
|
674
690
|
],
|
|
675
691
|
studio: [
|
|
676
692
|
"view telemetry and environment state",
|
|
693
|
+
"let signed-in users inventory/revoke their own agent grants and admins audit/global-revoke them",
|
|
694
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
677
695
|
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
678
696
|
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
679
697
|
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
@@ -696,25 +714,6 @@ function printGroup(out, heading, items) {
|
|
|
696
714
|
out.log("");
|
|
697
715
|
}
|
|
698
716
|
|
|
699
|
-
// src/redact.ts
|
|
700
|
-
var REPLACEMENTS = [
|
|
701
|
-
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
702
|
-
[/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
|
|
703
|
-
[/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
|
|
704
|
-
[/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
|
|
705
|
-
[/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
|
|
706
|
-
[/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
|
|
707
|
-
[/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
|
|
708
|
-
];
|
|
709
|
-
function redactSecrets(value) {
|
|
710
|
-
let result = value;
|
|
711
|
-
for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
|
|
712
|
-
return result;
|
|
713
|
-
}
|
|
714
|
-
function looksSecret(value) {
|
|
715
|
-
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
716
|
-
}
|
|
717
|
-
|
|
718
717
|
// src/config.ts
|
|
719
718
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
720
719
|
import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
@@ -722,6 +721,7 @@ import { pathToFileURL } from "url";
|
|
|
722
721
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
723
722
|
var DEFAULT_ENVS = ["dev"];
|
|
724
723
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
724
|
+
var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
725
725
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
726
726
|
const resolved = resolve2(configPath);
|
|
727
727
|
if (!existsSync3(resolved)) {
|
|
@@ -734,6 +734,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
734
734
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
735
735
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
736
736
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
737
|
+
validateCalendarConfig(raw, envs, services, resolved);
|
|
737
738
|
const local = {
|
|
738
739
|
tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
739
740
|
credentialsFile: resolve2(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
@@ -778,6 +779,28 @@ function buildPlan(cfg) {
|
|
|
778
779
|
aiProvider: cfg.ai?.provider
|
|
779
780
|
};
|
|
780
781
|
}
|
|
782
|
+
function calendarServiceConfig(cfg, env) {
|
|
783
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
784
|
+
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
785
|
+
const google = cfg.calendar?.google;
|
|
786
|
+
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
787
|
+
return {
|
|
788
|
+
provider: "google",
|
|
789
|
+
access: "read",
|
|
790
|
+
calendars: unique(google.calendars[env].map((id) => id.trim())),
|
|
791
|
+
match: {
|
|
792
|
+
organizerSelf: google.match?.organizerSelf ?? true,
|
|
793
|
+
requireAttendees: google.match?.requireAttendees ?? true,
|
|
794
|
+
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
795
|
+
},
|
|
796
|
+
attendeePolicy: google.attendeePolicy ?? "full"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function calendarBookingPageUrl(cfg, env) {
|
|
800
|
+
const value = cfg.calendar?.google.bookingPageUrl?.[env];
|
|
801
|
+
if (value === void 0 || value === null) return value;
|
|
802
|
+
return new URL(value).toString();
|
|
803
|
+
}
|
|
781
804
|
function rulesFromSchema(schema) {
|
|
782
805
|
const entities = serializedEntities(schema);
|
|
783
806
|
return Object.fromEntries(
|
|
@@ -801,7 +824,85 @@ function validateRawConfig(raw, path) {
|
|
|
801
824
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
802
825
|
if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
803
826
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
827
|
+
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
828
|
+
throw new Error(`${path}: envs must be an array of names`);
|
|
829
|
+
}
|
|
804
830
|
if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
831
|
+
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
832
|
+
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function validateCalendarConfig(cfg, envs, services, path) {
|
|
836
|
+
const enabled = services.includes("calendar");
|
|
837
|
+
if (!cfg.calendar) {
|
|
838
|
+
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
842
|
+
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
843
|
+
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
844
|
+
const google = cfg.calendar.google;
|
|
845
|
+
assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
|
|
846
|
+
if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
|
|
847
|
+
const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
|
|
848
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
|
|
849
|
+
for (const env of envs) {
|
|
850
|
+
const ids = google.calendars[env];
|
|
851
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
852
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
|
|
853
|
+
}
|
|
854
|
+
if (ids.length > 10) {
|
|
855
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
|
|
856
|
+
}
|
|
857
|
+
if (ids.some((id) => !safeText(id, 1024))) {
|
|
858
|
+
throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (google.bookingPageUrl !== void 0) {
|
|
862
|
+
if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
863
|
+
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
864
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
865
|
+
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
866
|
+
if (value !== null && !safeHttpsUrl(value)) {
|
|
867
|
+
throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
872
|
+
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
873
|
+
}
|
|
874
|
+
if (google.match !== void 0) {
|
|
875
|
+
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
876
|
+
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
877
|
+
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
878
|
+
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
879
|
+
}
|
|
880
|
+
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
881
|
+
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
882
|
+
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
887
|
+
}
|
|
888
|
+
function assertOnly(value, allowed, label) {
|
|
889
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
890
|
+
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
891
|
+
}
|
|
892
|
+
function isRecord4(value) {
|
|
893
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
894
|
+
}
|
|
895
|
+
function safeText(value, max) {
|
|
896
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
897
|
+
}
|
|
898
|
+
function safeHttpsUrl(value) {
|
|
899
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
900
|
+
try {
|
|
901
|
+
const url = new URL(value);
|
|
902
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
903
|
+
} catch {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
805
906
|
}
|
|
806
907
|
function validId(value) {
|
|
807
908
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
@@ -820,6 +921,441 @@ function unique(values) {
|
|
|
820
921
|
return [...new Set(values.filter(Boolean))];
|
|
821
922
|
}
|
|
822
923
|
|
|
924
|
+
// src/redact.ts
|
|
925
|
+
var REPLACEMENTS = [
|
|
926
|
+
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
927
|
+
[/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
|
|
928
|
+
[/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
|
|
929
|
+
[/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
|
|
930
|
+
[/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
|
|
931
|
+
[/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
|
|
932
|
+
[/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
|
|
933
|
+
];
|
|
934
|
+
function redactSecrets(value) {
|
|
935
|
+
let result = value;
|
|
936
|
+
for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
|
|
937
|
+
return result;
|
|
938
|
+
}
|
|
939
|
+
function looksSecret(value) {
|
|
940
|
+
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/calendar-http.ts
|
|
944
|
+
var CALENDAR_STATES = [
|
|
945
|
+
"not_connected",
|
|
946
|
+
"authorizing",
|
|
947
|
+
"needs_sync",
|
|
948
|
+
"initial_sync",
|
|
949
|
+
"healthy",
|
|
950
|
+
"degraded",
|
|
951
|
+
"disconnected",
|
|
952
|
+
"failed"
|
|
953
|
+
];
|
|
954
|
+
async function readCalendarStatus(ctx) {
|
|
955
|
+
return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
|
|
956
|
+
}
|
|
957
|
+
async function discoverGoogleCalendars(ctx) {
|
|
958
|
+
const raw = await calendarJson(ctx, "/calendars", {});
|
|
959
|
+
const value = record(raw);
|
|
960
|
+
if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
|
|
961
|
+
return value.calendars.map((item, index) => {
|
|
962
|
+
const calendar = record(item);
|
|
963
|
+
const id = textField(calendar?.id, 1024);
|
|
964
|
+
if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
|
|
965
|
+
const role = calendar.accessRole;
|
|
966
|
+
if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
|
|
967
|
+
throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
id,
|
|
971
|
+
...optionalText("summary", calendar.summary, 500),
|
|
972
|
+
...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
|
|
973
|
+
...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
|
|
974
|
+
...typeof role === "string" ? { accessRole: role } : {}
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
async function applyCalendarSettings(ctx, bookingPageUrl) {
|
|
979
|
+
return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
|
|
980
|
+
}
|
|
981
|
+
async function startCalendarConnection(ctx) {
|
|
982
|
+
const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
|
|
983
|
+
const value = wrapped(raw, "attempt");
|
|
984
|
+
const attemptId = textField(value.attemptId, 180);
|
|
985
|
+
const consentUrl = textField(value.consentUrl, 4096);
|
|
986
|
+
const expiresAt = timestamp3(value.expiresAt);
|
|
987
|
+
if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
|
|
988
|
+
return { attemptId, consentUrl, expiresAt };
|
|
989
|
+
}
|
|
990
|
+
async function pollCalendarConnection(ctx, attemptId) {
|
|
991
|
+
return parseCalendarStatus(
|
|
992
|
+
await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
|
|
993
|
+
ctx.env
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
async function requestCalendarResync(ctx) {
|
|
997
|
+
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
998
|
+
}
|
|
999
|
+
async function requestCalendarDisconnect(ctx) {
|
|
1000
|
+
return parseCalendarStatus(
|
|
1001
|
+
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
1002
|
+
ctx.env
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
function parseCalendarStatus(raw, env) {
|
|
1006
|
+
const outer = wrapped(raw, "calendar");
|
|
1007
|
+
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1008
|
+
const connection = record(value.connection) ?? {};
|
|
1009
|
+
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1010
|
+
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1011
|
+
const googleConfig = record(config.google) ?? config;
|
|
1012
|
+
const watches = record(value.watches) ?? {};
|
|
1013
|
+
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1014
|
+
if (!stateValue) {
|
|
1015
|
+
throw new Error("calendar status returned an invalid connection state");
|
|
1016
|
+
}
|
|
1017
|
+
const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
|
|
1018
|
+
if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
|
|
1019
|
+
throw new Error("calendar status returned an unsupported provider");
|
|
1020
|
+
}
|
|
1021
|
+
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1022
|
+
if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
|
|
1023
|
+
const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
|
|
1024
|
+
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1025
|
+
throw new Error("calendar status returned an invalid attendee policy");
|
|
1026
|
+
}
|
|
1027
|
+
const errorValue = record(value.error) ?? record(connection.error);
|
|
1028
|
+
const errorCode = textField(value.lastErrorCode, 128);
|
|
1029
|
+
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1030
|
+
return {
|
|
1031
|
+
env,
|
|
1032
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1033
|
+
connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
|
|
1034
|
+
provider: providerValue === "google" ? "google" : null,
|
|
1035
|
+
status: stateValue,
|
|
1036
|
+
...accessValue === "read" ? { access: "read" } : {},
|
|
1037
|
+
calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
|
|
1038
|
+
...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
|
|
1039
|
+
...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
|
|
1040
|
+
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1041
|
+
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1042
|
+
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1043
|
+
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1044
|
+
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1045
|
+
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1046
|
+
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1047
|
+
...errorValue || errorCode ? { error: {
|
|
1048
|
+
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1049
|
+
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
1050
|
+
...!errorValue && errorCode ? { code: errorCode } : {}
|
|
1051
|
+
} } : {}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function calendarState(value) {
|
|
1055
|
+
if (typeof value !== "string") return null;
|
|
1056
|
+
if (CALENDAR_STATES.includes(value)) return value;
|
|
1057
|
+
const legacy = {
|
|
1058
|
+
disabled: "not_connected",
|
|
1059
|
+
disconnected: "disconnected",
|
|
1060
|
+
connecting: "authorizing",
|
|
1061
|
+
syncing: "initial_sync",
|
|
1062
|
+
ready: "healthy",
|
|
1063
|
+
error: "failed"
|
|
1064
|
+
};
|
|
1065
|
+
return legacy[value] ?? null;
|
|
1066
|
+
}
|
|
1067
|
+
async function calendarJson(ctx, suffix, init) {
|
|
1068
|
+
const platform = platformAudience(ctx.platform);
|
|
1069
|
+
const appId = identifier(ctx.appId, "appId");
|
|
1070
|
+
const env = identifier(ctx.env, "env");
|
|
1071
|
+
const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
|
|
1072
|
+
url.searchParams.set("env", env);
|
|
1073
|
+
const token = credential(ctx.token);
|
|
1074
|
+
let response;
|
|
1075
|
+
try {
|
|
1076
|
+
response = await (ctx.fetch ?? fetch)(url, {
|
|
1077
|
+
...init,
|
|
1078
|
+
redirect: "error",
|
|
1079
|
+
credentials: "omit",
|
|
1080
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
|
|
1081
|
+
});
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1084
|
+
}
|
|
1085
|
+
const body = await response.json().catch(() => ({}));
|
|
1086
|
+
if (!response.ok) {
|
|
1087
|
+
const code = textField(body.error?.code, 128);
|
|
1088
|
+
const message = textField(body.error?.message, 500);
|
|
1089
|
+
throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
|
|
1090
|
+
}
|
|
1091
|
+
return body;
|
|
1092
|
+
}
|
|
1093
|
+
function wrapped(raw, key) {
|
|
1094
|
+
const outer = record(raw);
|
|
1095
|
+
if (!outer) throw new Error("calendar returned an invalid response");
|
|
1096
|
+
return record(outer[key]) ?? outer;
|
|
1097
|
+
}
|
|
1098
|
+
function record(value) {
|
|
1099
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1100
|
+
}
|
|
1101
|
+
function textField(value, max) {
|
|
1102
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
|
|
1103
|
+
}
|
|
1104
|
+
function stringList(value) {
|
|
1105
|
+
return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
|
|
1106
|
+
}
|
|
1107
|
+
function calendarIds(value) {
|
|
1108
|
+
if (!Array.isArray(value)) return [];
|
|
1109
|
+
return [...new Set(value.flatMap((item) => {
|
|
1110
|
+
if (typeof item === "string") return textField(item, 4096) ? [item] : [];
|
|
1111
|
+
const calendar = record(item);
|
|
1112
|
+
const id = textField(calendar?.id, 4096);
|
|
1113
|
+
return id && calendar?.selected !== false ? [id] : [];
|
|
1114
|
+
}))];
|
|
1115
|
+
}
|
|
1116
|
+
function timestamp3(value) {
|
|
1117
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
1118
|
+
if (typeof value === "string") {
|
|
1119
|
+
const parsed = Date.parse(value);
|
|
1120
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
1121
|
+
}
|
|
1122
|
+
return void 0;
|
|
1123
|
+
}
|
|
1124
|
+
function optionalText(key, value, max) {
|
|
1125
|
+
const parsed = textField(value, max);
|
|
1126
|
+
return parsed ? { [key]: parsed } : {};
|
|
1127
|
+
}
|
|
1128
|
+
function optionalNumber(key, value) {
|
|
1129
|
+
const parsed = timestamp3(value);
|
|
1130
|
+
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1131
|
+
}
|
|
1132
|
+
function optionalInteger(key, value) {
|
|
1133
|
+
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1134
|
+
}
|
|
1135
|
+
function optionalNullableUrl(key, value) {
|
|
1136
|
+
if (value === null) return { [key]: null };
|
|
1137
|
+
const parsed = textField(value, 4096);
|
|
1138
|
+
return parsed ? { [key]: parsed } : {};
|
|
1139
|
+
}
|
|
1140
|
+
function identifier(value, name) {
|
|
1141
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
1142
|
+
return value;
|
|
1143
|
+
}
|
|
1144
|
+
function credential(value) {
|
|
1145
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1146
|
+
throw new Error("calendar requires an odla developer token");
|
|
1147
|
+
}
|
|
1148
|
+
return value;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/calendar-poll.ts
|
|
1152
|
+
async function waitForCalendarPoll(milliseconds, signal) {
|
|
1153
|
+
if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
|
|
1154
|
+
await new Promise((resolve7, reject) => {
|
|
1155
|
+
const timer = setTimeout(resolve7, milliseconds);
|
|
1156
|
+
signal?.addEventListener("abort", () => {
|
|
1157
|
+
clearTimeout(timer);
|
|
1158
|
+
reject(signal.reason ?? new Error("calendar connection aborted"));
|
|
1159
|
+
}, { once: true });
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/calendar.ts
|
|
1164
|
+
async function calendarStatus(options) {
|
|
1165
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1166
|
+
const status = await readCalendarStatus(ctx);
|
|
1167
|
+
printStatus(status, options.json === true, out);
|
|
1168
|
+
return status;
|
|
1169
|
+
}
|
|
1170
|
+
async function calendarCalendars(options) {
|
|
1171
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1172
|
+
const calendars = await discoverGoogleCalendars(ctx);
|
|
1173
|
+
if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
|
|
1174
|
+
else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
|
|
1175
|
+
else for (const calendar of calendars) {
|
|
1176
|
+
out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
|
|
1177
|
+
}
|
|
1178
|
+
return calendars;
|
|
1179
|
+
}
|
|
1180
|
+
async function calendarConnect(options) {
|
|
1181
|
+
const { cfg, ctx, out } = await lifecycleContext(options);
|
|
1182
|
+
productionConsent(ctx.env, options.yes, "connect calendar");
|
|
1183
|
+
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1184
|
+
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1185
|
+
const connectOptions = connectionOptions(options, out);
|
|
1186
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
|
|
1187
|
+
}
|
|
1188
|
+
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1189
|
+
if (bookingPageUrl === void 0) return null;
|
|
1190
|
+
const status = await applyCalendarSettings(ctx, bookingPageUrl);
|
|
1191
|
+
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1192
|
+
return status;
|
|
1193
|
+
}
|
|
1194
|
+
async function calendarResync(options) {
|
|
1195
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1196
|
+
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1197
|
+
const status = await requestCalendarResync(ctx);
|
|
1198
|
+
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1199
|
+
return status;
|
|
1200
|
+
}
|
|
1201
|
+
async function calendarDisconnect(options) {
|
|
1202
|
+
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1203
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1204
|
+
const status = await requestCalendarDisconnect(ctx);
|
|
1205
|
+
out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
|
|
1206
|
+
return status;
|
|
1207
|
+
}
|
|
1208
|
+
async function ensureCalendarConnected(ctx, options) {
|
|
1209
|
+
const out = options.stdout ?? console;
|
|
1210
|
+
const current = await readCalendarStatus(ctx);
|
|
1211
|
+
const connectOptions = connectionOptions(options, out);
|
|
1212
|
+
return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
|
|
1213
|
+
}
|
|
1214
|
+
async function lifecycleContext(options) {
|
|
1215
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1216
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
1217
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1218
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1219
|
+
calendarServiceConfig(cfg, env);
|
|
1220
|
+
const out = options.stdout ?? console;
|
|
1221
|
+
const doFetch = options.fetch ?? fetch;
|
|
1222
|
+
const token = await getDeveloperToken(
|
|
1223
|
+
cfg,
|
|
1224
|
+
{
|
|
1225
|
+
configPath: cfg.configPath,
|
|
1226
|
+
token: options.token,
|
|
1227
|
+
email: options.email,
|
|
1228
|
+
open: options.open,
|
|
1229
|
+
interactive: options.interactive,
|
|
1230
|
+
openApprovalUrl: options.openConsentUrl
|
|
1231
|
+
},
|
|
1232
|
+
doFetch,
|
|
1233
|
+
out
|
|
1234
|
+
);
|
|
1235
|
+
return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
|
|
1236
|
+
}
|
|
1237
|
+
function connectionOptions(options, out) {
|
|
1238
|
+
const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
|
|
1239
|
+
return {
|
|
1240
|
+
out,
|
|
1241
|
+
open: browser.open,
|
|
1242
|
+
openConsentUrl: options.openConsentUrl,
|
|
1243
|
+
wait: options.wait,
|
|
1244
|
+
pollTimeoutMs: options.pollTimeoutMs,
|
|
1245
|
+
now: options.now,
|
|
1246
|
+
signal: options.signal
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
1250
|
+
if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
|
|
1251
|
+
options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
|
|
1252
|
+
return current;
|
|
1253
|
+
}
|
|
1254
|
+
if (current.status === "degraded") return null;
|
|
1255
|
+
if (current.status === "needs_sync") {
|
|
1256
|
+
if (!current.connected) return null;
|
|
1257
|
+
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1258
|
+
const synced = await requestCalendarResync(ctx);
|
|
1259
|
+
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1260
|
+
return synced;
|
|
1261
|
+
}
|
|
1262
|
+
if (current.status === "failed" && current.connected) {
|
|
1263
|
+
const reason = current.error?.message ?? current.error?.code;
|
|
1264
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1265
|
+
}
|
|
1266
|
+
const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
|
|
1267
|
+
if (!waitingForExisting) return null;
|
|
1268
|
+
const now = options.now ?? Date.now;
|
|
1269
|
+
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1270
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1271
|
+
let prior = "";
|
|
1272
|
+
while (now() < deadline) {
|
|
1273
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1274
|
+
const status = await readCalendarStatus(ctx);
|
|
1275
|
+
if (status.status !== prior) {
|
|
1276
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1277
|
+
prior = status.status;
|
|
1278
|
+
}
|
|
1279
|
+
if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
|
|
1280
|
+
if (status.status === "degraded") return null;
|
|
1281
|
+
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1282
|
+
if (status.status === "failed" && status.connected) {
|
|
1283
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1284
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1285
|
+
}
|
|
1286
|
+
if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
|
|
1287
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1288
|
+
}
|
|
1289
|
+
throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
|
|
1290
|
+
}
|
|
1291
|
+
async function connectWithContext(ctx, options) {
|
|
1292
|
+
const attempt = await startCalendarConnection(ctx);
|
|
1293
|
+
const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
|
|
1294
|
+
options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
|
|
1295
|
+
if (options.open) {
|
|
1296
|
+
await (options.openConsentUrl ?? openUrl)(consentUrl);
|
|
1297
|
+
options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
|
|
1298
|
+
}
|
|
1299
|
+
const now = options.now ?? Date.now;
|
|
1300
|
+
const timeout = pollTimeout(options.pollTimeoutMs);
|
|
1301
|
+
const deadline = Math.min(attempt.expiresAt, now() + timeout);
|
|
1302
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1303
|
+
let prior = "";
|
|
1304
|
+
while (now() < deadline) {
|
|
1305
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1306
|
+
const status = await pollCalendarConnection(ctx, attempt.attemptId);
|
|
1307
|
+
if (status.status !== prior) {
|
|
1308
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1309
|
+
prior = status.status;
|
|
1310
|
+
}
|
|
1311
|
+
if (status.status === "healthy" || status.status === "degraded") return status;
|
|
1312
|
+
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1313
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1314
|
+
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1315
|
+
}
|
|
1316
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1317
|
+
}
|
|
1318
|
+
throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
|
|
1319
|
+
}
|
|
1320
|
+
function trustedCalendarConsentUrl(platform, value) {
|
|
1321
|
+
const origin = platformAudience(platform);
|
|
1322
|
+
let url;
|
|
1323
|
+
try {
|
|
1324
|
+
url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
|
|
1325
|
+
} catch {
|
|
1326
|
+
throw new Error("odla.ai returned an invalid calendar consent URL");
|
|
1327
|
+
}
|
|
1328
|
+
const platformPage = url.origin === origin;
|
|
1329
|
+
const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
1330
|
+
if (!platformPage && !googlePage || url.username || url.password || url.hash) {
|
|
1331
|
+
throw new Error("odla.ai returned an untrusted calendar consent URL");
|
|
1332
|
+
}
|
|
1333
|
+
return url.toString();
|
|
1334
|
+
}
|
|
1335
|
+
function pollTimeout(value = 10 * 6e4) {
|
|
1336
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
|
|
1337
|
+
return value;
|
|
1338
|
+
}
|
|
1339
|
+
function productionConsent(env, yes, action) {
|
|
1340
|
+
if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
|
|
1341
|
+
}
|
|
1342
|
+
function printStatus(status, json, out) {
|
|
1343
|
+
if (json) {
|
|
1344
|
+
out.log(JSON.stringify(status, null, 2));
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1348
|
+
out.log(` access: ${status.access ?? "not granted"}`);
|
|
1349
|
+
out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1350
|
+
if (status.attendeePolicy) {
|
|
1351
|
+
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1354
|
+
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1355
|
+
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1356
|
+
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
823
1359
|
// src/doctor-checks.ts
|
|
824
1360
|
import { execFileSync } from "child_process";
|
|
825
1361
|
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
@@ -1005,6 +1541,15 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1005
1541
|
}
|
|
1006
1542
|
return warnings;
|
|
1007
1543
|
}
|
|
1544
|
+
function calendarProjectWarnings(rootDir) {
|
|
1545
|
+
const pkg = readPackageJson(rootDir);
|
|
1546
|
+
const dependencies = pkg?.dependencies;
|
|
1547
|
+
const devDependencies = pkg?.devDependencies;
|
|
1548
|
+
if (dependencies?.["@odla-ai/calendar"]) return [];
|
|
1549
|
+
return [
|
|
1550
|
+
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"
|
|
1551
|
+
];
|
|
1552
|
+
}
|
|
1008
1553
|
function readPackageJson(rootDir) {
|
|
1009
1554
|
try {
|
|
1010
1555
|
return JSON.parse(readFileSync4(join2(rootDir, "package.json"), "utf8"));
|
|
@@ -1030,6 +1575,14 @@ async function doctor(options) {
|
|
|
1030
1575
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1031
1576
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1032
1577
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1578
|
+
if (cfg.services.includes("calendar")) {
|
|
1579
|
+
const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
|
|
1580
|
+
out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
|
|
1581
|
+
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1582
|
+
out.log(`booking pages: ${pages}`);
|
|
1583
|
+
} else {
|
|
1584
|
+
out.log("calendar: not enabled");
|
|
1585
|
+
}
|
|
1033
1586
|
const warnings = [];
|
|
1034
1587
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
1035
1588
|
if (rules) {
|
|
@@ -1057,6 +1610,8 @@ async function doctor(options) {
|
|
|
1057
1610
|
}
|
|
1058
1611
|
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
1059
1612
|
}
|
|
1613
|
+
if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
|
|
1614
|
+
else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
|
|
1060
1615
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
1061
1616
|
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
1062
1617
|
cfg.local.tokenFile,
|
|
@@ -1088,6 +1643,7 @@ function initProject(options) {
|
|
|
1088
1643
|
}
|
|
1089
1644
|
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
1090
1645
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1646
|
+
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1091
1647
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1092
1648
|
mkdirSync2(dirname3(configPath), { recursive: true });
|
|
1093
1649
|
mkdirSync2(resolve4(rootDir, "src/odla"), { recursive: true });
|
|
@@ -1105,6 +1661,16 @@ function writeIfMissing(path, text) {
|
|
|
1105
1661
|
writeFileSync2(path, text);
|
|
1106
1662
|
}
|
|
1107
1663
|
function configTemplate(input) {
|
|
1664
|
+
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1665
|
+
google: {
|
|
1666
|
+
calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1667
|
+
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1668
|
+
match: { organizerSelf: true, requireAttendees: true },
|
|
1669
|
+
attendeePolicy: "full",
|
|
1670
|
+
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1671
|
+
},
|
|
1672
|
+
},
|
|
1673
|
+
` : "";
|
|
1108
1674
|
return `export default {
|
|
1109
1675
|
platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
|
|
1110
1676
|
dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
|
|
@@ -1126,6 +1692,7 @@ function configTemplate(input) {
|
|
|
1126
1692
|
// key in the platform vault for each tenant.
|
|
1127
1693
|
keyEnv: "${defaultKeyEnv(input.aiProvider)}",
|
|
1128
1694
|
},
|
|
1695
|
+
${calendar}
|
|
1129
1696
|
auth: {
|
|
1130
1697
|
clerk: {
|
|
1131
1698
|
// dev: "$CLERK_PUBLISHABLE_KEY",
|
|
@@ -1325,14 +1892,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1325
1892
|
appId: tenantId
|
|
1326
1893
|
})
|
|
1327
1894
|
});
|
|
1328
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
1895
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
|
|
1329
1896
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1330
1897
|
method: "POST",
|
|
1331
1898
|
headers,
|
|
1332
1899
|
body: "{}"
|
|
1333
1900
|
});
|
|
1334
1901
|
}
|
|
1335
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
1902
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1336
1903
|
const body = await res.json();
|
|
1337
1904
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1338
1905
|
return body.key;
|
|
@@ -1348,12 +1915,12 @@ async function issueO11yToken(opts) {
|
|
|
1348
1915
|
`o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
|
|
1349
1916
|
);
|
|
1350
1917
|
}
|
|
1351
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
1918
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1352
1919
|
const body = await res.json();
|
|
1353
1920
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1354
1921
|
return body.token;
|
|
1355
1922
|
}
|
|
1356
|
-
async function
|
|
1923
|
+
async function safeText2(res) {
|
|
1357
1924
|
try {
|
|
1358
1925
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1359
1926
|
} catch {
|
|
@@ -1393,6 +1960,14 @@ async function provision(options) {
|
|
|
1393
1960
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1394
1961
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
1395
1962
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1963
|
+
if (cfg.services.includes("calendar")) {
|
|
1964
|
+
for (const env of cfg.envs) {
|
|
1965
|
+
const calendar = calendarServiceConfig(cfg, env);
|
|
1966
|
+
out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
|
|
1967
|
+
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
1968
|
+
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1396
1971
|
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
1397
1972
|
return;
|
|
1398
1973
|
}
|
|
@@ -1427,8 +2002,9 @@ async function provision(options) {
|
|
|
1427
2002
|
await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
|
|
1428
2003
|
out.log(`app: created ${cfg.app.id}`);
|
|
1429
2004
|
}
|
|
2005
|
+
const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
|
|
1430
2006
|
for (const env of cfg.envs) {
|
|
1431
|
-
for (const service of
|
|
2007
|
+
for (const service of serviceOrder) {
|
|
1432
2008
|
if (service === "ai") {
|
|
1433
2009
|
if (cfg.ai?.provider) {
|
|
1434
2010
|
await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
|
|
@@ -1438,7 +2014,8 @@ async function provision(options) {
|
|
|
1438
2014
|
out.log(`${env}: ai enabled`);
|
|
1439
2015
|
}
|
|
1440
2016
|
} else {
|
|
1441
|
-
|
|
2017
|
+
const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
|
|
2018
|
+
await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
|
|
1442
2019
|
out.log(`${env}: ${service} enabled`);
|
|
1443
2020
|
}
|
|
1444
2021
|
}
|
|
@@ -1452,6 +2029,21 @@ async function provision(options) {
|
|
|
1452
2029
|
await apps.setLink(cfg.app.id, env, link ?? null);
|
|
1453
2030
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
1454
2031
|
}
|
|
2032
|
+
if (cfg.services.includes("calendar")) {
|
|
2033
|
+
const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
|
|
2034
|
+
await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
|
|
2035
|
+
await ensureCalendarConnected(
|
|
2036
|
+
calendarCtx,
|
|
2037
|
+
{
|
|
2038
|
+
open: options.open,
|
|
2039
|
+
interactive: options.interactive,
|
|
2040
|
+
openConsentUrl: options.openApprovalUrl,
|
|
2041
|
+
wait: options.calendarPollWait,
|
|
2042
|
+
pollTimeoutMs: options.calendarPollTimeoutMs,
|
|
2043
|
+
stdout: out
|
|
2044
|
+
}
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
1455
2047
|
}
|
|
1456
2048
|
for (const env of cfg.envs) {
|
|
1457
2049
|
const tenantId = tenantIdFor2(cfg.app.id, env);
|
|
@@ -1515,6 +2107,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1515
2107
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
1516
2108
|
}
|
|
1517
2109
|
}
|
|
2110
|
+
function serviceRank(service) {
|
|
2111
|
+
if (service === "db") return 0;
|
|
2112
|
+
if (service === "calendar") return 2;
|
|
2113
|
+
return 1;
|
|
2114
|
+
}
|
|
1518
2115
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
1519
2116
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
1520
2117
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -1530,7 +2127,7 @@ async function postJson(doFetch, url, bearer, body) {
|
|
|
1530
2127
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1531
2128
|
body: JSON.stringify(body)
|
|
1532
2129
|
});
|
|
1533
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2130
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
|
|
1534
2131
|
}
|
|
1535
2132
|
function normalizeClerkConfig(value) {
|
|
1536
2133
|
if (!value) return null;
|
|
@@ -1547,7 +2144,7 @@ function defaultSecretName(provider) {
|
|
|
1547
2144
|
const names = DEFAULT_SECRET_NAMES;
|
|
1548
2145
|
return names[provider] ?? `${provider}_api_key`;
|
|
1549
2146
|
}
|
|
1550
|
-
async function
|
|
2147
|
+
async function safeText3(res) {
|
|
1551
2148
|
try {
|
|
1552
2149
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1553
2150
|
} catch {
|
|
@@ -2382,6 +2979,24 @@ async function smoke(options) {
|
|
|
2382
2979
|
}
|
|
2383
2980
|
out.log(` ai: ${provider}`);
|
|
2384
2981
|
}
|
|
2982
|
+
if (cfg.services.includes("calendar")) {
|
|
2983
|
+
const token = await getDeveloperToken(
|
|
2984
|
+
cfg,
|
|
2985
|
+
{
|
|
2986
|
+
configPath: cfg.configPath,
|
|
2987
|
+
token: options.token,
|
|
2988
|
+
email: options.email,
|
|
2989
|
+
open: options.open,
|
|
2990
|
+
interactive: options.interactive,
|
|
2991
|
+
openApprovalUrl: options.openApprovalUrl
|
|
2992
|
+
},
|
|
2993
|
+
doFetch,
|
|
2994
|
+
out
|
|
2995
|
+
);
|
|
2996
|
+
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
2997
|
+
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
2998
|
+
out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
2999
|
+
}
|
|
2385
3000
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
2386
3001
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
2387
3002
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
@@ -2403,13 +3018,35 @@ async function smoke(options) {
|
|
|
2403
3018
|
} else {
|
|
2404
3019
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
2405
3020
|
}
|
|
3021
|
+
if (cfg.services.includes("calendar")) {
|
|
3022
|
+
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3023
|
+
ns: "$bookings",
|
|
3024
|
+
aggregate: { count: true }
|
|
3025
|
+
});
|
|
3026
|
+
const count = bookings.aggregate?.count;
|
|
3027
|
+
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3028
|
+
}
|
|
2406
3029
|
out.log("ok");
|
|
2407
3030
|
}
|
|
3031
|
+
function assertCalendarHealthy(status, expected) {
|
|
3032
|
+
if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
|
|
3033
|
+
if (status.status !== "healthy") {
|
|
3034
|
+
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3035
|
+
}
|
|
3036
|
+
if (status.access !== "read") throw new Error("calendar connection is not read-only");
|
|
3037
|
+
const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
|
|
3038
|
+
if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
|
|
3039
|
+
const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
|
|
3040
|
+
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3041
|
+
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3042
|
+
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3043
|
+
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3044
|
+
}
|
|
2408
3045
|
async function getJson(doFetch, url, bearer) {
|
|
2409
3046
|
const res = await doFetch(url, {
|
|
2410
3047
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
2411
3048
|
});
|
|
2412
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3049
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
2413
3050
|
return res.json();
|
|
2414
3051
|
}
|
|
2415
3052
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -2418,7 +3055,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
2418
3055
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2419
3056
|
body: JSON.stringify(body)
|
|
2420
3057
|
});
|
|
2421
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3058
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
2422
3059
|
return res.json();
|
|
2423
3060
|
}
|
|
2424
3061
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -2426,7 +3063,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
2426
3063
|
url.searchParams.set("env", env);
|
|
2427
3064
|
return url.toString();
|
|
2428
3065
|
}
|
|
2429
|
-
async function
|
|
3066
|
+
async function safeText4(res) {
|
|
2430
3067
|
try {
|
|
2431
3068
|
return (await res.text()).slice(0, 500);
|
|
2432
3069
|
} catch {
|
|
@@ -2525,6 +3162,7 @@ async function adminCommand(parsed) {
|
|
|
2525
3162
|
"platform",
|
|
2526
3163
|
"json",
|
|
2527
3164
|
"open",
|
|
3165
|
+
"email",
|
|
2528
3166
|
"provider",
|
|
2529
3167
|
"model",
|
|
2530
3168
|
"enabled",
|
|
@@ -2552,6 +3190,7 @@ async function adminCommand(parsed) {
|
|
|
2552
3190
|
maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
|
|
2553
3191
|
maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
|
|
2554
3192
|
platform: stringOpt(parsed.options.platform),
|
|
3193
|
+
email: stringOpt(parsed.options.email),
|
|
2555
3194
|
json: parsed.options.json === true,
|
|
2556
3195
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
2557
3196
|
credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
|
|
@@ -2579,10 +3218,15 @@ function printHelp() {
|
|
|
2579
3218
|
|
|
2580
3219
|
Usage:
|
|
2581
3220
|
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2582
|
-
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
3221
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
|
|
2583
3222
|
odla-ai doctor [--config odla.config.mjs]
|
|
3223
|
+
odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
|
|
3224
|
+
odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
|
|
3225
|
+
odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
|
|
3226
|
+
odla-ai calendar resync [--env dev] [--email <odla-account>] [--yes]
|
|
3227
|
+
odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
|
|
2584
3228
|
odla-ai capabilities [--json]
|
|
2585
|
-
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
3229
|
+
odla-ai admin ai show [--platform https://odla.ai] [--email <odla-account>] [--json]
|
|
2586
3230
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
2587
3231
|
odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
|
|
2588
3232
|
odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
|
|
@@ -2591,7 +3235,7 @@ Usage:
|
|
|
2591
3235
|
odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
|
|
2592
3236
|
odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
|
|
2593
3237
|
odla-ai admin ai audit [--limit <1-200>] [--json]
|
|
2594
|
-
odla-ai security github connect [--repo owner/name] [--env dev] [--no-open]
|
|
3238
|
+
odla-ai security github connect [--repo owner/name] [--env dev] [--email <odla-account>] [--no-open]
|
|
2595
3239
|
odla-ai security github disconnect --source <id> [--env dev] [--yes]
|
|
2596
3240
|
odla-ai security plan [--env dev] [--json]
|
|
2597
3241
|
odla-ai security sources [--env dev] [--json]
|
|
@@ -2600,18 +3244,19 @@ Usage:
|
|
|
2600
3244
|
odla-ai security report <job-id> [--json]
|
|
2601
3245
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
2602
3246
|
odla-ai security run [target] --self --ack-redacted-source
|
|
2603
|
-
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
2604
|
-
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
3247
|
+
odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
3248
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
|
|
2605
3249
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2606
3250
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
2607
|
-
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
2608
|
-
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
3251
|
+
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
|
|
3252
|
+
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
|
|
2609
3253
|
odla-ai version
|
|
2610
3254
|
|
|
2611
3255
|
Commands:
|
|
2612
3256
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
2613
3257
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
2614
3258
|
doctor Validate and summarize the project config without network calls.
|
|
3259
|
+
calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
|
|
2615
3260
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
2616
3261
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
2617
3262
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -2632,6 +3277,12 @@ Safety:
|
|
|
2632
3277
|
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
2633
3278
|
Provision opens the approval page automatically in interactive terminals;
|
|
2634
3279
|
use --open to force browser launch or --no-open to suppress it.
|
|
3280
|
+
A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
|
|
3281
|
+
The email is a non-secret identity hint: never provide a password or session
|
|
3282
|
+
token. The matching account must already exist, be signed in, explicitly
|
|
3283
|
+
review the exact code, and finish any current request before claiming another.
|
|
3284
|
+
Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
|
|
3285
|
+
OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
|
|
2635
3286
|
GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
|
|
2636
3287
|
for a PAT or provider key. GitHub read access is separate from the explicit
|
|
2637
3288
|
--ack-redacted-source consent and the exact --plan-digest printed by security
|
|
@@ -2679,7 +3330,7 @@ async function hostedSecurityContext(parsed, dependencies) {
|
|
|
2679
3330
|
const open = parsed.options.open === false ? false : parsed.options.open === true ? true : void 0;
|
|
2680
3331
|
const token = await getDeveloperToken(
|
|
2681
3332
|
cfg,
|
|
2682
|
-
{ configPath, open, openApprovalUrl: dependencies.openUrl },
|
|
3333
|
+
{ configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
|
|
2683
3334
|
doFetch,
|
|
2684
3335
|
stdout
|
|
2685
3336
|
);
|
|
@@ -2825,6 +3476,7 @@ async function runSourceSecurityCommand(parsed, dependencies, sourceId) {
|
|
|
2825
3476
|
"source",
|
|
2826
3477
|
"ref",
|
|
2827
3478
|
"follow",
|
|
3479
|
+
"email",
|
|
2828
3480
|
"open",
|
|
2829
3481
|
"json",
|
|
2830
3482
|
"ack-redacted-source",
|
|
@@ -2913,6 +3565,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2913
3565
|
"platform",
|
|
2914
3566
|
"self",
|
|
2915
3567
|
"ack-redacted-source",
|
|
3568
|
+
"email",
|
|
2916
3569
|
"open",
|
|
2917
3570
|
"fail-on",
|
|
2918
3571
|
"fail-on-candidates",
|
|
@@ -2942,6 +3595,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2942
3595
|
return getScopedPlatformToken({
|
|
2943
3596
|
platform: request.platform,
|
|
2944
3597
|
scope: request.scope,
|
|
3598
|
+
email: stringOpt(parsed.options.email),
|
|
2945
3599
|
open,
|
|
2946
3600
|
fetch: doFetch,
|
|
2947
3601
|
stdout: out,
|
|
@@ -2954,7 +3608,7 @@ async function runLocalSecurityCommand(parsed, dependencies) {
|
|
|
2954
3608
|
}
|
|
2955
3609
|
return getDeveloperToken(
|
|
2956
3610
|
cfg,
|
|
2957
|
-
{ configPath, open, openApprovalUrl: dependencies.openUrl },
|
|
3611
|
+
{ configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
|
|
2958
3612
|
doFetch,
|
|
2959
3613
|
out
|
|
2960
3614
|
);
|
|
@@ -2988,7 +3642,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
2988
3642
|
return;
|
|
2989
3643
|
}
|
|
2990
3644
|
if (sub === "plan") {
|
|
2991
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
|
|
3645
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
|
|
2992
3646
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
2993
3647
|
const plan = await getHostedSecurityPlan(context);
|
|
2994
3648
|
if (parsed.options.json === true) context.stdout.log(JSON.stringify(plan, null, 2));
|
|
@@ -3004,7 +3658,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
3004
3658
|
return;
|
|
3005
3659
|
}
|
|
3006
3660
|
if (sub === "report") {
|
|
3007
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 3);
|
|
3661
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 3);
|
|
3008
3662
|
const jobId = requiredSecurityPositional(parsed, 2, "job id");
|
|
3009
3663
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
3010
3664
|
const report = await getHostedSecurityReport({ ...context, jobId });
|
|
@@ -3022,7 +3676,7 @@ async function securityCommand(parsed, dependencies) {
|
|
|
3022
3676
|
async function githubSecurityCommand(parsed, dependencies) {
|
|
3023
3677
|
const action = parsed.positionals[2];
|
|
3024
3678
|
if (action === "disconnect") {
|
|
3025
|
-
assertArgs(parsed, ["config", "env", "platform", "source", "open", "yes"], 3);
|
|
3679
|
+
assertArgs(parsed, ["config", "env", "platform", "source", "email", "open", "yes"], 3);
|
|
3026
3680
|
const context2 = await hostedSecurityContext(parsed, dependencies);
|
|
3027
3681
|
const sourceId = requiredString(parsed.options.source, "--source");
|
|
3028
3682
|
const confirmed = parsed.options.yes === true || await interactiveConfirmation(
|
|
@@ -3039,7 +3693,7 @@ async function githubSecurityCommand(parsed, dependencies) {
|
|
|
3039
3693
|
if (action !== "connect") {
|
|
3040
3694
|
throw new Error('unknown security github command. Try "odla-ai security github connect".');
|
|
3041
3695
|
}
|
|
3042
|
-
assertArgs(parsed, ["config", "env", "platform", "repo", "open"], 3);
|
|
3696
|
+
assertArgs(parsed, ["config", "env", "platform", "repo", "email", "open"], 3);
|
|
3043
3697
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
3044
3698
|
const repository = stringOpt(parsed.options.repo) ?? await inferGitHubRepository(process.cwd(), dependencies.readGitOrigin);
|
|
3045
3699
|
const connection = await connectGitHubSecuritySource({
|
|
@@ -3054,7 +3708,7 @@ async function githubSecurityCommand(parsed, dependencies) {
|
|
|
3054
3708
|
context.stdout.log("github: odla.ai stores the installation; no PAT or GitHub token is written locally");
|
|
3055
3709
|
}
|
|
3056
3710
|
async function listSecuritySources(parsed, dependencies) {
|
|
3057
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json"], 2);
|
|
3711
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
|
|
3058
3712
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
3059
3713
|
const [plan, sources] = await Promise.all([
|
|
3060
3714
|
getHostedSecurityPlan(context),
|
|
@@ -3076,7 +3730,7 @@ source repository default ref status`);
|
|
|
3076
3730
|
}
|
|
3077
3731
|
}
|
|
3078
3732
|
async function securityStatus(parsed, dependencies) {
|
|
3079
|
-
assertArgs(parsed, ["config", "env", "platform", "open", "json", "follow"], 3);
|
|
3733
|
+
assertArgs(parsed, ["config", "env", "platform", "email", "open", "json", "follow"], 3);
|
|
3080
3734
|
const jobId = requiredSecurityPositional(parsed, 2, "job id");
|
|
3081
3735
|
const context = await hostedSecurityContext(parsed, dependencies);
|
|
3082
3736
|
const job = parsed.options.follow === true ? await followHostedSecurityJob({
|
|
@@ -3131,6 +3785,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3131
3785
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
3132
3786
|
return;
|
|
3133
3787
|
}
|
|
3788
|
+
if (command === "calendar") {
|
|
3789
|
+
await calendarCommand(parsed, dependencies);
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3134
3792
|
if (command === "capabilities") {
|
|
3135
3793
|
assertArgs(parsed, ["json"], 1);
|
|
3136
3794
|
printCapabilities(parsed.options.json === true);
|
|
@@ -3145,14 +3803,20 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3145
3803
|
return;
|
|
3146
3804
|
}
|
|
3147
3805
|
if (command === "provision") {
|
|
3148
|
-
await provisionCommand(parsed);
|
|
3806
|
+
await provisionCommand(parsed, dependencies);
|
|
3149
3807
|
return;
|
|
3150
3808
|
}
|
|
3151
3809
|
if (command === "smoke") {
|
|
3152
|
-
assertArgs(parsed, ["config", "env"], 1);
|
|
3810
|
+
assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
|
|
3153
3811
|
await smoke({
|
|
3154
3812
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3155
|
-
env: stringOpt(parsed.options.env)
|
|
3813
|
+
env: stringOpt(parsed.options.env),
|
|
3814
|
+
token: stringOpt(parsed.options.token),
|
|
3815
|
+
email: stringOpt(parsed.options.email),
|
|
3816
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3817
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3818
|
+
fetch: dependencies.fetch,
|
|
3819
|
+
stdout: dependencies.stdout
|
|
3156
3820
|
});
|
|
3157
3821
|
return;
|
|
3158
3822
|
}
|
|
@@ -3186,7 +3850,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3186
3850
|
if (command === "secrets") {
|
|
3187
3851
|
const sub = parsed.positionals[1];
|
|
3188
3852
|
if (sub === "set" || sub === "set-clerk-key") {
|
|
3189
|
-
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
|
|
3853
|
+
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "email", "yes"], sub === "set" ? 3 : 2);
|
|
3190
3854
|
const options = {
|
|
3191
3855
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3192
3856
|
name: parsed.positionals[2],
|
|
@@ -3194,6 +3858,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3194
3858
|
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
3195
3859
|
stdin: parsed.options.stdin === true,
|
|
3196
3860
|
token: stringOpt(parsed.options.token),
|
|
3861
|
+
email: stringOpt(parsed.options.email),
|
|
3197
3862
|
yes: parsed.options.yes === true,
|
|
3198
3863
|
fetch: dependencies.fetch,
|
|
3199
3864
|
stdout: dependencies.stdout
|
|
@@ -3217,7 +3882,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3217
3882
|
}
|
|
3218
3883
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
3219
3884
|
}
|
|
3220
|
-
async function provisionCommand(parsed) {
|
|
3885
|
+
async function provisionCommand(parsed, dependencies) {
|
|
3221
3886
|
assertArgs(parsed, [
|
|
3222
3887
|
"config",
|
|
3223
3888
|
"dry-run",
|
|
@@ -3227,6 +3892,7 @@ async function provisionCommand(parsed) {
|
|
|
3227
3892
|
"write-credentials",
|
|
3228
3893
|
"write-dev-vars",
|
|
3229
3894
|
"token",
|
|
3895
|
+
"email",
|
|
3230
3896
|
"open",
|
|
3231
3897
|
"yes"
|
|
3232
3898
|
], 1);
|
|
@@ -3240,11 +3906,41 @@ async function provisionCommand(parsed) {
|
|
|
3240
3906
|
writeCredentials: parsed.options["write-credentials"] !== false,
|
|
3241
3907
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
3242
3908
|
token: stringOpt(parsed.options.token),
|
|
3909
|
+
email: stringOpt(parsed.options.email),
|
|
3243
3910
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3244
|
-
yes: parsed.options.yes === true
|
|
3911
|
+
yes: parsed.options.yes === true,
|
|
3912
|
+
fetch: dependencies.fetch,
|
|
3913
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3914
|
+
stdout: dependencies.stdout
|
|
3245
3915
|
};
|
|
3246
3916
|
await provision(options);
|
|
3247
3917
|
}
|
|
3918
|
+
async function calendarCommand(parsed, dependencies) {
|
|
3919
|
+
const sub = parsed.positionals[1];
|
|
3920
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
|
|
3921
|
+
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3922
|
+
}
|
|
3923
|
+
assertArgs(parsed, ["config", "env", "json", "token", "email", "open", "yes"], 2);
|
|
3924
|
+
if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
|
|
3925
|
+
if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
|
|
3926
|
+
const options = {
|
|
3927
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3928
|
+
env: stringOpt(parsed.options.env),
|
|
3929
|
+
token: stringOpt(parsed.options.token),
|
|
3930
|
+
email: stringOpt(parsed.options.email),
|
|
3931
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3932
|
+
yes: parsed.options.yes === true,
|
|
3933
|
+
json: parsed.options.json === true,
|
|
3934
|
+
fetch: dependencies.fetch,
|
|
3935
|
+
stdout: dependencies.stdout,
|
|
3936
|
+
openConsentUrl: dependencies.openUrl
|
|
3937
|
+
};
|
|
3938
|
+
if (sub === "status") await calendarStatus(options);
|
|
3939
|
+
else if (sub === "calendars") await calendarCalendars(options);
|
|
3940
|
+
else if (sub === "connect") await calendarConnect(options);
|
|
3941
|
+
else if (sub === "resync") await calendarResync(options);
|
|
3942
|
+
else await calendarDisconnect(options);
|
|
3943
|
+
}
|
|
3248
3944
|
|
|
3249
3945
|
export {
|
|
3250
3946
|
getScopedPlatformToken,
|
|
@@ -3252,7 +3948,15 @@ export {
|
|
|
3252
3948
|
adminAi,
|
|
3253
3949
|
CAPABILITIES,
|
|
3254
3950
|
printCapabilities,
|
|
3951
|
+
GOOGLE_CALENDAR_READ_SCOPE,
|
|
3952
|
+
calendarServiceConfig,
|
|
3953
|
+
calendarBookingPageUrl,
|
|
3255
3954
|
redactSecrets,
|
|
3955
|
+
calendarStatus,
|
|
3956
|
+
calendarCalendars,
|
|
3957
|
+
calendarConnect,
|
|
3958
|
+
calendarResync,
|
|
3959
|
+
calendarDisconnect,
|
|
3256
3960
|
doctor,
|
|
3257
3961
|
initProject,
|
|
3258
3962
|
secretsPush,
|
|
@@ -3278,4 +3982,4 @@ export {
|
|
|
3278
3982
|
smoke,
|
|
3279
3983
|
runCli
|
|
3280
3984
|
};
|
|
3281
|
-
//# sourceMappingURL=chunk-
|
|
3985
|
+
//# sourceMappingURL=chunk-MWVKOIGR.js.map
|