@odla-ai/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -8
- package/REQUIREMENTS.md +14 -0
- package/dist/bin.cjs +806 -56
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-BHAEDEL2.js → chunk-WDKBW4BE.js} +824 -64
- package/dist/chunk-WDKBW4BE.js.map +1 -0
- package/dist/index.cjs +826 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +232 -282
- package/dist/index.d.ts +232 -282
- package/dist/index.js +21 -1
- package/llms.txt +315 -291
- package/package.json +3 -3
- package/skills/odla/SKILL.md +12 -3
- package/skills/odla/references/build.md +19 -0
- package/skills/odla/references/sdks.md +29 -0
- package/skills/odla-migrate/SKILL.md +8 -2
- package/skills/odla-migrate/references/phase-2-db.md +2 -2
- package/skills/odla-migrate/references/phase-2b-calendar.md +42 -0
- package/skills/odla-migrate/references/phase-3b-user-sync.md +15 -8
- package/skills/odla-migrate/references/secrets-map.md +16 -2
- package/dist/chunk-BHAEDEL2.js.map +0 -1
|
@@ -297,7 +297,30 @@ function requireSystemAiPurpose(value) {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
// src/admin-ai.ts
|
|
300
|
+
import process6 from "process";
|
|
301
|
+
|
|
302
|
+
// src/secret-input.ts
|
|
300
303
|
import process5 from "process";
|
|
304
|
+
var MAX_BYTES = 64 * 1024;
|
|
305
|
+
async function secretInputValue(options, kind = "credential") {
|
|
306
|
+
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
307
|
+
let value;
|
|
308
|
+
if (options.fromEnv) value = process5.env[options.fromEnv];
|
|
309
|
+
else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
|
|
310
|
+
else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
|
|
311
|
+
value = value?.replace(/[\r\n]+$/, "");
|
|
312
|
+
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : `stdin ${kind} is empty`);
|
|
313
|
+
if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
async function readSecretStream(kind, stream = process5.stdin) {
|
|
317
|
+
let value = "";
|
|
318
|
+
for await (const chunk of stream) {
|
|
319
|
+
value += String(chunk);
|
|
320
|
+
if (value.length > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
321
|
+
}
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
301
324
|
|
|
302
325
|
// src/admin-ai-audit.ts
|
|
303
326
|
function adminAiAuditQuery(filters) {
|
|
@@ -445,7 +468,7 @@ function isRecord2(value) {
|
|
|
445
468
|
|
|
446
469
|
// src/admin-ai.ts
|
|
447
470
|
async function adminAi(options) {
|
|
448
|
-
const platform = platformAudience(options.platform ??
|
|
471
|
+
const platform = platformAudience(options.platform ?? process6.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
449
472
|
const doFetch = options.fetch ?? fetch;
|
|
450
473
|
const out = options.stdout ?? console;
|
|
451
474
|
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
@@ -484,7 +507,7 @@ async function adminAi(options) {
|
|
|
484
507
|
}
|
|
485
508
|
if (options.action === "credential-set") {
|
|
486
509
|
const provider = requireProvider(options.credentialProvider);
|
|
487
|
-
const value = await
|
|
510
|
+
const value = await secretInputValue(options);
|
|
488
511
|
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
|
|
489
512
|
method: "PUT",
|
|
490
513
|
headers,
|
|
@@ -594,25 +617,6 @@ function requireProvider(value) {
|
|
|
594
617
|
}
|
|
595
618
|
return value;
|
|
596
619
|
}
|
|
597
|
-
async function credentialValue(options) {
|
|
598
|
-
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
599
|
-
let value;
|
|
600
|
-
if (options.fromEnv) value = process5.env[options.fromEnv];
|
|
601
|
-
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
602
|
-
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
603
|
-
value = value?.replace(/[\r\n]+$/, "");
|
|
604
|
-
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
|
|
605
|
-
if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
606
|
-
return value;
|
|
607
|
-
}
|
|
608
|
-
async function readStdin() {
|
|
609
|
-
let value = "";
|
|
610
|
-
for await (const chunk of process5.stdin) {
|
|
611
|
-
value += String(chunk);
|
|
612
|
-
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
613
|
-
}
|
|
614
|
-
return value;
|
|
615
|
-
}
|
|
616
620
|
function policyArray(body) {
|
|
617
621
|
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
618
622
|
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
@@ -648,7 +652,9 @@ var CAPABILITIES = {
|
|
|
648
652
|
"register the app and enable configured services per environment",
|
|
649
653
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
650
654
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
655
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
651
656
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
657
|
+
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
652
658
|
"validate config offline and smoke-test a provisioned db environment",
|
|
653
659
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
654
660
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
@@ -657,10 +663,12 @@ var CAPABILITIES = {
|
|
|
657
663
|
agent: [
|
|
658
664
|
"install and import the selected odla SDKs",
|
|
659
665
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
660
|
-
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
666
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
667
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
661
668
|
],
|
|
662
669
|
human: [
|
|
663
670
|
"approve the odla device code and one-off third-party logins",
|
|
671
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
664
672
|
"consent to production changes with --yes",
|
|
665
673
|
"request destructive credential rotation explicitly",
|
|
666
674
|
"review application semantics, security findings, releases, and merges",
|
|
@@ -669,6 +677,7 @@ var CAPABILITIES = {
|
|
|
669
677
|
],
|
|
670
678
|
studio: [
|
|
671
679
|
"view telemetry and environment state",
|
|
680
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
672
681
|
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
673
682
|
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
674
683
|
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
@@ -691,25 +700,6 @@ function printGroup(out, heading, items) {
|
|
|
691
700
|
out.log("");
|
|
692
701
|
}
|
|
693
702
|
|
|
694
|
-
// src/redact.ts
|
|
695
|
-
var REPLACEMENTS = [
|
|
696
|
-
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
697
|
-
[/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
|
|
698
|
-
[/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
|
|
699
|
-
[/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
|
|
700
|
-
[/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
|
|
701
|
-
[/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
|
|
702
|
-
[/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
|
|
703
|
-
];
|
|
704
|
-
function redactSecrets(value) {
|
|
705
|
-
let result = value;
|
|
706
|
-
for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
|
|
707
|
-
return result;
|
|
708
|
-
}
|
|
709
|
-
function looksSecret(value) {
|
|
710
|
-
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
711
|
-
}
|
|
712
|
-
|
|
713
703
|
// src/config.ts
|
|
714
704
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
715
705
|
import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
@@ -717,6 +707,7 @@ import { pathToFileURL } from "url";
|
|
|
717
707
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
718
708
|
var DEFAULT_ENVS = ["dev"];
|
|
719
709
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
710
|
+
var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
720
711
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
721
712
|
const resolved = resolve2(configPath);
|
|
722
713
|
if (!existsSync3(resolved)) {
|
|
@@ -729,6 +720,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
729
720
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
730
721
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
731
722
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
723
|
+
validateCalendarConfig(raw, envs, services, resolved);
|
|
732
724
|
const local = {
|
|
733
725
|
tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
734
726
|
credentialsFile: resolve2(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
@@ -773,6 +765,28 @@ function buildPlan(cfg) {
|
|
|
773
765
|
aiProvider: cfg.ai?.provider
|
|
774
766
|
};
|
|
775
767
|
}
|
|
768
|
+
function calendarServiceConfig(cfg, env) {
|
|
769
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
770
|
+
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
771
|
+
const google = cfg.calendar?.google;
|
|
772
|
+
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
773
|
+
return {
|
|
774
|
+
provider: "google",
|
|
775
|
+
access: "read",
|
|
776
|
+
calendars: unique(google.calendars[env].map((id) => id.trim())),
|
|
777
|
+
match: {
|
|
778
|
+
organizerSelf: google.match?.organizerSelf ?? true,
|
|
779
|
+
requireAttendees: google.match?.requireAttendees ?? true,
|
|
780
|
+
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
781
|
+
},
|
|
782
|
+
attendeePolicy: google.attendeePolicy ?? "full"
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function calendarBookingPageUrl(cfg, env) {
|
|
786
|
+
const value = cfg.calendar?.google.bookingPageUrl?.[env];
|
|
787
|
+
if (value === void 0 || value === null) return value;
|
|
788
|
+
return new URL(value).toString();
|
|
789
|
+
}
|
|
776
790
|
function rulesFromSchema(schema) {
|
|
777
791
|
const entities = serializedEntities(schema);
|
|
778
792
|
return Object.fromEntries(
|
|
@@ -796,7 +810,85 @@ function validateRawConfig(raw, path) {
|
|
|
796
810
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
797
811
|
if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
798
812
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
813
|
+
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
814
|
+
throw new Error(`${path}: envs must be an array of names`);
|
|
815
|
+
}
|
|
799
816
|
if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
817
|
+
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
818
|
+
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function validateCalendarConfig(cfg, envs, services, path) {
|
|
822
|
+
const enabled = services.includes("calendar");
|
|
823
|
+
if (!cfg.calendar) {
|
|
824
|
+
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
828
|
+
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
829
|
+
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
830
|
+
const google = cfg.calendar.google;
|
|
831
|
+
assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
|
|
832
|
+
if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
|
|
833
|
+
const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
|
|
834
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
|
|
835
|
+
for (const env of envs) {
|
|
836
|
+
const ids = google.calendars[env];
|
|
837
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
838
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
|
|
839
|
+
}
|
|
840
|
+
if (ids.length > 10) {
|
|
841
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
|
|
842
|
+
}
|
|
843
|
+
if (ids.some((id) => !safeText(id, 1024))) {
|
|
844
|
+
throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (google.bookingPageUrl !== void 0) {
|
|
848
|
+
if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
849
|
+
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
850
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
851
|
+
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
852
|
+
if (value !== null && !safeHttpsUrl(value)) {
|
|
853
|
+
throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
858
|
+
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
859
|
+
}
|
|
860
|
+
if (google.match !== void 0) {
|
|
861
|
+
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
862
|
+
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
863
|
+
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
864
|
+
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
865
|
+
}
|
|
866
|
+
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
867
|
+
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
868
|
+
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
873
|
+
}
|
|
874
|
+
function assertOnly(value, allowed, label) {
|
|
875
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
876
|
+
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
877
|
+
}
|
|
878
|
+
function isRecord4(value) {
|
|
879
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
880
|
+
}
|
|
881
|
+
function safeText(value, max) {
|
|
882
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
883
|
+
}
|
|
884
|
+
function safeHttpsUrl(value) {
|
|
885
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
886
|
+
try {
|
|
887
|
+
const url = new URL(value);
|
|
888
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
889
|
+
} catch {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
800
892
|
}
|
|
801
893
|
function validId(value) {
|
|
802
894
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
@@ -815,6 +907,440 @@ function unique(values) {
|
|
|
815
907
|
return [...new Set(values.filter(Boolean))];
|
|
816
908
|
}
|
|
817
909
|
|
|
910
|
+
// src/redact.ts
|
|
911
|
+
var REPLACEMENTS = [
|
|
912
|
+
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
913
|
+
[/\bsk_(live|test)_[A-Za-z0-9]+/g, "sk_$1_[redacted]"],
|
|
914
|
+
[/\bsk-[A-Za-z0-9._-]+/g, "sk-[redacted]"],
|
|
915
|
+
[/\bwhsec_[A-Za-z0-9+/=]+/g, "whsec_[redacted]"],
|
|
916
|
+
[/\bo11y_[A-Za-z0-9]+/g, "o11y_[redacted]"],
|
|
917
|
+
[/\b(ghp|gho|github_pat)_[A-Za-z0-9_]+/g, "$1_[redacted]"],
|
|
918
|
+
[/\bAKIA[A-Z0-9]{12,}/g, "AKIA[redacted]"]
|
|
919
|
+
];
|
|
920
|
+
function redactSecrets(value) {
|
|
921
|
+
let result = value;
|
|
922
|
+
for (const [pattern, replacement] of REPLACEMENTS) result = result.replace(pattern, replacement);
|
|
923
|
+
return result;
|
|
924
|
+
}
|
|
925
|
+
function looksSecret(value) {
|
|
926
|
+
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/calendar-http.ts
|
|
930
|
+
var CALENDAR_STATES = [
|
|
931
|
+
"not_connected",
|
|
932
|
+
"authorizing",
|
|
933
|
+
"needs_sync",
|
|
934
|
+
"initial_sync",
|
|
935
|
+
"healthy",
|
|
936
|
+
"degraded",
|
|
937
|
+
"disconnected",
|
|
938
|
+
"failed"
|
|
939
|
+
];
|
|
940
|
+
async function readCalendarStatus(ctx) {
|
|
941
|
+
return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
|
|
942
|
+
}
|
|
943
|
+
async function discoverGoogleCalendars(ctx) {
|
|
944
|
+
const raw = await calendarJson(ctx, "/calendars", {});
|
|
945
|
+
const value = record(raw);
|
|
946
|
+
if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
|
|
947
|
+
return value.calendars.map((item, index) => {
|
|
948
|
+
const calendar = record(item);
|
|
949
|
+
const id = textField(calendar?.id, 1024);
|
|
950
|
+
if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
|
|
951
|
+
const role = calendar.accessRole;
|
|
952
|
+
if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
|
|
953
|
+
throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
id,
|
|
957
|
+
...optionalText("summary", calendar.summary, 500),
|
|
958
|
+
...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
|
|
959
|
+
...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
|
|
960
|
+
...typeof role === "string" ? { accessRole: role } : {}
|
|
961
|
+
};
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
async function applyCalendarSettings(ctx, bookingPageUrl) {
|
|
965
|
+
return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
|
|
966
|
+
}
|
|
967
|
+
async function startCalendarConnection(ctx) {
|
|
968
|
+
const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
|
|
969
|
+
const value = wrapped(raw, "attempt");
|
|
970
|
+
const attemptId = textField(value.attemptId, 180);
|
|
971
|
+
const consentUrl = textField(value.consentUrl, 4096);
|
|
972
|
+
const expiresAt = timestamp3(value.expiresAt);
|
|
973
|
+
if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
|
|
974
|
+
return { attemptId, consentUrl, expiresAt };
|
|
975
|
+
}
|
|
976
|
+
async function pollCalendarConnection(ctx, attemptId) {
|
|
977
|
+
return parseCalendarStatus(
|
|
978
|
+
await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
|
|
979
|
+
ctx.env
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
async function requestCalendarResync(ctx) {
|
|
983
|
+
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
984
|
+
}
|
|
985
|
+
async function requestCalendarDisconnect(ctx) {
|
|
986
|
+
return parseCalendarStatus(
|
|
987
|
+
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
988
|
+
ctx.env
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
function parseCalendarStatus(raw, env) {
|
|
992
|
+
const outer = wrapped(raw, "calendar");
|
|
993
|
+
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
994
|
+
const connection = record(value.connection) ?? {};
|
|
995
|
+
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
996
|
+
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
997
|
+
const googleConfig = record(config.google) ?? config;
|
|
998
|
+
const watches = record(value.watches) ?? {};
|
|
999
|
+
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1000
|
+
if (!stateValue) {
|
|
1001
|
+
throw new Error("calendar status returned an invalid connection state");
|
|
1002
|
+
}
|
|
1003
|
+
const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
|
|
1004
|
+
if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
|
|
1005
|
+
throw new Error("calendar status returned an unsupported provider");
|
|
1006
|
+
}
|
|
1007
|
+
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1008
|
+
if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
|
|
1009
|
+
const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
|
|
1010
|
+
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1011
|
+
throw new Error("calendar status returned an invalid attendee policy");
|
|
1012
|
+
}
|
|
1013
|
+
const errorValue = record(value.error) ?? record(connection.error);
|
|
1014
|
+
const errorCode = textField(value.lastErrorCode, 128);
|
|
1015
|
+
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1016
|
+
return {
|
|
1017
|
+
env,
|
|
1018
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1019
|
+
connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
|
|
1020
|
+
provider: providerValue === "google" ? "google" : null,
|
|
1021
|
+
status: stateValue,
|
|
1022
|
+
...accessValue === "read" ? { access: "read" } : {},
|
|
1023
|
+
calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
|
|
1024
|
+
...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
|
|
1025
|
+
...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
|
|
1026
|
+
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1027
|
+
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1028
|
+
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1029
|
+
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1030
|
+
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1031
|
+
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1032
|
+
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1033
|
+
...errorValue || errorCode ? { error: {
|
|
1034
|
+
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1035
|
+
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
1036
|
+
...!errorValue && errorCode ? { code: errorCode } : {}
|
|
1037
|
+
} } : {}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
function calendarState(value) {
|
|
1041
|
+
if (typeof value !== "string") return null;
|
|
1042
|
+
if (CALENDAR_STATES.includes(value)) return value;
|
|
1043
|
+
const legacy = {
|
|
1044
|
+
disabled: "not_connected",
|
|
1045
|
+
disconnected: "disconnected",
|
|
1046
|
+
connecting: "authorizing",
|
|
1047
|
+
syncing: "initial_sync",
|
|
1048
|
+
ready: "healthy",
|
|
1049
|
+
error: "failed"
|
|
1050
|
+
};
|
|
1051
|
+
return legacy[value] ?? null;
|
|
1052
|
+
}
|
|
1053
|
+
async function calendarJson(ctx, suffix, init) {
|
|
1054
|
+
const platform = platformAudience(ctx.platform);
|
|
1055
|
+
const appId = identifier(ctx.appId, "appId");
|
|
1056
|
+
const env = identifier(ctx.env, "env");
|
|
1057
|
+
const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
|
|
1058
|
+
url.searchParams.set("env", env);
|
|
1059
|
+
const token = credential(ctx.token);
|
|
1060
|
+
let response;
|
|
1061
|
+
try {
|
|
1062
|
+
response = await (ctx.fetch ?? fetch)(url, {
|
|
1063
|
+
...init,
|
|
1064
|
+
redirect: "error",
|
|
1065
|
+
credentials: "omit",
|
|
1066
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
|
|
1067
|
+
});
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1070
|
+
}
|
|
1071
|
+
const body = await response.json().catch(() => ({}));
|
|
1072
|
+
if (!response.ok) {
|
|
1073
|
+
const code = textField(body.error?.code, 128);
|
|
1074
|
+
const message = textField(body.error?.message, 500);
|
|
1075
|
+
throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
|
|
1076
|
+
}
|
|
1077
|
+
return body;
|
|
1078
|
+
}
|
|
1079
|
+
function wrapped(raw, key) {
|
|
1080
|
+
const outer = record(raw);
|
|
1081
|
+
if (!outer) throw new Error("calendar returned an invalid response");
|
|
1082
|
+
return record(outer[key]) ?? outer;
|
|
1083
|
+
}
|
|
1084
|
+
function record(value) {
|
|
1085
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1086
|
+
}
|
|
1087
|
+
function textField(value, max) {
|
|
1088
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
|
|
1089
|
+
}
|
|
1090
|
+
function stringList(value) {
|
|
1091
|
+
return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
|
|
1092
|
+
}
|
|
1093
|
+
function calendarIds(value) {
|
|
1094
|
+
if (!Array.isArray(value)) return [];
|
|
1095
|
+
return [...new Set(value.flatMap((item) => {
|
|
1096
|
+
if (typeof item === "string") return textField(item, 4096) ? [item] : [];
|
|
1097
|
+
const calendar = record(item);
|
|
1098
|
+
const id = textField(calendar?.id, 4096);
|
|
1099
|
+
return id && calendar?.selected !== false ? [id] : [];
|
|
1100
|
+
}))];
|
|
1101
|
+
}
|
|
1102
|
+
function timestamp3(value) {
|
|
1103
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
1104
|
+
if (typeof value === "string") {
|
|
1105
|
+
const parsed = Date.parse(value);
|
|
1106
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
1107
|
+
}
|
|
1108
|
+
return void 0;
|
|
1109
|
+
}
|
|
1110
|
+
function optionalText(key, value, max) {
|
|
1111
|
+
const parsed = textField(value, max);
|
|
1112
|
+
return parsed ? { [key]: parsed } : {};
|
|
1113
|
+
}
|
|
1114
|
+
function optionalNumber(key, value) {
|
|
1115
|
+
const parsed = timestamp3(value);
|
|
1116
|
+
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1117
|
+
}
|
|
1118
|
+
function optionalInteger(key, value) {
|
|
1119
|
+
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1120
|
+
}
|
|
1121
|
+
function optionalNullableUrl(key, value) {
|
|
1122
|
+
if (value === null) return { [key]: null };
|
|
1123
|
+
const parsed = textField(value, 4096);
|
|
1124
|
+
return parsed ? { [key]: parsed } : {};
|
|
1125
|
+
}
|
|
1126
|
+
function identifier(value, name) {
|
|
1127
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
1128
|
+
return value;
|
|
1129
|
+
}
|
|
1130
|
+
function credential(value) {
|
|
1131
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1132
|
+
throw new Error("calendar requires an odla developer token");
|
|
1133
|
+
}
|
|
1134
|
+
return value;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/calendar-poll.ts
|
|
1138
|
+
async function waitForCalendarPoll(milliseconds, signal) {
|
|
1139
|
+
if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
|
|
1140
|
+
await new Promise((resolve7, reject) => {
|
|
1141
|
+
const timer = setTimeout(resolve7, milliseconds);
|
|
1142
|
+
signal?.addEventListener("abort", () => {
|
|
1143
|
+
clearTimeout(timer);
|
|
1144
|
+
reject(signal.reason ?? new Error("calendar connection aborted"));
|
|
1145
|
+
}, { once: true });
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/calendar.ts
|
|
1150
|
+
async function calendarStatus(options) {
|
|
1151
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1152
|
+
const status = await readCalendarStatus(ctx);
|
|
1153
|
+
printStatus(status, options.json === true, out);
|
|
1154
|
+
return status;
|
|
1155
|
+
}
|
|
1156
|
+
async function calendarCalendars(options) {
|
|
1157
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1158
|
+
const calendars = await discoverGoogleCalendars(ctx);
|
|
1159
|
+
if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
|
|
1160
|
+
else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
|
|
1161
|
+
else for (const calendar of calendars) {
|
|
1162
|
+
out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
|
|
1163
|
+
}
|
|
1164
|
+
return calendars;
|
|
1165
|
+
}
|
|
1166
|
+
async function calendarConnect(options) {
|
|
1167
|
+
const { cfg, ctx, out } = await lifecycleContext(options);
|
|
1168
|
+
productionConsent(ctx.env, options.yes, "connect calendar");
|
|
1169
|
+
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1170
|
+
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1171
|
+
const connectOptions = connectionOptions(options, out);
|
|
1172
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
|
|
1173
|
+
}
|
|
1174
|
+
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1175
|
+
if (bookingPageUrl === void 0) return null;
|
|
1176
|
+
const status = await applyCalendarSettings(ctx, bookingPageUrl);
|
|
1177
|
+
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1178
|
+
return status;
|
|
1179
|
+
}
|
|
1180
|
+
async function calendarResync(options) {
|
|
1181
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1182
|
+
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1183
|
+
const status = await requestCalendarResync(ctx);
|
|
1184
|
+
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1185
|
+
return status;
|
|
1186
|
+
}
|
|
1187
|
+
async function calendarDisconnect(options) {
|
|
1188
|
+
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1189
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1190
|
+
const status = await requestCalendarDisconnect(ctx);
|
|
1191
|
+
out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
|
|
1192
|
+
return status;
|
|
1193
|
+
}
|
|
1194
|
+
async function ensureCalendarConnected(ctx, options) {
|
|
1195
|
+
const out = options.stdout ?? console;
|
|
1196
|
+
const current = await readCalendarStatus(ctx);
|
|
1197
|
+
const connectOptions = connectionOptions(options, out);
|
|
1198
|
+
return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
|
|
1199
|
+
}
|
|
1200
|
+
async function lifecycleContext(options) {
|
|
1201
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1202
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
1203
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1204
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1205
|
+
calendarServiceConfig(cfg, env);
|
|
1206
|
+
const out = options.stdout ?? console;
|
|
1207
|
+
const doFetch = options.fetch ?? fetch;
|
|
1208
|
+
const token = await getDeveloperToken(
|
|
1209
|
+
cfg,
|
|
1210
|
+
{
|
|
1211
|
+
configPath: cfg.configPath,
|
|
1212
|
+
token: options.token,
|
|
1213
|
+
open: options.open,
|
|
1214
|
+
interactive: options.interactive,
|
|
1215
|
+
openApprovalUrl: options.openConsentUrl
|
|
1216
|
+
},
|
|
1217
|
+
doFetch,
|
|
1218
|
+
out
|
|
1219
|
+
);
|
|
1220
|
+
return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
|
|
1221
|
+
}
|
|
1222
|
+
function connectionOptions(options, out) {
|
|
1223
|
+
const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
|
|
1224
|
+
return {
|
|
1225
|
+
out,
|
|
1226
|
+
open: browser.open,
|
|
1227
|
+
openConsentUrl: options.openConsentUrl,
|
|
1228
|
+
wait: options.wait,
|
|
1229
|
+
pollTimeoutMs: options.pollTimeoutMs,
|
|
1230
|
+
now: options.now,
|
|
1231
|
+
signal: options.signal
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
1235
|
+
if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
|
|
1236
|
+
options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
|
|
1237
|
+
return current;
|
|
1238
|
+
}
|
|
1239
|
+
if (current.status === "degraded") return null;
|
|
1240
|
+
if (current.status === "needs_sync") {
|
|
1241
|
+
if (!current.connected) return null;
|
|
1242
|
+
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1243
|
+
const synced = await requestCalendarResync(ctx);
|
|
1244
|
+
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1245
|
+
return synced;
|
|
1246
|
+
}
|
|
1247
|
+
if (current.status === "failed" && current.connected) {
|
|
1248
|
+
const reason = current.error?.message ?? current.error?.code;
|
|
1249
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1250
|
+
}
|
|
1251
|
+
const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
|
|
1252
|
+
if (!waitingForExisting) return null;
|
|
1253
|
+
const now = options.now ?? Date.now;
|
|
1254
|
+
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1255
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1256
|
+
let prior = "";
|
|
1257
|
+
while (now() < deadline) {
|
|
1258
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1259
|
+
const status = await readCalendarStatus(ctx);
|
|
1260
|
+
if (status.status !== prior) {
|
|
1261
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1262
|
+
prior = status.status;
|
|
1263
|
+
}
|
|
1264
|
+
if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
|
|
1265
|
+
if (status.status === "degraded") return null;
|
|
1266
|
+
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1267
|
+
if (status.status === "failed" && status.connected) {
|
|
1268
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1269
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1270
|
+
}
|
|
1271
|
+
if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
|
|
1272
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1273
|
+
}
|
|
1274
|
+
throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
|
|
1275
|
+
}
|
|
1276
|
+
async function connectWithContext(ctx, options) {
|
|
1277
|
+
const attempt = await startCalendarConnection(ctx);
|
|
1278
|
+
const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
|
|
1279
|
+
options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
|
|
1280
|
+
if (options.open) {
|
|
1281
|
+
await (options.openConsentUrl ?? openUrl)(consentUrl);
|
|
1282
|
+
options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
|
|
1283
|
+
}
|
|
1284
|
+
const now = options.now ?? Date.now;
|
|
1285
|
+
const timeout = pollTimeout(options.pollTimeoutMs);
|
|
1286
|
+
const deadline = Math.min(attempt.expiresAt, now() + timeout);
|
|
1287
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1288
|
+
let prior = "";
|
|
1289
|
+
while (now() < deadline) {
|
|
1290
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1291
|
+
const status = await pollCalendarConnection(ctx, attempt.attemptId);
|
|
1292
|
+
if (status.status !== prior) {
|
|
1293
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1294
|
+
prior = status.status;
|
|
1295
|
+
}
|
|
1296
|
+
if (status.status === "healthy" || status.status === "degraded") return status;
|
|
1297
|
+
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1298
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1299
|
+
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1300
|
+
}
|
|
1301
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1302
|
+
}
|
|
1303
|
+
throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
|
|
1304
|
+
}
|
|
1305
|
+
function trustedCalendarConsentUrl(platform, value) {
|
|
1306
|
+
const origin = platformAudience(platform);
|
|
1307
|
+
let url;
|
|
1308
|
+
try {
|
|
1309
|
+
url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
|
|
1310
|
+
} catch {
|
|
1311
|
+
throw new Error("odla.ai returned an invalid calendar consent URL");
|
|
1312
|
+
}
|
|
1313
|
+
const platformPage = url.origin === origin;
|
|
1314
|
+
const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
1315
|
+
if (!platformPage && !googlePage || url.username || url.password || url.hash) {
|
|
1316
|
+
throw new Error("odla.ai returned an untrusted calendar consent URL");
|
|
1317
|
+
}
|
|
1318
|
+
return url.toString();
|
|
1319
|
+
}
|
|
1320
|
+
function pollTimeout(value = 10 * 6e4) {
|
|
1321
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
|
|
1322
|
+
return value;
|
|
1323
|
+
}
|
|
1324
|
+
function productionConsent(env, yes, action) {
|
|
1325
|
+
if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
|
|
1326
|
+
}
|
|
1327
|
+
function printStatus(status, json, out) {
|
|
1328
|
+
if (json) {
|
|
1329
|
+
out.log(JSON.stringify(status, null, 2));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1333
|
+
out.log(` access: ${status.access ?? "not granted"}`);
|
|
1334
|
+
out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1335
|
+
if (status.attendeePolicy) {
|
|
1336
|
+
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1337
|
+
}
|
|
1338
|
+
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1339
|
+
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1340
|
+
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1341
|
+
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
818
1344
|
// src/doctor-checks.ts
|
|
819
1345
|
import { execFileSync } from "child_process";
|
|
820
1346
|
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
@@ -1000,6 +1526,15 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1000
1526
|
}
|
|
1001
1527
|
return warnings;
|
|
1002
1528
|
}
|
|
1529
|
+
function calendarProjectWarnings(rootDir) {
|
|
1530
|
+
const pkg = readPackageJson(rootDir);
|
|
1531
|
+
const dependencies = pkg?.dependencies;
|
|
1532
|
+
const devDependencies = pkg?.devDependencies;
|
|
1533
|
+
if (dependencies?.["@odla-ai/calendar"]) return [];
|
|
1534
|
+
return [
|
|
1535
|
+
devDependencies?.["@odla-ai/calendar"] ? "@odla-ai/calendar is a devDependency \u2014 move it to dependencies so trusted Worker code can import it" : "calendar service is enabled but @odla-ai/calendar is not installed in dependencies"
|
|
1536
|
+
];
|
|
1537
|
+
}
|
|
1003
1538
|
function readPackageJson(rootDir) {
|
|
1004
1539
|
try {
|
|
1005
1540
|
return JSON.parse(readFileSync4(join2(rootDir, "package.json"), "utf8"));
|
|
@@ -1025,6 +1560,14 @@ async function doctor(options) {
|
|
|
1025
1560
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1026
1561
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1027
1562
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1563
|
+
if (cfg.services.includes("calendar")) {
|
|
1564
|
+
const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
|
|
1565
|
+
out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
|
|
1566
|
+
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1567
|
+
out.log(`booking pages: ${pages}`);
|
|
1568
|
+
} else {
|
|
1569
|
+
out.log("calendar: not enabled");
|
|
1570
|
+
}
|
|
1028
1571
|
const warnings = [];
|
|
1029
1572
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
1030
1573
|
if (rules) {
|
|
@@ -1052,6 +1595,8 @@ async function doctor(options) {
|
|
|
1052
1595
|
}
|
|
1053
1596
|
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
1054
1597
|
}
|
|
1598
|
+
if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
|
|
1599
|
+
else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
|
|
1055
1600
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
1056
1601
|
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
1057
1602
|
cfg.local.tokenFile,
|
|
@@ -1083,6 +1628,7 @@ function initProject(options) {
|
|
|
1083
1628
|
}
|
|
1084
1629
|
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
1085
1630
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1631
|
+
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1086
1632
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1087
1633
|
mkdirSync2(dirname3(configPath), { recursive: true });
|
|
1088
1634
|
mkdirSync2(resolve4(rootDir, "src/odla"), { recursive: true });
|
|
@@ -1100,6 +1646,16 @@ function writeIfMissing(path, text) {
|
|
|
1100
1646
|
writeFileSync2(path, text);
|
|
1101
1647
|
}
|
|
1102
1648
|
function configTemplate(input) {
|
|
1649
|
+
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1650
|
+
google: {
|
|
1651
|
+
calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1652
|
+
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1653
|
+
match: { organizerSelf: true, requireAttendees: true },
|
|
1654
|
+
attendeePolicy: "full",
|
|
1655
|
+
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
` : "";
|
|
1103
1659
|
return `export default {
|
|
1104
1660
|
platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
|
|
1105
1661
|
dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
|
|
@@ -1121,6 +1677,7 @@ function configTemplate(input) {
|
|
|
1121
1677
|
// key in the platform vault for each tenant.
|
|
1122
1678
|
keyEnv: "${defaultKeyEnv(input.aiProvider)}",
|
|
1123
1679
|
},
|
|
1680
|
+
${calendar}
|
|
1124
1681
|
auth: {
|
|
1125
1682
|
clerk: {
|
|
1126
1683
|
// dev: "$CLERK_PUBLISHABLE_KEY",
|
|
@@ -1259,7 +1816,7 @@ function assertWranglerConfig(cfg) {
|
|
|
1259
1816
|
// src/provision.ts
|
|
1260
1817
|
import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
|
|
1261
1818
|
import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
|
|
1262
|
-
import
|
|
1819
|
+
import process7 from "process";
|
|
1263
1820
|
|
|
1264
1821
|
// src/provision-credentials.ts
|
|
1265
1822
|
import { tenantIdFor } from "@odla-ai/apps";
|
|
@@ -1320,14 +1877,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1320
1877
|
appId: tenantId
|
|
1321
1878
|
})
|
|
1322
1879
|
});
|
|
1323
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
1880
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
|
|
1324
1881
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1325
1882
|
method: "POST",
|
|
1326
1883
|
headers,
|
|
1327
1884
|
body: "{}"
|
|
1328
1885
|
});
|
|
1329
1886
|
}
|
|
1330
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
1887
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1331
1888
|
const body = await res.json();
|
|
1332
1889
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1333
1890
|
return body.key;
|
|
@@ -1343,12 +1900,12 @@ async function issueO11yToken(opts) {
|
|
|
1343
1900
|
`o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
|
|
1344
1901
|
);
|
|
1345
1902
|
}
|
|
1346
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
1903
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1347
1904
|
const body = await res.json();
|
|
1348
1905
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1349
1906
|
return body.token;
|
|
1350
1907
|
}
|
|
1351
|
-
async function
|
|
1908
|
+
async function safeText2(res) {
|
|
1352
1909
|
try {
|
|
1353
1910
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1354
1911
|
} catch {
|
|
@@ -1388,6 +1945,14 @@ async function provision(options) {
|
|
|
1388
1945
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1389
1946
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
1390
1947
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1948
|
+
if (cfg.services.includes("calendar")) {
|
|
1949
|
+
for (const env of cfg.envs) {
|
|
1950
|
+
const calendar = calendarServiceConfig(cfg, env);
|
|
1951
|
+
out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
|
|
1952
|
+
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
1953
|
+
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1391
1956
|
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
1392
1957
|
return;
|
|
1393
1958
|
}
|
|
@@ -1422,8 +1987,9 @@ async function provision(options) {
|
|
|
1422
1987
|
await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
|
|
1423
1988
|
out.log(`app: created ${cfg.app.id}`);
|
|
1424
1989
|
}
|
|
1990
|
+
const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
|
|
1425
1991
|
for (const env of cfg.envs) {
|
|
1426
|
-
for (const service of
|
|
1992
|
+
for (const service of serviceOrder) {
|
|
1427
1993
|
if (service === "ai") {
|
|
1428
1994
|
if (cfg.ai?.provider) {
|
|
1429
1995
|
await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
|
|
@@ -1433,7 +1999,8 @@ async function provision(options) {
|
|
|
1433
1999
|
out.log(`${env}: ai enabled`);
|
|
1434
2000
|
}
|
|
1435
2001
|
} else {
|
|
1436
|
-
|
|
2002
|
+
const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
|
|
2003
|
+
await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
|
|
1437
2004
|
out.log(`${env}: ${service} enabled`);
|
|
1438
2005
|
}
|
|
1439
2006
|
}
|
|
@@ -1447,6 +2014,21 @@ async function provision(options) {
|
|
|
1447
2014
|
await apps.setLink(cfg.app.id, env, link ?? null);
|
|
1448
2015
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
1449
2016
|
}
|
|
2017
|
+
if (cfg.services.includes("calendar")) {
|
|
2018
|
+
const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
|
|
2019
|
+
await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
|
|
2020
|
+
await ensureCalendarConnected(
|
|
2021
|
+
calendarCtx,
|
|
2022
|
+
{
|
|
2023
|
+
open: options.open,
|
|
2024
|
+
interactive: options.interactive,
|
|
2025
|
+
openConsentUrl: options.openApprovalUrl,
|
|
2026
|
+
wait: options.calendarPollWait,
|
|
2027
|
+
pollTimeoutMs: options.calendarPollTimeoutMs,
|
|
2028
|
+
stdout: out
|
|
2029
|
+
}
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
1450
2032
|
}
|
|
1451
2033
|
for (const env of cfg.envs) {
|
|
1452
2034
|
const tenantId = tenantIdFor2(cfg.app.id, env);
|
|
@@ -1490,7 +2072,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1490
2072
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1491
2073
|
}
|
|
1492
2074
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1493
|
-
const key =
|
|
2075
|
+
const key = process7.env[cfg.ai.keyEnv];
|
|
1494
2076
|
if (key) {
|
|
1495
2077
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1496
2078
|
await putSecret({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1510,6 +2092,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1510
2092
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
1511
2093
|
}
|
|
1512
2094
|
}
|
|
2095
|
+
function serviceRank(service) {
|
|
2096
|
+
if (service === "db") return 0;
|
|
2097
|
+
if (service === "calendar") return 2;
|
|
2098
|
+
return 1;
|
|
2099
|
+
}
|
|
1513
2100
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
1514
2101
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
1515
2102
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -1525,7 +2112,7 @@ async function postJson(doFetch, url, bearer, body) {
|
|
|
1525
2112
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1526
2113
|
body: JSON.stringify(body)
|
|
1527
2114
|
});
|
|
1528
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2115
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
|
|
1529
2116
|
}
|
|
1530
2117
|
function normalizeClerkConfig(value) {
|
|
1531
2118
|
if (!value) return null;
|
|
@@ -1542,7 +2129,7 @@ function defaultSecretName(provider) {
|
|
|
1542
2129
|
const names = DEFAULT_SECRET_NAMES;
|
|
1543
2130
|
return names[provider] ?? `${provider}_api_key`;
|
|
1544
2131
|
}
|
|
1545
|
-
async function
|
|
2132
|
+
async function safeText3(res) {
|
|
1546
2133
|
try {
|
|
1547
2134
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1548
2135
|
} catch {
|
|
@@ -1550,6 +2137,63 @@ async function safeText2(res) {
|
|
|
1550
2137
|
}
|
|
1551
2138
|
}
|
|
1552
2139
|
|
|
2140
|
+
// src/secrets-set.ts
|
|
2141
|
+
import { putSecret as putSecret2 } from "@odla-ai/ai";
|
|
2142
|
+
import { tenantIdFor as tenantIdFor3 } from "@odla-ai/apps";
|
|
2143
|
+
var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2144
|
+
async function secretsSet(options) {
|
|
2145
|
+
const name = (options.name ?? "").trim();
|
|
2146
|
+
if (!name) throw new Error('secret name is required, e.g. "odla-ai secrets set clerk_webhook_secret --env dev --stdin"');
|
|
2147
|
+
if (name.startsWith("$")) {
|
|
2148
|
+
throw new Error('"$"-prefixed vault names are platform-reserved; for the Clerk secret key use "odla-ai secrets set-clerk-key"');
|
|
2149
|
+
}
|
|
2150
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2151
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2152
|
+
try {
|
|
2153
|
+
await putSecret2({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
|
|
2156
|
+
}
|
|
2157
|
+
out.log(`${name} stored in the ${tenantId} vault (write-only; the value was never echoed and cannot be read back here)`);
|
|
2158
|
+
}
|
|
2159
|
+
async function secretsSetClerkKey(options) {
|
|
2160
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2161
|
+
if (!value.startsWith("sk_")) throw new Error("the Clerk secret key must start with sk_ (sk_test_\u2026 or sk_live_\u2026)");
|
|
2162
|
+
if (value.startsWith("sk_test_") && PROD_ENV_NAMES2.has(options.env)) {
|
|
2163
|
+
throw new Error(`refusing to store an sk_test_ Clerk key for "${options.env}" \u2014 use the production instance's sk_live_ key`);
|
|
2164
|
+
}
|
|
2165
|
+
if (value.startsWith("sk_live_") && !PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2166
|
+
throw new Error(`refusing to store an sk_live_ Clerk key for "${options.env}" without --yes (live users would sync into a non-prod tenant)`);
|
|
2167
|
+
}
|
|
2168
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2169
|
+
const res = await doFetch(`${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/clerk-secret`, {
|
|
2170
|
+
method: "POST",
|
|
2171
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2172
|
+
body: JSON.stringify({ value })
|
|
2173
|
+
});
|
|
2174
|
+
if (!res.ok) {
|
|
2175
|
+
const text = scrubValue((await res.text().catch(() => "")).slice(0, 300), value);
|
|
2176
|
+
throw new Error(`store Clerk secret key failed (${res.status}): ${text || "request failed"}`);
|
|
2177
|
+
}
|
|
2178
|
+
out.log(`Clerk secret key stored for ${tenantId} ($clerk_secret, reserved + write-only; the value was never echoed)`);
|
|
2179
|
+
}
|
|
2180
|
+
function scrubValue(text, value) {
|
|
2181
|
+
return redactSecrets(text).split(value).join("[value redacted]");
|
|
2182
|
+
}
|
|
2183
|
+
async function resolveVaultWrite(options) {
|
|
2184
|
+
const out = options.stdout ?? console;
|
|
2185
|
+
const doFetch = options.fetch ?? fetch;
|
|
2186
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
2187
|
+
if (!cfg.envs.includes(options.env)) {
|
|
2188
|
+
throw new Error(`env "${options.env}" is not in config envs (${cfg.envs.join(", ")})`);
|
|
2189
|
+
}
|
|
2190
|
+
if (PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2191
|
+
throw new Error(`refusing to store a secret for "${options.env}" without --yes`);
|
|
2192
|
+
}
|
|
2193
|
+
const value = await secretInputValue(options, "secret");
|
|
2194
|
+
return { cfg, tenantId: tenantIdFor3(cfg.app.id, options.env), value, doFetch, out };
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1553
2197
|
// src/security.ts
|
|
1554
2198
|
import { isAbsolute as isAbsolute3, relative as relative2, resolve as resolve5, sep } from "path";
|
|
1555
2199
|
import {
|
|
@@ -2320,6 +2964,23 @@ async function smoke(options) {
|
|
|
2320
2964
|
}
|
|
2321
2965
|
out.log(` ai: ${provider}`);
|
|
2322
2966
|
}
|
|
2967
|
+
if (cfg.services.includes("calendar")) {
|
|
2968
|
+
const token = await getDeveloperToken(
|
|
2969
|
+
cfg,
|
|
2970
|
+
{
|
|
2971
|
+
configPath: cfg.configPath,
|
|
2972
|
+
token: options.token,
|
|
2973
|
+
open: options.open,
|
|
2974
|
+
interactive: options.interactive,
|
|
2975
|
+
openApprovalUrl: options.openApprovalUrl
|
|
2976
|
+
},
|
|
2977
|
+
doFetch,
|
|
2978
|
+
out
|
|
2979
|
+
);
|
|
2980
|
+
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
2981
|
+
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
2982
|
+
out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
2983
|
+
}
|
|
2323
2984
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
2324
2985
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
2325
2986
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
@@ -2341,13 +3002,35 @@ async function smoke(options) {
|
|
|
2341
3002
|
} else {
|
|
2342
3003
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
2343
3004
|
}
|
|
3005
|
+
if (cfg.services.includes("calendar")) {
|
|
3006
|
+
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3007
|
+
ns: "$bookings",
|
|
3008
|
+
aggregate: { count: true }
|
|
3009
|
+
});
|
|
3010
|
+
const count = bookings.aggregate?.count;
|
|
3011
|
+
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3012
|
+
}
|
|
2344
3013
|
out.log("ok");
|
|
2345
3014
|
}
|
|
3015
|
+
function assertCalendarHealthy(status, expected) {
|
|
3016
|
+
if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
|
|
3017
|
+
if (status.status !== "healthy") {
|
|
3018
|
+
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3019
|
+
}
|
|
3020
|
+
if (status.access !== "read") throw new Error("calendar connection is not read-only");
|
|
3021
|
+
const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
|
|
3022
|
+
if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
|
|
3023
|
+
const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
|
|
3024
|
+
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3025
|
+
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3026
|
+
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3027
|
+
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3028
|
+
}
|
|
2346
3029
|
async function getJson(doFetch, url, bearer) {
|
|
2347
3030
|
const res = await doFetch(url, {
|
|
2348
3031
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
2349
3032
|
});
|
|
2350
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3033
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
2351
3034
|
return res.json();
|
|
2352
3035
|
}
|
|
2353
3036
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -2356,7 +3039,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
2356
3039
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2357
3040
|
body: JSON.stringify(body)
|
|
2358
3041
|
});
|
|
2359
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3042
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
2360
3043
|
return res.json();
|
|
2361
3044
|
}
|
|
2362
3045
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -2364,7 +3047,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
2364
3047
|
url.searchParams.set("env", env);
|
|
2365
3048
|
return url.toString();
|
|
2366
3049
|
}
|
|
2367
|
-
async function
|
|
3050
|
+
async function safeText4(res) {
|
|
2368
3051
|
try {
|
|
2369
3052
|
return (await res.text()).slice(0, 500);
|
|
2370
3053
|
} catch {
|
|
@@ -2517,8 +3200,13 @@ function printHelp() {
|
|
|
2517
3200
|
|
|
2518
3201
|
Usage:
|
|
2519
3202
|
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2520
|
-
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
3203
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
|
|
2521
3204
|
odla-ai doctor [--config odla.config.mjs]
|
|
3205
|
+
odla-ai calendar status [--env dev] [--json]
|
|
3206
|
+
odla-ai calendar calendars [--env dev] [--json]
|
|
3207
|
+
odla-ai calendar connect [--env dev] [--no-open] [--yes]
|
|
3208
|
+
odla-ai calendar resync [--env dev] [--yes]
|
|
3209
|
+
odla-ai calendar disconnect [--env dev] --yes
|
|
2522
3210
|
odla-ai capabilities [--json]
|
|
2523
3211
|
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
2524
3212
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -2539,15 +3227,18 @@ Usage:
|
|
|
2539
3227
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
2540
3228
|
odla-ai security run [target] --self --ack-redacted-source
|
|
2541
3229
|
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
2542
|
-
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
3230
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
|
|
2543
3231
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
2544
3232
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
3233
|
+
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
3234
|
+
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
2545
3235
|
odla-ai version
|
|
2546
3236
|
|
|
2547
3237
|
Commands:
|
|
2548
3238
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
2549
3239
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
2550
3240
|
doctor Validate and summarize the project config without network calls.
|
|
3241
|
+
calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
|
|
2551
3242
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
2552
3243
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
2553
3244
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -2555,7 +3246,9 @@ Commands:
|
|
|
2555
3246
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
2556
3247
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
2557
3248
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
2558
|
-
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
3249
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
3250
|
+
stdin; set stores a tenant-vault secret and set-clerk-key the
|
|
3251
|
+
reserved Clerk secret key, write-only from stdin or an env var.
|
|
2559
3252
|
version Print the CLI version.
|
|
2560
3253
|
|
|
2561
3254
|
Safety:
|
|
@@ -2566,6 +3259,8 @@ Safety:
|
|
|
2566
3259
|
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
2567
3260
|
Provision opens the approval page automatically in interactive terminals;
|
|
2568
3261
|
use --open to force browser launch or --no-open to suppress it.
|
|
3262
|
+
Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
|
|
3263
|
+
OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
|
|
2569
3264
|
GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
|
|
2570
3265
|
for a PAT or provider key. GitHub read access is separate from the explicit
|
|
2571
3266
|
--ack-redacted-source consent and the exact --plan-digest printed by security
|
|
@@ -3065,6 +3760,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3065
3760
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
3066
3761
|
return;
|
|
3067
3762
|
}
|
|
3763
|
+
if (command === "calendar") {
|
|
3764
|
+
await calendarCommand(parsed, dependencies);
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3068
3767
|
if (command === "capabilities") {
|
|
3069
3768
|
assertArgs(parsed, ["json"], 1);
|
|
3070
3769
|
printCapabilities(parsed.options.json === true);
|
|
@@ -3079,14 +3778,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3079
3778
|
return;
|
|
3080
3779
|
}
|
|
3081
3780
|
if (command === "provision") {
|
|
3082
|
-
await provisionCommand(parsed);
|
|
3781
|
+
await provisionCommand(parsed, dependencies);
|
|
3083
3782
|
return;
|
|
3084
3783
|
}
|
|
3085
3784
|
if (command === "smoke") {
|
|
3086
|
-
assertArgs(parsed, ["config", "env"], 1);
|
|
3785
|
+
assertArgs(parsed, ["config", "env", "token", "open"], 1);
|
|
3087
3786
|
await smoke({
|
|
3088
3787
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3089
|
-
env: stringOpt(parsed.options.env)
|
|
3788
|
+
env: stringOpt(parsed.options.env),
|
|
3789
|
+
token: stringOpt(parsed.options.token),
|
|
3790
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3791
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3792
|
+
fetch: dependencies.fetch,
|
|
3793
|
+
stdout: dependencies.stdout
|
|
3090
3794
|
});
|
|
3091
3795
|
return;
|
|
3092
3796
|
}
|
|
@@ -3119,8 +3823,26 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3119
3823
|
}
|
|
3120
3824
|
if (command === "secrets") {
|
|
3121
3825
|
const sub = parsed.positionals[1];
|
|
3826
|
+
if (sub === "set" || sub === "set-clerk-key") {
|
|
3827
|
+
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
|
|
3828
|
+
const options = {
|
|
3829
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3830
|
+
name: parsed.positionals[2],
|
|
3831
|
+
env: requiredString(parsed.options.env, "--env"),
|
|
3832
|
+
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
3833
|
+
stdin: parsed.options.stdin === true,
|
|
3834
|
+
token: stringOpt(parsed.options.token),
|
|
3835
|
+
yes: parsed.options.yes === true,
|
|
3836
|
+
fetch: dependencies.fetch,
|
|
3837
|
+
stdout: dependencies.stdout
|
|
3838
|
+
};
|
|
3839
|
+
await (sub === "set" ? secretsSet(options) : secretsSetClerkKey(options));
|
|
3840
|
+
return;
|
|
3841
|
+
}
|
|
3122
3842
|
if (sub !== "push") {
|
|
3123
|
-
throw new Error(
|
|
3843
|
+
throw new Error(
|
|
3844
|
+
`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev", "odla-ai secrets set <name> --env dev --stdin", or "odla-ai secrets set-clerk-key --env dev --stdin".`
|
|
3845
|
+
);
|
|
3124
3846
|
}
|
|
3125
3847
|
assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
|
|
3126
3848
|
await secretsPush({
|
|
@@ -3133,7 +3855,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3133
3855
|
}
|
|
3134
3856
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
3135
3857
|
}
|
|
3136
|
-
async function provisionCommand(parsed) {
|
|
3858
|
+
async function provisionCommand(parsed, dependencies) {
|
|
3137
3859
|
assertArgs(parsed, [
|
|
3138
3860
|
"config",
|
|
3139
3861
|
"dry-run",
|
|
@@ -3157,10 +3879,38 @@ async function provisionCommand(parsed) {
|
|
|
3157
3879
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
3158
3880
|
token: stringOpt(parsed.options.token),
|
|
3159
3881
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3160
|
-
yes: parsed.options.yes === true
|
|
3882
|
+
yes: parsed.options.yes === true,
|
|
3883
|
+
fetch: dependencies.fetch,
|
|
3884
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3885
|
+
stdout: dependencies.stdout
|
|
3161
3886
|
};
|
|
3162
3887
|
await provision(options);
|
|
3163
3888
|
}
|
|
3889
|
+
async function calendarCommand(parsed, dependencies) {
|
|
3890
|
+
const sub = parsed.positionals[1];
|
|
3891
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
|
|
3892
|
+
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3893
|
+
}
|
|
3894
|
+
assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
|
|
3895
|
+
if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
|
|
3896
|
+
if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
|
|
3897
|
+
const options = {
|
|
3898
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3899
|
+
env: stringOpt(parsed.options.env),
|
|
3900
|
+
token: stringOpt(parsed.options.token),
|
|
3901
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3902
|
+
yes: parsed.options.yes === true,
|
|
3903
|
+
json: parsed.options.json === true,
|
|
3904
|
+
fetch: dependencies.fetch,
|
|
3905
|
+
stdout: dependencies.stdout,
|
|
3906
|
+
openConsentUrl: dependencies.openUrl
|
|
3907
|
+
};
|
|
3908
|
+
if (sub === "status") await calendarStatus(options);
|
|
3909
|
+
else if (sub === "calendars") await calendarCalendars(options);
|
|
3910
|
+
else if (sub === "connect") await calendarConnect(options);
|
|
3911
|
+
else if (sub === "resync") await calendarResync(options);
|
|
3912
|
+
else await calendarDisconnect(options);
|
|
3913
|
+
}
|
|
3164
3914
|
|
|
3165
3915
|
export {
|
|
3166
3916
|
getScopedPlatformToken,
|
|
@@ -3168,11 +3918,21 @@ export {
|
|
|
3168
3918
|
adminAi,
|
|
3169
3919
|
CAPABILITIES,
|
|
3170
3920
|
printCapabilities,
|
|
3921
|
+
GOOGLE_CALENDAR_READ_SCOPE,
|
|
3922
|
+
calendarServiceConfig,
|
|
3923
|
+
calendarBookingPageUrl,
|
|
3171
3924
|
redactSecrets,
|
|
3925
|
+
calendarStatus,
|
|
3926
|
+
calendarCalendars,
|
|
3927
|
+
calendarConnect,
|
|
3928
|
+
calendarResync,
|
|
3929
|
+
calendarDisconnect,
|
|
3172
3930
|
doctor,
|
|
3173
3931
|
initProject,
|
|
3174
3932
|
secretsPush,
|
|
3175
3933
|
provision,
|
|
3934
|
+
secretsSet,
|
|
3935
|
+
secretsSetClerkKey,
|
|
3176
3936
|
runHostedSecurity,
|
|
3177
3937
|
connectGitHubSecuritySource,
|
|
3178
3938
|
listGitHubSecuritySources,
|
|
@@ -3192,4 +3952,4 @@ export {
|
|
|
3192
3952
|
smoke,
|
|
3193
3953
|
runCli
|
|
3194
3954
|
};
|
|
3195
|
-
//# sourceMappingURL=chunk-
|
|
3955
|
+
//# sourceMappingURL=chunk-WDKBW4BE.js.map
|