@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
package/dist/bin.cjs
CHANGED
|
@@ -28,7 +28,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
28
28
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
29
29
|
|
|
30
30
|
// src/admin-ai.ts
|
|
31
|
-
var
|
|
31
|
+
var import_node_process5 = __toESM(require("process"), 1);
|
|
32
32
|
|
|
33
33
|
// src/token.ts
|
|
34
34
|
var import_db = require("@odla-ai/db");
|
|
@@ -258,9 +258,32 @@ function platformAudience(value) {
|
|
|
258
258
|
return url.origin;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
// src/secret-input.ts
|
|
262
|
+
var import_node_process3 = __toESM(require("process"), 1);
|
|
263
|
+
var MAX_BYTES = 64 * 1024;
|
|
264
|
+
async function secretInputValue(options, kind = "credential") {
|
|
265
|
+
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
266
|
+
let value;
|
|
267
|
+
if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
|
|
268
|
+
else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
|
|
269
|
+
else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
|
|
270
|
+
value = value?.replace(/[\r\n]+$/, "");
|
|
271
|
+
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : `stdin ${kind} is empty`);
|
|
272
|
+
if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
async function readSecretStream(kind, stream = import_node_process3.default.stdin) {
|
|
276
|
+
let value = "";
|
|
277
|
+
for await (const chunk of stream) {
|
|
278
|
+
value += String(chunk);
|
|
279
|
+
if (value.length > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
|
|
261
284
|
// src/admin-ai-auth.ts
|
|
262
285
|
var import_node_fs2 = require("fs");
|
|
263
|
-
var
|
|
286
|
+
var import_node_process4 = __toESM(require("process"), 1);
|
|
264
287
|
var import_db2 = require("@odla-ai/db");
|
|
265
288
|
async function getScopedPlatformToken(options) {
|
|
266
289
|
return resolveAdminPlatformToken(options);
|
|
@@ -268,7 +291,7 @@ async function getScopedPlatformToken(options) {
|
|
|
268
291
|
async function resolveAdminPlatformToken(options) {
|
|
269
292
|
const audience = platformAudience(options.platform);
|
|
270
293
|
if (options.token) return options.token;
|
|
271
|
-
const fromEnv =
|
|
294
|
+
const fromEnv = import_node_process4.default.env.ODLA_ADMIN_TOKEN;
|
|
272
295
|
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
273
296
|
return scopedToken(
|
|
274
297
|
audience,
|
|
@@ -280,7 +303,7 @@ async function resolveAdminPlatformToken(options) {
|
|
|
280
303
|
}
|
|
281
304
|
function audienceBoundEnvToken(token, platform) {
|
|
282
305
|
const audience = platformAudience(platform);
|
|
283
|
-
const declared =
|
|
306
|
+
const declared = import_node_process4.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
284
307
|
if (declared) {
|
|
285
308
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
286
309
|
} else if (audience !== "https://odla.ai") {
|
|
@@ -290,7 +313,7 @@ function audienceBoundEnvToken(token, platform) {
|
|
|
290
313
|
}
|
|
291
314
|
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
292
315
|
const audience = platformAudience(platform);
|
|
293
|
-
const tokenFile = options.tokenFile ?? `${
|
|
316
|
+
const tokenFile = options.tokenFile ?? `${import_node_process4.default.cwd()}/.odla/admin-token.local.json`;
|
|
294
317
|
const cache = readJsonFile(tokenFile);
|
|
295
318
|
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
296
319
|
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
@@ -305,13 +328,13 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
305
328
|
onCode: async ({ userCode, expiresIn }) => {
|
|
306
329
|
const approvalUrl = handshakeUrl(audience, userCode);
|
|
307
330
|
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
308
|
-
const shouldOpen = options.open === true || options.open !== false && !
|
|
331
|
+
const shouldOpen = options.open === true || options.open !== false && !import_node_process4.default.env.CI && Boolean(import_node_process4.default.stdin.isTTY && import_node_process4.default.stdout.isTTY);
|
|
309
332
|
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
310
333
|
}
|
|
311
334
|
});
|
|
312
335
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
313
336
|
tokens[scope] = { token, expiresAt };
|
|
314
|
-
if ((0, import_node_fs2.existsSync)(`${
|
|
337
|
+
if ((0, import_node_fs2.existsSync)(`${import_node_process4.default.cwd()}/.git`)) ensureGitignore(import_node_process4.default.cwd(), [tokenFile]);
|
|
315
338
|
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
316
339
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
317
340
|
return token;
|
|
@@ -472,7 +495,7 @@ function isRecord2(value) {
|
|
|
472
495
|
|
|
473
496
|
// src/admin-ai.ts
|
|
474
497
|
async function adminAi(options) {
|
|
475
|
-
const platform = platformAudience(options.platform ??
|
|
498
|
+
const platform = platformAudience(options.platform ?? import_node_process5.default.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
476
499
|
const doFetch = options.fetch ?? fetch;
|
|
477
500
|
const out = options.stdout ?? console;
|
|
478
501
|
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
@@ -511,7 +534,7 @@ async function adminAi(options) {
|
|
|
511
534
|
}
|
|
512
535
|
if (options.action === "credential-set") {
|
|
513
536
|
const provider = requireProvider(options.credentialProvider);
|
|
514
|
-
const value = await
|
|
537
|
+
const value = await secretInputValue(options);
|
|
515
538
|
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
|
|
516
539
|
method: "PUT",
|
|
517
540
|
headers,
|
|
@@ -621,25 +644,6 @@ function requireProvider(value) {
|
|
|
621
644
|
}
|
|
622
645
|
return value;
|
|
623
646
|
}
|
|
624
|
-
async function credentialValue(options) {
|
|
625
|
-
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
626
|
-
let value;
|
|
627
|
-
if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
|
|
628
|
-
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
629
|
-
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
630
|
-
value = value?.replace(/[\r\n]+$/, "");
|
|
631
|
-
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
|
|
632
|
-
if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
633
|
-
return value;
|
|
634
|
-
}
|
|
635
|
-
async function readStdin() {
|
|
636
|
-
let value = "";
|
|
637
|
-
for await (const chunk of import_node_process4.default.stdin) {
|
|
638
|
-
value += String(chunk);
|
|
639
|
-
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
640
|
-
}
|
|
641
|
-
return value;
|
|
642
|
-
}
|
|
643
647
|
function policyArray(body) {
|
|
644
648
|
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
645
649
|
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
@@ -809,7 +813,9 @@ var CAPABILITIES = {
|
|
|
809
813
|
"register the app and enable configured services per environment",
|
|
810
814
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
811
815
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
816
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
812
817
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
818
|
+
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
813
819
|
"validate config offline and smoke-test a provisioned db environment",
|
|
814
820
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
815
821
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
@@ -818,10 +824,12 @@ var CAPABILITIES = {
|
|
|
818
824
|
agent: [
|
|
819
825
|
"install and import the selected odla SDKs",
|
|
820
826
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
821
|
-
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
827
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
828
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
822
829
|
],
|
|
823
830
|
human: [
|
|
824
831
|
"approve the odla device code and one-off third-party logins",
|
|
832
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
825
833
|
"consent to production changes with --yes",
|
|
826
834
|
"request destructive credential rotation explicitly",
|
|
827
835
|
"review application semantics, security findings, releases, and merges",
|
|
@@ -830,6 +838,7 @@ var CAPABILITIES = {
|
|
|
830
838
|
],
|
|
831
839
|
studio: [
|
|
832
840
|
"view telemetry and environment state",
|
|
841
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
833
842
|
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
834
843
|
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
835
844
|
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
@@ -859,6 +868,7 @@ var import_node_url = require("url");
|
|
|
859
868
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
860
869
|
var DEFAULT_ENVS = ["dev"];
|
|
861
870
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
871
|
+
var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
862
872
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
863
873
|
const resolved = (0, import_node_path2.resolve)(configPath);
|
|
864
874
|
if (!(0, import_node_fs3.existsSync)(resolved)) {
|
|
@@ -871,6 +881,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
871
881
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
872
882
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
873
883
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
884
|
+
validateCalendarConfig(raw, envs, services, resolved);
|
|
874
885
|
const local = {
|
|
875
886
|
tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
876
887
|
credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
@@ -915,6 +926,28 @@ function buildPlan(cfg) {
|
|
|
915
926
|
aiProvider: cfg.ai?.provider
|
|
916
927
|
};
|
|
917
928
|
}
|
|
929
|
+
function calendarServiceConfig(cfg, env) {
|
|
930
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
931
|
+
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
932
|
+
const google = cfg.calendar?.google;
|
|
933
|
+
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
934
|
+
return {
|
|
935
|
+
provider: "google",
|
|
936
|
+
access: "read",
|
|
937
|
+
calendars: unique(google.calendars[env].map((id) => id.trim())),
|
|
938
|
+
match: {
|
|
939
|
+
organizerSelf: google.match?.organizerSelf ?? true,
|
|
940
|
+
requireAttendees: google.match?.requireAttendees ?? true,
|
|
941
|
+
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
942
|
+
},
|
|
943
|
+
attendeePolicy: google.attendeePolicy ?? "full"
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function calendarBookingPageUrl(cfg, env) {
|
|
947
|
+
const value = cfg.calendar?.google.bookingPageUrl?.[env];
|
|
948
|
+
if (value === void 0 || value === null) return value;
|
|
949
|
+
return new URL(value).toString();
|
|
950
|
+
}
|
|
918
951
|
function rulesFromSchema(schema) {
|
|
919
952
|
const entities = serializedEntities(schema);
|
|
920
953
|
return Object.fromEntries(
|
|
@@ -938,7 +971,85 @@ function validateRawConfig(raw, path) {
|
|
|
938
971
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
939
972
|
if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
940
973
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
974
|
+
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
975
|
+
throw new Error(`${path}: envs must be an array of names`);
|
|
976
|
+
}
|
|
941
977
|
if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
978
|
+
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
979
|
+
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function validateCalendarConfig(cfg, envs, services, path) {
|
|
983
|
+
const enabled = services.includes("calendar");
|
|
984
|
+
if (!cfg.calendar) {
|
|
985
|
+
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
989
|
+
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
990
|
+
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
991
|
+
const google = cfg.calendar.google;
|
|
992
|
+
assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
|
|
993
|
+
if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
|
|
994
|
+
const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
|
|
995
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
|
|
996
|
+
for (const env of envs) {
|
|
997
|
+
const ids = google.calendars[env];
|
|
998
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
999
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
|
|
1000
|
+
}
|
|
1001
|
+
if (ids.length > 10) {
|
|
1002
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
|
|
1003
|
+
}
|
|
1004
|
+
if (ids.some((id) => !safeText(id, 1024))) {
|
|
1005
|
+
throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (google.bookingPageUrl !== void 0) {
|
|
1009
|
+
if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
1010
|
+
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
1011
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
1012
|
+
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
1013
|
+
if (value !== null && !safeHttpsUrl(value)) {
|
|
1014
|
+
throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
1019
|
+
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
1020
|
+
}
|
|
1021
|
+
if (google.match !== void 0) {
|
|
1022
|
+
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
1023
|
+
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
1024
|
+
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
1025
|
+
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
1026
|
+
}
|
|
1027
|
+
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
1028
|
+
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
1029
|
+
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
1034
|
+
}
|
|
1035
|
+
function assertOnly(value, allowed, label) {
|
|
1036
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
1037
|
+
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
1038
|
+
}
|
|
1039
|
+
function isRecord4(value) {
|
|
1040
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1041
|
+
}
|
|
1042
|
+
function safeText(value, max) {
|
|
1043
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1044
|
+
}
|
|
1045
|
+
function safeHttpsUrl(value) {
|
|
1046
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
1047
|
+
try {
|
|
1048
|
+
const url = new URL(value);
|
|
1049
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
1050
|
+
} catch {
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
942
1053
|
}
|
|
943
1054
|
function validId(value) {
|
|
944
1055
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
@@ -957,11 +1068,6 @@ function unique(values) {
|
|
|
957
1068
|
return [...new Set(values.filter(Boolean))];
|
|
958
1069
|
}
|
|
959
1070
|
|
|
960
|
-
// src/doctor-checks.ts
|
|
961
|
-
var import_node_child_process3 = require("child_process");
|
|
962
|
-
var import_node_fs5 = require("fs");
|
|
963
|
-
var import_node_path4 = require("path");
|
|
964
|
-
|
|
965
1071
|
// src/redact.ts
|
|
966
1072
|
var REPLACEMENTS = [
|
|
967
1073
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -981,6 +1087,426 @@ function looksSecret(value) {
|
|
|
981
1087
|
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
982
1088
|
}
|
|
983
1089
|
|
|
1090
|
+
// src/calendar-http.ts
|
|
1091
|
+
var CALENDAR_STATES = [
|
|
1092
|
+
"not_connected",
|
|
1093
|
+
"authorizing",
|
|
1094
|
+
"needs_sync",
|
|
1095
|
+
"initial_sync",
|
|
1096
|
+
"healthy",
|
|
1097
|
+
"degraded",
|
|
1098
|
+
"disconnected",
|
|
1099
|
+
"failed"
|
|
1100
|
+
];
|
|
1101
|
+
async function readCalendarStatus(ctx) {
|
|
1102
|
+
return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
|
|
1103
|
+
}
|
|
1104
|
+
async function discoverGoogleCalendars(ctx) {
|
|
1105
|
+
const raw = await calendarJson(ctx, "/calendars", {});
|
|
1106
|
+
const value = record(raw);
|
|
1107
|
+
if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
|
|
1108
|
+
return value.calendars.map((item, index) => {
|
|
1109
|
+
const calendar = record(item);
|
|
1110
|
+
const id = textField(calendar?.id, 1024);
|
|
1111
|
+
if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
|
|
1112
|
+
const role = calendar.accessRole;
|
|
1113
|
+
if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
|
|
1114
|
+
throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
id,
|
|
1118
|
+
...optionalText("summary", calendar.summary, 500),
|
|
1119
|
+
...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
|
|
1120
|
+
...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
|
|
1121
|
+
...typeof role === "string" ? { accessRole: role } : {}
|
|
1122
|
+
};
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
async function applyCalendarSettings(ctx, bookingPageUrl) {
|
|
1126
|
+
return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
|
|
1127
|
+
}
|
|
1128
|
+
async function startCalendarConnection(ctx) {
|
|
1129
|
+
const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
|
|
1130
|
+
const value = wrapped(raw, "attempt");
|
|
1131
|
+
const attemptId = textField(value.attemptId, 180);
|
|
1132
|
+
const consentUrl = textField(value.consentUrl, 4096);
|
|
1133
|
+
const expiresAt = timestamp3(value.expiresAt);
|
|
1134
|
+
if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
|
|
1135
|
+
return { attemptId, consentUrl, expiresAt };
|
|
1136
|
+
}
|
|
1137
|
+
async function pollCalendarConnection(ctx, attemptId) {
|
|
1138
|
+
return parseCalendarStatus(
|
|
1139
|
+
await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
|
|
1140
|
+
ctx.env
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
async function requestCalendarResync(ctx) {
|
|
1144
|
+
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1145
|
+
}
|
|
1146
|
+
async function requestCalendarDisconnect(ctx) {
|
|
1147
|
+
return parseCalendarStatus(
|
|
1148
|
+
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
1149
|
+
ctx.env
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
function parseCalendarStatus(raw, env) {
|
|
1153
|
+
const outer = wrapped(raw, "calendar");
|
|
1154
|
+
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1155
|
+
const connection = record(value.connection) ?? {};
|
|
1156
|
+
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1157
|
+
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1158
|
+
const googleConfig = record(config.google) ?? config;
|
|
1159
|
+
const watches = record(value.watches) ?? {};
|
|
1160
|
+
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1161
|
+
if (!stateValue) {
|
|
1162
|
+
throw new Error("calendar status returned an invalid connection state");
|
|
1163
|
+
}
|
|
1164
|
+
const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
|
|
1165
|
+
if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
|
|
1166
|
+
throw new Error("calendar status returned an unsupported provider");
|
|
1167
|
+
}
|
|
1168
|
+
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1169
|
+
if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
|
|
1170
|
+
const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
|
|
1171
|
+
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1172
|
+
throw new Error("calendar status returned an invalid attendee policy");
|
|
1173
|
+
}
|
|
1174
|
+
const errorValue = record(value.error) ?? record(connection.error);
|
|
1175
|
+
const errorCode = textField(value.lastErrorCode, 128);
|
|
1176
|
+
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1177
|
+
return {
|
|
1178
|
+
env,
|
|
1179
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1180
|
+
connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
|
|
1181
|
+
provider: providerValue === "google" ? "google" : null,
|
|
1182
|
+
status: stateValue,
|
|
1183
|
+
...accessValue === "read" ? { access: "read" } : {},
|
|
1184
|
+
calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
|
|
1185
|
+
...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
|
|
1186
|
+
...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
|
|
1187
|
+
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1188
|
+
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1189
|
+
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1190
|
+
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1191
|
+
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1192
|
+
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1193
|
+
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1194
|
+
...errorValue || errorCode ? { error: {
|
|
1195
|
+
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1196
|
+
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
1197
|
+
...!errorValue && errorCode ? { code: errorCode } : {}
|
|
1198
|
+
} } : {}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
function calendarState(value) {
|
|
1202
|
+
if (typeof value !== "string") return null;
|
|
1203
|
+
if (CALENDAR_STATES.includes(value)) return value;
|
|
1204
|
+
const legacy = {
|
|
1205
|
+
disabled: "not_connected",
|
|
1206
|
+
disconnected: "disconnected",
|
|
1207
|
+
connecting: "authorizing",
|
|
1208
|
+
syncing: "initial_sync",
|
|
1209
|
+
ready: "healthy",
|
|
1210
|
+
error: "failed"
|
|
1211
|
+
};
|
|
1212
|
+
return legacy[value] ?? null;
|
|
1213
|
+
}
|
|
1214
|
+
async function calendarJson(ctx, suffix, init) {
|
|
1215
|
+
const platform = platformAudience(ctx.platform);
|
|
1216
|
+
const appId = identifier(ctx.appId, "appId");
|
|
1217
|
+
const env = identifier(ctx.env, "env");
|
|
1218
|
+
const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
|
|
1219
|
+
url.searchParams.set("env", env);
|
|
1220
|
+
const token = credential(ctx.token);
|
|
1221
|
+
let response;
|
|
1222
|
+
try {
|
|
1223
|
+
response = await (ctx.fetch ?? fetch)(url, {
|
|
1224
|
+
...init,
|
|
1225
|
+
redirect: "error",
|
|
1226
|
+
credentials: "omit",
|
|
1227
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
|
|
1228
|
+
});
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1231
|
+
}
|
|
1232
|
+
const body = await response.json().catch(() => ({}));
|
|
1233
|
+
if (!response.ok) {
|
|
1234
|
+
const code = textField(body.error?.code, 128);
|
|
1235
|
+
const message = textField(body.error?.message, 500);
|
|
1236
|
+
throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
|
|
1237
|
+
}
|
|
1238
|
+
return body;
|
|
1239
|
+
}
|
|
1240
|
+
function wrapped(raw, key) {
|
|
1241
|
+
const outer = record(raw);
|
|
1242
|
+
if (!outer) throw new Error("calendar returned an invalid response");
|
|
1243
|
+
return record(outer[key]) ?? outer;
|
|
1244
|
+
}
|
|
1245
|
+
function record(value) {
|
|
1246
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1247
|
+
}
|
|
1248
|
+
function textField(value, max) {
|
|
1249
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
|
|
1250
|
+
}
|
|
1251
|
+
function stringList(value) {
|
|
1252
|
+
return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
|
|
1253
|
+
}
|
|
1254
|
+
function calendarIds(value) {
|
|
1255
|
+
if (!Array.isArray(value)) return [];
|
|
1256
|
+
return [...new Set(value.flatMap((item) => {
|
|
1257
|
+
if (typeof item === "string") return textField(item, 4096) ? [item] : [];
|
|
1258
|
+
const calendar = record(item);
|
|
1259
|
+
const id = textField(calendar?.id, 4096);
|
|
1260
|
+
return id && calendar?.selected !== false ? [id] : [];
|
|
1261
|
+
}))];
|
|
1262
|
+
}
|
|
1263
|
+
function timestamp3(value) {
|
|
1264
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
1265
|
+
if (typeof value === "string") {
|
|
1266
|
+
const parsed = Date.parse(value);
|
|
1267
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
1268
|
+
}
|
|
1269
|
+
return void 0;
|
|
1270
|
+
}
|
|
1271
|
+
function optionalText(key, value, max) {
|
|
1272
|
+
const parsed = textField(value, max);
|
|
1273
|
+
return parsed ? { [key]: parsed } : {};
|
|
1274
|
+
}
|
|
1275
|
+
function optionalNumber(key, value) {
|
|
1276
|
+
const parsed = timestamp3(value);
|
|
1277
|
+
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1278
|
+
}
|
|
1279
|
+
function optionalInteger(key, value) {
|
|
1280
|
+
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1281
|
+
}
|
|
1282
|
+
function optionalNullableUrl(key, value) {
|
|
1283
|
+
if (value === null) return { [key]: null };
|
|
1284
|
+
const parsed = textField(value, 4096);
|
|
1285
|
+
return parsed ? { [key]: parsed } : {};
|
|
1286
|
+
}
|
|
1287
|
+
function identifier(value, name) {
|
|
1288
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
1289
|
+
return value;
|
|
1290
|
+
}
|
|
1291
|
+
function credential(value) {
|
|
1292
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1293
|
+
throw new Error("calendar requires an odla developer token");
|
|
1294
|
+
}
|
|
1295
|
+
return value;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/calendar-poll.ts
|
|
1299
|
+
async function waitForCalendarPoll(milliseconds, signal) {
|
|
1300
|
+
if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
|
|
1301
|
+
await new Promise((resolve7, reject) => {
|
|
1302
|
+
const timer = setTimeout(resolve7, milliseconds);
|
|
1303
|
+
signal?.addEventListener("abort", () => {
|
|
1304
|
+
clearTimeout(timer);
|
|
1305
|
+
reject(signal.reason ?? new Error("calendar connection aborted"));
|
|
1306
|
+
}, { once: true });
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// src/calendar.ts
|
|
1311
|
+
async function calendarStatus(options) {
|
|
1312
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1313
|
+
const status = await readCalendarStatus(ctx);
|
|
1314
|
+
printStatus(status, options.json === true, out);
|
|
1315
|
+
return status;
|
|
1316
|
+
}
|
|
1317
|
+
async function calendarCalendars(options) {
|
|
1318
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1319
|
+
const calendars = await discoverGoogleCalendars(ctx);
|
|
1320
|
+
if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
|
|
1321
|
+
else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
|
|
1322
|
+
else for (const calendar of calendars) {
|
|
1323
|
+
out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
|
|
1324
|
+
}
|
|
1325
|
+
return calendars;
|
|
1326
|
+
}
|
|
1327
|
+
async function calendarConnect(options) {
|
|
1328
|
+
const { cfg, ctx, out } = await lifecycleContext(options);
|
|
1329
|
+
productionConsent(ctx.env, options.yes, "connect calendar");
|
|
1330
|
+
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1331
|
+
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1332
|
+
const connectOptions = connectionOptions(options, out);
|
|
1333
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
|
|
1334
|
+
}
|
|
1335
|
+
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1336
|
+
if (bookingPageUrl === void 0) return null;
|
|
1337
|
+
const status = await applyCalendarSettings(ctx, bookingPageUrl);
|
|
1338
|
+
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1339
|
+
return status;
|
|
1340
|
+
}
|
|
1341
|
+
async function calendarResync(options) {
|
|
1342
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1343
|
+
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1344
|
+
const status = await requestCalendarResync(ctx);
|
|
1345
|
+
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1346
|
+
return status;
|
|
1347
|
+
}
|
|
1348
|
+
async function calendarDisconnect(options) {
|
|
1349
|
+
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1350
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1351
|
+
const status = await requestCalendarDisconnect(ctx);
|
|
1352
|
+
out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
|
|
1353
|
+
return status;
|
|
1354
|
+
}
|
|
1355
|
+
async function ensureCalendarConnected(ctx, options) {
|
|
1356
|
+
const out = options.stdout ?? console;
|
|
1357
|
+
const current = await readCalendarStatus(ctx);
|
|
1358
|
+
const connectOptions = connectionOptions(options, out);
|
|
1359
|
+
return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
|
|
1360
|
+
}
|
|
1361
|
+
async function lifecycleContext(options) {
|
|
1362
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1363
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
1364
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1365
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1366
|
+
calendarServiceConfig(cfg, env);
|
|
1367
|
+
const out = options.stdout ?? console;
|
|
1368
|
+
const doFetch = options.fetch ?? fetch;
|
|
1369
|
+
const token = await getDeveloperToken(
|
|
1370
|
+
cfg,
|
|
1371
|
+
{
|
|
1372
|
+
configPath: cfg.configPath,
|
|
1373
|
+
token: options.token,
|
|
1374
|
+
open: options.open,
|
|
1375
|
+
interactive: options.interactive,
|
|
1376
|
+
openApprovalUrl: options.openConsentUrl
|
|
1377
|
+
},
|
|
1378
|
+
doFetch,
|
|
1379
|
+
out
|
|
1380
|
+
);
|
|
1381
|
+
return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
|
|
1382
|
+
}
|
|
1383
|
+
function connectionOptions(options, out) {
|
|
1384
|
+
const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
|
|
1385
|
+
return {
|
|
1386
|
+
out,
|
|
1387
|
+
open: browser.open,
|
|
1388
|
+
openConsentUrl: options.openConsentUrl,
|
|
1389
|
+
wait: options.wait,
|
|
1390
|
+
pollTimeoutMs: options.pollTimeoutMs,
|
|
1391
|
+
now: options.now,
|
|
1392
|
+
signal: options.signal
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
1396
|
+
if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
|
|
1397
|
+
options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
|
|
1398
|
+
return current;
|
|
1399
|
+
}
|
|
1400
|
+
if (current.status === "degraded") return null;
|
|
1401
|
+
if (current.status === "needs_sync") {
|
|
1402
|
+
if (!current.connected) return null;
|
|
1403
|
+
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1404
|
+
const synced = await requestCalendarResync(ctx);
|
|
1405
|
+
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1406
|
+
return synced;
|
|
1407
|
+
}
|
|
1408
|
+
if (current.status === "failed" && current.connected) {
|
|
1409
|
+
const reason = current.error?.message ?? current.error?.code;
|
|
1410
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1411
|
+
}
|
|
1412
|
+
const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
|
|
1413
|
+
if (!waitingForExisting) return null;
|
|
1414
|
+
const now = options.now ?? Date.now;
|
|
1415
|
+
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1416
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1417
|
+
let prior = "";
|
|
1418
|
+
while (now() < deadline) {
|
|
1419
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1420
|
+
const status = await readCalendarStatus(ctx);
|
|
1421
|
+
if (status.status !== prior) {
|
|
1422
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1423
|
+
prior = status.status;
|
|
1424
|
+
}
|
|
1425
|
+
if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
|
|
1426
|
+
if (status.status === "degraded") return null;
|
|
1427
|
+
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1428
|
+
if (status.status === "failed" && status.connected) {
|
|
1429
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1430
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1431
|
+
}
|
|
1432
|
+
if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
|
|
1433
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1434
|
+
}
|
|
1435
|
+
throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
|
|
1436
|
+
}
|
|
1437
|
+
async function connectWithContext(ctx, options) {
|
|
1438
|
+
const attempt = await startCalendarConnection(ctx);
|
|
1439
|
+
const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
|
|
1440
|
+
options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
|
|
1441
|
+
if (options.open) {
|
|
1442
|
+
await (options.openConsentUrl ?? openUrl)(consentUrl);
|
|
1443
|
+
options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
|
|
1444
|
+
}
|
|
1445
|
+
const now = options.now ?? Date.now;
|
|
1446
|
+
const timeout = pollTimeout(options.pollTimeoutMs);
|
|
1447
|
+
const deadline = Math.min(attempt.expiresAt, now() + timeout);
|
|
1448
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1449
|
+
let prior = "";
|
|
1450
|
+
while (now() < deadline) {
|
|
1451
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1452
|
+
const status = await pollCalendarConnection(ctx, attempt.attemptId);
|
|
1453
|
+
if (status.status !== prior) {
|
|
1454
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1455
|
+
prior = status.status;
|
|
1456
|
+
}
|
|
1457
|
+
if (status.status === "healthy" || status.status === "degraded") return status;
|
|
1458
|
+
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1459
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1460
|
+
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1461
|
+
}
|
|
1462
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1463
|
+
}
|
|
1464
|
+
throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
|
|
1465
|
+
}
|
|
1466
|
+
function trustedCalendarConsentUrl(platform, value) {
|
|
1467
|
+
const origin = platformAudience(platform);
|
|
1468
|
+
let url;
|
|
1469
|
+
try {
|
|
1470
|
+
url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
|
|
1471
|
+
} catch {
|
|
1472
|
+
throw new Error("odla.ai returned an invalid calendar consent URL");
|
|
1473
|
+
}
|
|
1474
|
+
const platformPage = url.origin === origin;
|
|
1475
|
+
const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
1476
|
+
if (!platformPage && !googlePage || url.username || url.password || url.hash) {
|
|
1477
|
+
throw new Error("odla.ai returned an untrusted calendar consent URL");
|
|
1478
|
+
}
|
|
1479
|
+
return url.toString();
|
|
1480
|
+
}
|
|
1481
|
+
function pollTimeout(value = 10 * 6e4) {
|
|
1482
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
|
|
1483
|
+
return value;
|
|
1484
|
+
}
|
|
1485
|
+
function productionConsent(env, yes, action) {
|
|
1486
|
+
if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
|
|
1487
|
+
}
|
|
1488
|
+
function printStatus(status, json, out) {
|
|
1489
|
+
if (json) {
|
|
1490
|
+
out.log(JSON.stringify(status, null, 2));
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1494
|
+
out.log(` access: ${status.access ?? "not granted"}`);
|
|
1495
|
+
out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1496
|
+
if (status.attendeePolicy) {
|
|
1497
|
+
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1498
|
+
}
|
|
1499
|
+
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1500
|
+
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1501
|
+
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1502
|
+
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/doctor-checks.ts
|
|
1506
|
+
var import_node_child_process3 = require("child_process");
|
|
1507
|
+
var import_node_fs5 = require("fs");
|
|
1508
|
+
var import_node_path4 = require("path");
|
|
1509
|
+
|
|
984
1510
|
// src/wrangler.ts
|
|
985
1511
|
var import_node_child_process2 = require("child_process");
|
|
986
1512
|
var import_node_fs4 = require("fs");
|
|
@@ -1161,6 +1687,15 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1161
1687
|
}
|
|
1162
1688
|
return warnings;
|
|
1163
1689
|
}
|
|
1690
|
+
function calendarProjectWarnings(rootDir) {
|
|
1691
|
+
const pkg = readPackageJson(rootDir);
|
|
1692
|
+
const dependencies = pkg?.dependencies;
|
|
1693
|
+
const devDependencies = pkg?.devDependencies;
|
|
1694
|
+
if (dependencies?.["@odla-ai/calendar"]) return [];
|
|
1695
|
+
return [
|
|
1696
|
+
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"
|
|
1697
|
+
];
|
|
1698
|
+
}
|
|
1164
1699
|
function readPackageJson(rootDir) {
|
|
1165
1700
|
try {
|
|
1166
1701
|
return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
|
|
@@ -1186,6 +1721,14 @@ async function doctor(options) {
|
|
|
1186
1721
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1187
1722
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1188
1723
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1724
|
+
if (cfg.services.includes("calendar")) {
|
|
1725
|
+
const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
|
|
1726
|
+
out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
|
|
1727
|
+
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1728
|
+
out.log(`booking pages: ${pages}`);
|
|
1729
|
+
} else {
|
|
1730
|
+
out.log("calendar: not enabled");
|
|
1731
|
+
}
|
|
1189
1732
|
const warnings = [];
|
|
1190
1733
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
1191
1734
|
if (rules) {
|
|
@@ -1213,6 +1756,8 @@ async function doctor(options) {
|
|
|
1213
1756
|
}
|
|
1214
1757
|
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
1215
1758
|
}
|
|
1759
|
+
if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
|
|
1760
|
+
else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
|
|
1216
1761
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
1217
1762
|
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
1218
1763
|
cfg.local.tokenFile,
|
|
@@ -1240,8 +1785,13 @@ function printHelp() {
|
|
|
1240
1785
|
|
|
1241
1786
|
Usage:
|
|
1242
1787
|
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1243
|
-
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
1788
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
|
|
1244
1789
|
odla-ai doctor [--config odla.config.mjs]
|
|
1790
|
+
odla-ai calendar status [--env dev] [--json]
|
|
1791
|
+
odla-ai calendar calendars [--env dev] [--json]
|
|
1792
|
+
odla-ai calendar connect [--env dev] [--no-open] [--yes]
|
|
1793
|
+
odla-ai calendar resync [--env dev] [--yes]
|
|
1794
|
+
odla-ai calendar disconnect [--env dev] --yes
|
|
1245
1795
|
odla-ai capabilities [--json]
|
|
1246
1796
|
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
1247
1797
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -1262,15 +1812,18 @@ Usage:
|
|
|
1262
1812
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1263
1813
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1264
1814
|
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1265
|
-
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
1815
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
|
|
1266
1816
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1267
1817
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1818
|
+
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1819
|
+
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1268
1820
|
odla-ai version
|
|
1269
1821
|
|
|
1270
1822
|
Commands:
|
|
1271
1823
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1272
1824
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1273
1825
|
doctor Validate and summarize the project config without network calls.
|
|
1826
|
+
calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
|
|
1274
1827
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1275
1828
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1276
1829
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -1278,7 +1831,9 @@ Commands:
|
|
|
1278
1831
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
1279
1832
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1280
1833
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1281
|
-
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
1834
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
1835
|
+
stdin; set stores a tenant-vault secret and set-clerk-key the
|
|
1836
|
+
reserved Clerk secret key, write-only from stdin or an env var.
|
|
1282
1837
|
version Print the CLI version.
|
|
1283
1838
|
|
|
1284
1839
|
Safety:
|
|
@@ -1289,6 +1844,8 @@ Safety:
|
|
|
1289
1844
|
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1290
1845
|
Provision opens the approval page automatically in interactive terminals;
|
|
1291
1846
|
use --open to force browser launch or --no-open to suppress it.
|
|
1847
|
+
Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
|
|
1848
|
+
OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
|
|
1292
1849
|
GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
|
|
1293
1850
|
for a PAT or provider key. GitHub read access is separate from the explicit
|
|
1294
1851
|
--ack-redacted-source consent and the exact --plan-digest printed by security
|
|
@@ -1333,6 +1890,7 @@ function initProject(options) {
|
|
|
1333
1890
|
}
|
|
1334
1891
|
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
1335
1892
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1893
|
+
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1336
1894
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1337
1895
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
|
|
1338
1896
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
|
|
@@ -1350,6 +1908,16 @@ function writeIfMissing(path, text) {
|
|
|
1350
1908
|
(0, import_node_fs7.writeFileSync)(path, text);
|
|
1351
1909
|
}
|
|
1352
1910
|
function configTemplate(input) {
|
|
1911
|
+
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1912
|
+
google: {
|
|
1913
|
+
calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1914
|
+
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1915
|
+
match: { organizerSelf: true, requireAttendees: true },
|
|
1916
|
+
attendeePolicy: "full",
|
|
1917
|
+
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1918
|
+
},
|
|
1919
|
+
},
|
|
1920
|
+
` : "";
|
|
1353
1921
|
return `export default {
|
|
1354
1922
|
platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
|
|
1355
1923
|
dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
|
|
@@ -1371,6 +1939,7 @@ function configTemplate(input) {
|
|
|
1371
1939
|
// key in the platform vault for each tenant.
|
|
1372
1940
|
keyEnv: "${defaultKeyEnv(input.aiProvider)}",
|
|
1373
1941
|
},
|
|
1942
|
+
${calendar}
|
|
1374
1943
|
auth: {
|
|
1375
1944
|
clerk: {
|
|
1376
1945
|
// dev: "$CLERK_PUBLISHABLE_KEY",
|
|
@@ -1436,7 +2005,7 @@ function relativeDisplay(path, rootDir) {
|
|
|
1436
2005
|
// src/provision.ts
|
|
1437
2006
|
var import_apps2 = require("@odla-ai/apps");
|
|
1438
2007
|
var import_ai = require("@odla-ai/ai");
|
|
1439
|
-
var
|
|
2008
|
+
var import_node_process6 = __toESM(require("process"), 1);
|
|
1440
2009
|
|
|
1441
2010
|
// src/provision-credentials.ts
|
|
1442
2011
|
var import_apps = require("@odla-ai/apps");
|
|
@@ -1497,14 +2066,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1497
2066
|
appId: tenantId
|
|
1498
2067
|
})
|
|
1499
2068
|
});
|
|
1500
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2069
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
|
|
1501
2070
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1502
2071
|
method: "POST",
|
|
1503
2072
|
headers,
|
|
1504
2073
|
body: "{}"
|
|
1505
2074
|
});
|
|
1506
2075
|
}
|
|
1507
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2076
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1508
2077
|
const body = await res.json();
|
|
1509
2078
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1510
2079
|
return body.key;
|
|
@@ -1520,12 +2089,12 @@ async function issueO11yToken(opts) {
|
|
|
1520
2089
|
`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`
|
|
1521
2090
|
);
|
|
1522
2091
|
}
|
|
1523
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2092
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1524
2093
|
const body = await res.json();
|
|
1525
2094
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1526
2095
|
return body.token;
|
|
1527
2096
|
}
|
|
1528
|
-
async function
|
|
2097
|
+
async function safeText2(res) {
|
|
1529
2098
|
try {
|
|
1530
2099
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1531
2100
|
} catch {
|
|
@@ -1638,6 +2207,14 @@ async function provision(options) {
|
|
|
1638
2207
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1639
2208
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
1640
2209
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
2210
|
+
if (cfg.services.includes("calendar")) {
|
|
2211
|
+
for (const env of cfg.envs) {
|
|
2212
|
+
const calendar = calendarServiceConfig(cfg, env);
|
|
2213
|
+
out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
|
|
2214
|
+
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
2215
|
+
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
1641
2218
|
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
1642
2219
|
return;
|
|
1643
2220
|
}
|
|
@@ -1672,8 +2249,9 @@ async function provision(options) {
|
|
|
1672
2249
|
await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
|
|
1673
2250
|
out.log(`app: created ${cfg.app.id}`);
|
|
1674
2251
|
}
|
|
2252
|
+
const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
|
|
1675
2253
|
for (const env of cfg.envs) {
|
|
1676
|
-
for (const service of
|
|
2254
|
+
for (const service of serviceOrder) {
|
|
1677
2255
|
if (service === "ai") {
|
|
1678
2256
|
if (cfg.ai?.provider) {
|
|
1679
2257
|
await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
|
|
@@ -1683,7 +2261,8 @@ async function provision(options) {
|
|
|
1683
2261
|
out.log(`${env}: ai enabled`);
|
|
1684
2262
|
}
|
|
1685
2263
|
} else {
|
|
1686
|
-
|
|
2264
|
+
const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
|
|
2265
|
+
await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
|
|
1687
2266
|
out.log(`${env}: ${service} enabled`);
|
|
1688
2267
|
}
|
|
1689
2268
|
}
|
|
@@ -1697,6 +2276,21 @@ async function provision(options) {
|
|
|
1697
2276
|
await apps.setLink(cfg.app.id, env, link ?? null);
|
|
1698
2277
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
1699
2278
|
}
|
|
2279
|
+
if (cfg.services.includes("calendar")) {
|
|
2280
|
+
const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
|
|
2281
|
+
await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
|
|
2282
|
+
await ensureCalendarConnected(
|
|
2283
|
+
calendarCtx,
|
|
2284
|
+
{
|
|
2285
|
+
open: options.open,
|
|
2286
|
+
interactive: options.interactive,
|
|
2287
|
+
openConsentUrl: options.openApprovalUrl,
|
|
2288
|
+
wait: options.calendarPollWait,
|
|
2289
|
+
pollTimeoutMs: options.calendarPollTimeoutMs,
|
|
2290
|
+
stdout: out
|
|
2291
|
+
}
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
1700
2294
|
}
|
|
1701
2295
|
for (const env of cfg.envs) {
|
|
1702
2296
|
const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
|
|
@@ -1740,7 +2334,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1740
2334
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1741
2335
|
}
|
|
1742
2336
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1743
|
-
const key =
|
|
2337
|
+
const key = import_node_process6.default.env[cfg.ai.keyEnv];
|
|
1744
2338
|
if (key) {
|
|
1745
2339
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1746
2340
|
await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1760,6 +2354,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1760
2354
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
1761
2355
|
}
|
|
1762
2356
|
}
|
|
2357
|
+
function serviceRank(service) {
|
|
2358
|
+
if (service === "db") return 0;
|
|
2359
|
+
if (service === "calendar") return 2;
|
|
2360
|
+
return 1;
|
|
2361
|
+
}
|
|
1763
2362
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
1764
2363
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
1765
2364
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -1775,7 +2374,7 @@ async function postJson(doFetch, url, bearer, body) {
|
|
|
1775
2374
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1776
2375
|
body: JSON.stringify(body)
|
|
1777
2376
|
});
|
|
1778
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2377
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
|
|
1779
2378
|
}
|
|
1780
2379
|
function normalizeClerkConfig(value) {
|
|
1781
2380
|
if (!value) return null;
|
|
@@ -1792,7 +2391,7 @@ function defaultSecretName(provider) {
|
|
|
1792
2391
|
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
1793
2392
|
return names[provider] ?? `${provider}_api_key`;
|
|
1794
2393
|
}
|
|
1795
|
-
async function
|
|
2394
|
+
async function safeText3(res) {
|
|
1796
2395
|
try {
|
|
1797
2396
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1798
2397
|
} catch {
|
|
@@ -1800,6 +2399,63 @@ async function safeText2(res) {
|
|
|
1800
2399
|
}
|
|
1801
2400
|
}
|
|
1802
2401
|
|
|
2402
|
+
// src/secrets-set.ts
|
|
2403
|
+
var import_ai2 = require("@odla-ai/ai");
|
|
2404
|
+
var import_apps3 = require("@odla-ai/apps");
|
|
2405
|
+
var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2406
|
+
async function secretsSet(options) {
|
|
2407
|
+
const name = (options.name ?? "").trim();
|
|
2408
|
+
if (!name) throw new Error('secret name is required, e.g. "odla-ai secrets set clerk_webhook_secret --env dev --stdin"');
|
|
2409
|
+
if (name.startsWith("$")) {
|
|
2410
|
+
throw new Error('"$"-prefixed vault names are platform-reserved; for the Clerk secret key use "odla-ai secrets set-clerk-key"');
|
|
2411
|
+
}
|
|
2412
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2413
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2414
|
+
try {
|
|
2415
|
+
await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
|
|
2418
|
+
}
|
|
2419
|
+
out.log(`${name} stored in the ${tenantId} vault (write-only; the value was never echoed and cannot be read back here)`);
|
|
2420
|
+
}
|
|
2421
|
+
async function secretsSetClerkKey(options) {
|
|
2422
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2423
|
+
if (!value.startsWith("sk_")) throw new Error("the Clerk secret key must start with sk_ (sk_test_\u2026 or sk_live_\u2026)");
|
|
2424
|
+
if (value.startsWith("sk_test_") && PROD_ENV_NAMES2.has(options.env)) {
|
|
2425
|
+
throw new Error(`refusing to store an sk_test_ Clerk key for "${options.env}" \u2014 use the production instance's sk_live_ key`);
|
|
2426
|
+
}
|
|
2427
|
+
if (value.startsWith("sk_live_") && !PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2428
|
+
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)`);
|
|
2429
|
+
}
|
|
2430
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2431
|
+
const res = await doFetch(`${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/clerk-secret`, {
|
|
2432
|
+
method: "POST",
|
|
2433
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2434
|
+
body: JSON.stringify({ value })
|
|
2435
|
+
});
|
|
2436
|
+
if (!res.ok) {
|
|
2437
|
+
const text = scrubValue((await res.text().catch(() => "")).slice(0, 300), value);
|
|
2438
|
+
throw new Error(`store Clerk secret key failed (${res.status}): ${text || "request failed"}`);
|
|
2439
|
+
}
|
|
2440
|
+
out.log(`Clerk secret key stored for ${tenantId} ($clerk_secret, reserved + write-only; the value was never echoed)`);
|
|
2441
|
+
}
|
|
2442
|
+
function scrubValue(text, value) {
|
|
2443
|
+
return redactSecrets(text).split(value).join("[value redacted]");
|
|
2444
|
+
}
|
|
2445
|
+
async function resolveVaultWrite(options) {
|
|
2446
|
+
const out = options.stdout ?? console;
|
|
2447
|
+
const doFetch = options.fetch ?? fetch;
|
|
2448
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
2449
|
+
if (!cfg.envs.includes(options.env)) {
|
|
2450
|
+
throw new Error(`env "${options.env}" is not in config envs (${cfg.envs.join(", ")})`);
|
|
2451
|
+
}
|
|
2452
|
+
if (PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2453
|
+
throw new Error(`refusing to store a secret for "${options.env}" without --yes`);
|
|
2454
|
+
}
|
|
2455
|
+
const value = await secretInputValue(options, "secret");
|
|
2456
|
+
return { cfg, tenantId: (0, import_apps3.tenantIdFor)(cfg.app.id, options.env), value, doFetch, out };
|
|
2457
|
+
}
|
|
2458
|
+
|
|
1803
2459
|
// src/security-command-context.ts
|
|
1804
2460
|
var import_promises = require("readline/promises");
|
|
1805
2461
|
async function hostedSecurityContext(parsed, dependencies) {
|
|
@@ -2984,6 +3640,23 @@ async function smoke(options) {
|
|
|
2984
3640
|
}
|
|
2985
3641
|
out.log(` ai: ${provider}`);
|
|
2986
3642
|
}
|
|
3643
|
+
if (cfg.services.includes("calendar")) {
|
|
3644
|
+
const token = await getDeveloperToken(
|
|
3645
|
+
cfg,
|
|
3646
|
+
{
|
|
3647
|
+
configPath: cfg.configPath,
|
|
3648
|
+
token: options.token,
|
|
3649
|
+
open: options.open,
|
|
3650
|
+
interactive: options.interactive,
|
|
3651
|
+
openApprovalUrl: options.openApprovalUrl
|
|
3652
|
+
},
|
|
3653
|
+
doFetch,
|
|
3654
|
+
out
|
|
3655
|
+
);
|
|
3656
|
+
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3657
|
+
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3658
|
+
out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
3659
|
+
}
|
|
2987
3660
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
2988
3661
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
2989
3662
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
@@ -3005,13 +3678,35 @@ async function smoke(options) {
|
|
|
3005
3678
|
} else {
|
|
3006
3679
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3007
3680
|
}
|
|
3681
|
+
if (cfg.services.includes("calendar")) {
|
|
3682
|
+
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3683
|
+
ns: "$bookings",
|
|
3684
|
+
aggregate: { count: true }
|
|
3685
|
+
});
|
|
3686
|
+
const count = bookings.aggregate?.count;
|
|
3687
|
+
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3688
|
+
}
|
|
3008
3689
|
out.log("ok");
|
|
3009
3690
|
}
|
|
3691
|
+
function assertCalendarHealthy(status, expected) {
|
|
3692
|
+
if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
|
|
3693
|
+
if (status.status !== "healthy") {
|
|
3694
|
+
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3695
|
+
}
|
|
3696
|
+
if (status.access !== "read") throw new Error("calendar connection is not read-only");
|
|
3697
|
+
const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
|
|
3698
|
+
if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
|
|
3699
|
+
const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
|
|
3700
|
+
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3701
|
+
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3702
|
+
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3703
|
+
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3704
|
+
}
|
|
3010
3705
|
async function getJson(doFetch, url, bearer) {
|
|
3011
3706
|
const res = await doFetch(url, {
|
|
3012
3707
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
3013
3708
|
});
|
|
3014
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3709
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3015
3710
|
return res.json();
|
|
3016
3711
|
}
|
|
3017
3712
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -3020,7 +3715,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
3020
3715
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
3021
3716
|
body: JSON.stringify(body)
|
|
3022
3717
|
});
|
|
3023
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3718
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3024
3719
|
return res.json();
|
|
3025
3720
|
}
|
|
3026
3721
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -3028,7 +3723,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
3028
3723
|
url.searchParams.set("env", env);
|
|
3029
3724
|
return url.toString();
|
|
3030
3725
|
}
|
|
3031
|
-
async function
|
|
3726
|
+
async function safeText4(res) {
|
|
3032
3727
|
try {
|
|
3033
3728
|
return (await res.text()).slice(0, 500);
|
|
3034
3729
|
} catch {
|
|
@@ -3076,6 +3771,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3076
3771
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
3077
3772
|
return;
|
|
3078
3773
|
}
|
|
3774
|
+
if (command === "calendar") {
|
|
3775
|
+
await calendarCommand(parsed, dependencies);
|
|
3776
|
+
return;
|
|
3777
|
+
}
|
|
3079
3778
|
if (command === "capabilities") {
|
|
3080
3779
|
assertArgs(parsed, ["json"], 1);
|
|
3081
3780
|
printCapabilities(parsed.options.json === true);
|
|
@@ -3090,14 +3789,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3090
3789
|
return;
|
|
3091
3790
|
}
|
|
3092
3791
|
if (command === "provision") {
|
|
3093
|
-
await provisionCommand(parsed);
|
|
3792
|
+
await provisionCommand(parsed, dependencies);
|
|
3094
3793
|
return;
|
|
3095
3794
|
}
|
|
3096
3795
|
if (command === "smoke") {
|
|
3097
|
-
assertArgs(parsed, ["config", "env"], 1);
|
|
3796
|
+
assertArgs(parsed, ["config", "env", "token", "open"], 1);
|
|
3098
3797
|
await smoke({
|
|
3099
3798
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3100
|
-
env: stringOpt(parsed.options.env)
|
|
3799
|
+
env: stringOpt(parsed.options.env),
|
|
3800
|
+
token: stringOpt(parsed.options.token),
|
|
3801
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3802
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3803
|
+
fetch: dependencies.fetch,
|
|
3804
|
+
stdout: dependencies.stdout
|
|
3101
3805
|
});
|
|
3102
3806
|
return;
|
|
3103
3807
|
}
|
|
@@ -3130,8 +3834,26 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3130
3834
|
}
|
|
3131
3835
|
if (command === "secrets") {
|
|
3132
3836
|
const sub = parsed.positionals[1];
|
|
3837
|
+
if (sub === "set" || sub === "set-clerk-key") {
|
|
3838
|
+
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
|
|
3839
|
+
const options = {
|
|
3840
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3841
|
+
name: parsed.positionals[2],
|
|
3842
|
+
env: requiredString(parsed.options.env, "--env"),
|
|
3843
|
+
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
3844
|
+
stdin: parsed.options.stdin === true,
|
|
3845
|
+
token: stringOpt(parsed.options.token),
|
|
3846
|
+
yes: parsed.options.yes === true,
|
|
3847
|
+
fetch: dependencies.fetch,
|
|
3848
|
+
stdout: dependencies.stdout
|
|
3849
|
+
};
|
|
3850
|
+
await (sub === "set" ? secretsSet(options) : secretsSetClerkKey(options));
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3133
3853
|
if (sub !== "push") {
|
|
3134
|
-
throw new Error(
|
|
3854
|
+
throw new Error(
|
|
3855
|
+
`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".`
|
|
3856
|
+
);
|
|
3135
3857
|
}
|
|
3136
3858
|
assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
|
|
3137
3859
|
await secretsPush({
|
|
@@ -3144,7 +3866,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3144
3866
|
}
|
|
3145
3867
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
3146
3868
|
}
|
|
3147
|
-
async function provisionCommand(parsed) {
|
|
3869
|
+
async function provisionCommand(parsed, dependencies) {
|
|
3148
3870
|
assertArgs(parsed, [
|
|
3149
3871
|
"config",
|
|
3150
3872
|
"dry-run",
|
|
@@ -3168,10 +3890,38 @@ async function provisionCommand(parsed) {
|
|
|
3168
3890
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
3169
3891
|
token: stringOpt(parsed.options.token),
|
|
3170
3892
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3171
|
-
yes: parsed.options.yes === true
|
|
3893
|
+
yes: parsed.options.yes === true,
|
|
3894
|
+
fetch: dependencies.fetch,
|
|
3895
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3896
|
+
stdout: dependencies.stdout
|
|
3172
3897
|
};
|
|
3173
3898
|
await provision(options);
|
|
3174
3899
|
}
|
|
3900
|
+
async function calendarCommand(parsed, dependencies) {
|
|
3901
|
+
const sub = parsed.positionals[1];
|
|
3902
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
|
|
3903
|
+
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3904
|
+
}
|
|
3905
|
+
assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
|
|
3906
|
+
if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
|
|
3907
|
+
if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
|
|
3908
|
+
const options = {
|
|
3909
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3910
|
+
env: stringOpt(parsed.options.env),
|
|
3911
|
+
token: stringOpt(parsed.options.token),
|
|
3912
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3913
|
+
yes: parsed.options.yes === true,
|
|
3914
|
+
json: parsed.options.json === true,
|
|
3915
|
+
fetch: dependencies.fetch,
|
|
3916
|
+
stdout: dependencies.stdout,
|
|
3917
|
+
openConsentUrl: dependencies.openUrl
|
|
3918
|
+
};
|
|
3919
|
+
if (sub === "status") await calendarStatus(options);
|
|
3920
|
+
else if (sub === "calendars") await calendarCalendars(options);
|
|
3921
|
+
else if (sub === "connect") await calendarConnect(options);
|
|
3922
|
+
else if (sub === "resync") await calendarResync(options);
|
|
3923
|
+
else await calendarDisconnect(options);
|
|
3924
|
+
}
|
|
3175
3925
|
|
|
3176
3926
|
// src/bin.ts
|
|
3177
3927
|
runCli().catch((err) => {
|