@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/index.cjs
CHANGED
|
@@ -33,8 +33,16 @@ var index_exports = {};
|
|
|
33
33
|
__export(index_exports, {
|
|
34
34
|
AGENT_HARNESSES: () => AGENT_HARNESSES,
|
|
35
35
|
CAPABILITIES: () => CAPABILITIES,
|
|
36
|
+
GOOGLE_CALENDAR_READ_SCOPE: () => GOOGLE_CALENDAR_READ_SCOPE,
|
|
36
37
|
SYSTEM_AI_PURPOSES: () => SYSTEM_AI_PURPOSES,
|
|
37
38
|
adminAi: () => adminAi,
|
|
39
|
+
calendarBookingPageUrl: () => calendarBookingPageUrl,
|
|
40
|
+
calendarCalendars: () => calendarCalendars,
|
|
41
|
+
calendarConnect: () => calendarConnect,
|
|
42
|
+
calendarDisconnect: () => calendarDisconnect,
|
|
43
|
+
calendarResync: () => calendarResync,
|
|
44
|
+
calendarServiceConfig: () => calendarServiceConfig,
|
|
45
|
+
calendarStatus: () => calendarStatus,
|
|
38
46
|
connectGitHubSecuritySource: () => connectGitHubSecuritySource,
|
|
39
47
|
disconnectGitHubSecuritySource: () => disconnectGitHubSecuritySource,
|
|
40
48
|
doctor: () => doctor,
|
|
@@ -57,6 +65,8 @@ __export(index_exports, {
|
|
|
57
65
|
runCli: () => runCli,
|
|
58
66
|
runHostedSecurity: () => runHostedSecurity,
|
|
59
67
|
secretsPush: () => secretsPush,
|
|
68
|
+
secretsSet: () => secretsSet,
|
|
69
|
+
secretsSetClerkKey: () => secretsSetClerkKey,
|
|
60
70
|
smoke: () => smoke,
|
|
61
71
|
startHostedSecurityJob: () => startHostedSecurityJob
|
|
62
72
|
});
|
|
@@ -67,7 +77,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
67
77
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
68
78
|
|
|
69
79
|
// src/admin-ai.ts
|
|
70
|
-
var
|
|
80
|
+
var import_node_process5 = __toESM(require("process"), 1);
|
|
71
81
|
|
|
72
82
|
// src/token.ts
|
|
73
83
|
var import_db = require("@odla-ai/db");
|
|
@@ -297,9 +307,32 @@ function platformAudience(value) {
|
|
|
297
307
|
return url.origin;
|
|
298
308
|
}
|
|
299
309
|
|
|
310
|
+
// src/secret-input.ts
|
|
311
|
+
var import_node_process3 = __toESM(require("process"), 1);
|
|
312
|
+
var MAX_BYTES = 64 * 1024;
|
|
313
|
+
async function secretInputValue(options, kind = "credential") {
|
|
314
|
+
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
315
|
+
let value;
|
|
316
|
+
if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
|
|
317
|
+
else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
|
|
318
|
+
else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
|
|
319
|
+
value = value?.replace(/[\r\n]+$/, "");
|
|
320
|
+
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : `stdin ${kind} is empty`);
|
|
321
|
+
if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
async function readSecretStream(kind, stream = import_node_process3.default.stdin) {
|
|
325
|
+
let value = "";
|
|
326
|
+
for await (const chunk of stream) {
|
|
327
|
+
value += String(chunk);
|
|
328
|
+
if (value.length > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
|
|
329
|
+
}
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
|
|
300
333
|
// src/admin-ai-auth.ts
|
|
301
334
|
var import_node_fs2 = require("fs");
|
|
302
|
-
var
|
|
335
|
+
var import_node_process4 = __toESM(require("process"), 1);
|
|
303
336
|
var import_db2 = require("@odla-ai/db");
|
|
304
337
|
async function getScopedPlatformToken(options) {
|
|
305
338
|
return resolveAdminPlatformToken(options);
|
|
@@ -307,7 +340,7 @@ async function getScopedPlatformToken(options) {
|
|
|
307
340
|
async function resolveAdminPlatformToken(options) {
|
|
308
341
|
const audience = platformAudience(options.platform);
|
|
309
342
|
if (options.token) return options.token;
|
|
310
|
-
const fromEnv =
|
|
343
|
+
const fromEnv = import_node_process4.default.env.ODLA_ADMIN_TOKEN;
|
|
311
344
|
if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
|
|
312
345
|
return scopedToken(
|
|
313
346
|
audience,
|
|
@@ -319,7 +352,7 @@ async function resolveAdminPlatformToken(options) {
|
|
|
319
352
|
}
|
|
320
353
|
function audienceBoundEnvToken(token, platform) {
|
|
321
354
|
const audience = platformAudience(platform);
|
|
322
|
-
const declared =
|
|
355
|
+
const declared = import_node_process4.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
|
|
323
356
|
if (declared) {
|
|
324
357
|
if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
|
|
325
358
|
} else if (audience !== "https://odla.ai") {
|
|
@@ -329,7 +362,7 @@ function audienceBoundEnvToken(token, platform) {
|
|
|
329
362
|
}
|
|
330
363
|
async function scopedToken(platform, scope, options, doFetch, out) {
|
|
331
364
|
const audience = platformAudience(platform);
|
|
332
|
-
const tokenFile = options.tokenFile ?? `${
|
|
365
|
+
const tokenFile = options.tokenFile ?? `${import_node_process4.default.cwd()}/.odla/admin-token.local.json`;
|
|
333
366
|
const cache = readJsonFile(tokenFile);
|
|
334
367
|
const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
|
|
335
368
|
if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
|
|
@@ -344,13 +377,13 @@ async function scopedToken(platform, scope, options, doFetch, out) {
|
|
|
344
377
|
onCode: async ({ userCode, expiresIn }) => {
|
|
345
378
|
const approvalUrl = handshakeUrl(audience, userCode);
|
|
346
379
|
out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
|
|
347
|
-
const shouldOpen = options.open === true || options.open !== false && !
|
|
380
|
+
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);
|
|
348
381
|
if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
|
|
349
382
|
}
|
|
350
383
|
});
|
|
351
384
|
const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
|
|
352
385
|
tokens[scope] = { token, expiresAt };
|
|
353
|
-
if ((0, import_node_fs2.existsSync)(`${
|
|
386
|
+
if ((0, import_node_fs2.existsSync)(`${import_node_process4.default.cwd()}/.git`)) ensureGitignore(import_node_process4.default.cwd(), [tokenFile]);
|
|
354
387
|
writePrivateJson(tokenFile, { platform: audience, tokens });
|
|
355
388
|
out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
|
|
356
389
|
return token;
|
|
@@ -511,7 +544,7 @@ function isRecord2(value) {
|
|
|
511
544
|
|
|
512
545
|
// src/admin-ai.ts
|
|
513
546
|
async function adminAi(options) {
|
|
514
|
-
const platform = platformAudience(options.platform ??
|
|
547
|
+
const platform = platformAudience(options.platform ?? import_node_process5.default.env.ODLA_PLATFORM ?? "https://odla.ai");
|
|
515
548
|
const doFetch = options.fetch ?? fetch;
|
|
516
549
|
const out = options.stdout ?? console;
|
|
517
550
|
const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
|
|
@@ -550,7 +583,7 @@ async function adminAi(options) {
|
|
|
550
583
|
}
|
|
551
584
|
if (options.action === "credential-set") {
|
|
552
585
|
const provider = requireProvider(options.credentialProvider);
|
|
553
|
-
const value = await
|
|
586
|
+
const value = await secretInputValue(options);
|
|
554
587
|
const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
|
|
555
588
|
method: "PUT",
|
|
556
589
|
headers,
|
|
@@ -660,25 +693,6 @@ function requireProvider(value) {
|
|
|
660
693
|
}
|
|
661
694
|
return value;
|
|
662
695
|
}
|
|
663
|
-
async function credentialValue(options) {
|
|
664
|
-
if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
|
|
665
|
-
let value;
|
|
666
|
-
if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
|
|
667
|
-
else if (options.stdin) value = await (options.readStdin ?? readStdin)();
|
|
668
|
-
else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
|
|
669
|
-
value = value?.replace(/[\r\n]+$/, "");
|
|
670
|
-
if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
|
|
671
|
-
if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
672
|
-
return value;
|
|
673
|
-
}
|
|
674
|
-
async function readStdin() {
|
|
675
|
-
let value = "";
|
|
676
|
-
for await (const chunk of import_node_process4.default.stdin) {
|
|
677
|
-
value += String(chunk);
|
|
678
|
-
if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
|
|
679
|
-
}
|
|
680
|
-
return value;
|
|
681
|
-
}
|
|
682
696
|
function policyArray(body) {
|
|
683
697
|
if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
|
|
684
698
|
if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
|
|
@@ -848,7 +862,9 @@ var CAPABILITIES = {
|
|
|
848
862
|
"register the app and enable configured services per environment",
|
|
849
863
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
850
864
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
865
|
+
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
851
866
|
"push db schema/rules and configure platform AI, auth, and deployment links",
|
|
867
|
+
"apply read-only Google calendar mirror and public booking-page config, then drive state-bound consent, status, discovery, resync, and disconnect flows",
|
|
852
868
|
"validate config offline and smoke-test a provisioned db environment",
|
|
853
869
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
854
870
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
@@ -857,10 +873,12 @@ var CAPABILITIES = {
|
|
|
857
873
|
agent: [
|
|
858
874
|
"install and import the selected odla SDKs",
|
|
859
875
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
860
|
-
"make application-specific schema, rules, auth, UI, and migration decisions"
|
|
876
|
+
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
877
|
+
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
861
878
|
],
|
|
862
879
|
human: [
|
|
863
880
|
"approve the odla device code and one-off third-party logins",
|
|
881
|
+
"review mirrored calendar/attendee disclosure and complete the server-issued Google consent flow",
|
|
864
882
|
"consent to production changes with --yes",
|
|
865
883
|
"request destructive credential rotation explicitly",
|
|
866
884
|
"review application semantics, security findings, releases, and merges",
|
|
@@ -869,6 +887,7 @@ var CAPABILITIES = {
|
|
|
869
887
|
],
|
|
870
888
|
studio: [
|
|
871
889
|
"view telemetry and environment state",
|
|
890
|
+
"review calendar connection, granted read scope, selected calendars, and sync health without exposing provider tokens",
|
|
872
891
|
"perform manual credential recovery when the CLI's local shown-once copy is unavailable",
|
|
873
892
|
"configure system AI purposes and view app/environment/run-attributed usage",
|
|
874
893
|
"connect/revoke GitHub security sources and inspect ref-to-SHA jobs, routes, retention, coverage, and normalized reports"
|
|
@@ -898,6 +917,7 @@ var import_node_url = require("url");
|
|
|
898
917
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
899
918
|
var DEFAULT_ENVS = ["dev"];
|
|
900
919
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
920
|
+
var GOOGLE_CALENDAR_READ_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
|
|
901
921
|
async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
902
922
|
const resolved = (0, import_node_path2.resolve)(configPath);
|
|
903
923
|
if (!(0, import_node_fs3.existsSync)(resolved)) {
|
|
@@ -910,6 +930,7 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
910
930
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
911
931
|
const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
912
932
|
const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
933
|
+
validateCalendarConfig(raw, envs, services, resolved);
|
|
913
934
|
const local = {
|
|
914
935
|
tokenFile: (0, import_node_path2.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
915
936
|
credentialsFile: (0, import_node_path2.resolve)(rootDir, raw.local?.credentialsFile ?? ".odla/credentials.local.json"),
|
|
@@ -954,6 +975,28 @@ function buildPlan(cfg) {
|
|
|
954
975
|
aiProvider: cfg.ai?.provider
|
|
955
976
|
};
|
|
956
977
|
}
|
|
978
|
+
function calendarServiceConfig(cfg, env) {
|
|
979
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
980
|
+
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
981
|
+
const google = cfg.calendar?.google;
|
|
982
|
+
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
983
|
+
return {
|
|
984
|
+
provider: "google",
|
|
985
|
+
access: "read",
|
|
986
|
+
calendars: unique(google.calendars[env].map((id) => id.trim())),
|
|
987
|
+
match: {
|
|
988
|
+
organizerSelf: google.match?.organizerSelf ?? true,
|
|
989
|
+
requireAttendees: google.match?.requireAttendees ?? true,
|
|
990
|
+
...google.match?.summaryPrefix !== void 0 ? { summaryPrefix: google.match.summaryPrefix.trim() } : {}
|
|
991
|
+
},
|
|
992
|
+
attendeePolicy: google.attendeePolicy ?? "full"
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function calendarBookingPageUrl(cfg, env) {
|
|
996
|
+
const value = cfg.calendar?.google.bookingPageUrl?.[env];
|
|
997
|
+
if (value === void 0 || value === null) return value;
|
|
998
|
+
return new URL(value).toString();
|
|
999
|
+
}
|
|
957
1000
|
function rulesFromSchema(schema) {
|
|
958
1001
|
const entities = serializedEntities(schema);
|
|
959
1002
|
return Object.fromEntries(
|
|
@@ -977,7 +1020,85 @@ function validateRawConfig(raw, path) {
|
|
|
977
1020
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
978
1021
|
if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
979
1022
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
1023
|
+
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
1024
|
+
throw new Error(`${path}: envs must be an array of names`);
|
|
1025
|
+
}
|
|
980
1026
|
if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
1027
|
+
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
1028
|
+
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function validateCalendarConfig(cfg, envs, services, path) {
|
|
1032
|
+
const enabled = services.includes("calendar");
|
|
1033
|
+
if (!cfg.calendar) {
|
|
1034
|
+
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
1038
|
+
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
1039
|
+
if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
1040
|
+
const google = cfg.calendar.google;
|
|
1041
|
+
assertOnly(google, ["calendars", "bookingPageUrl", "match", "attendeePolicy"], `${path}: calendar.google`);
|
|
1042
|
+
if (!isRecord4(google.calendars)) throw new Error(`${path}: calendar.google.calendars must map env names to calendar ids`);
|
|
1043
|
+
const unknownEnv = Object.keys(google.calendars).find((env) => !envs.includes(env));
|
|
1044
|
+
if (unknownEnv) throw new Error(`${path}: calendar.google.calendars.${unknownEnv} is not in config envs`);
|
|
1045
|
+
for (const env of envs) {
|
|
1046
|
+
const ids = google.calendars[env];
|
|
1047
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
1048
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must be a non-empty array`);
|
|
1049
|
+
}
|
|
1050
|
+
if (ids.length > 10) {
|
|
1051
|
+
throw new Error(`${path}: calendar.google.calendars.${env} must contain at most 10 calendar ids`);
|
|
1052
|
+
}
|
|
1053
|
+
if (ids.some((id) => !safeText(id, 1024))) {
|
|
1054
|
+
throw new Error(`${path}: calendar.google.calendars.${env} contains an invalid calendar id`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (google.bookingPageUrl !== void 0) {
|
|
1058
|
+
if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
1059
|
+
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
1060
|
+
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
1061
|
+
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
1062
|
+
if (value !== null && !safeHttpsUrl(value)) {
|
|
1063
|
+
throw new Error(`${path}: calendar.google.bookingPageUrl.${env} must be an HTTPS URL without credentials or fragment`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (google.attendeePolicy !== void 0 && google.attendeePolicy !== "full" && google.attendeePolicy !== "hashed") {
|
|
1068
|
+
throw new Error(`${path}: calendar.google.attendeePolicy must be "full" or "hashed"`);
|
|
1069
|
+
}
|
|
1070
|
+
if (google.match !== void 0) {
|
|
1071
|
+
if (!isRecord4(google.match)) throw new Error(`${path}: calendar.google.match must be an object`);
|
|
1072
|
+
assertOnly(google.match, ["summaryPrefix", "organizerSelf", "requireAttendees"], `${path}: calendar.google.match`);
|
|
1073
|
+
if (google.match.summaryPrefix !== void 0 && !safeText(google.match.summaryPrefix, 256)) {
|
|
1074
|
+
throw new Error(`${path}: calendar.google.match.summaryPrefix must be 1-256 printable characters`);
|
|
1075
|
+
}
|
|
1076
|
+
for (const name of ["organizerSelf", "requireAttendees"]) {
|
|
1077
|
+
if (google.match[name] !== void 0 && typeof google.match[name] !== "boolean") {
|
|
1078
|
+
throw new Error(`${path}: calendar.google.match.${name} must be boolean`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (enabled && !services.includes("db")) throw new Error(`${path}: calendar service requires the db service`);
|
|
1083
|
+
}
|
|
1084
|
+
function assertOnly(value, allowed, label) {
|
|
1085
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
1086
|
+
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
1087
|
+
}
|
|
1088
|
+
function isRecord4(value) {
|
|
1089
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1090
|
+
}
|
|
1091
|
+
function safeText(value, max) {
|
|
1092
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1093
|
+
}
|
|
1094
|
+
function safeHttpsUrl(value) {
|
|
1095
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
1096
|
+
try {
|
|
1097
|
+
const url = new URL(value);
|
|
1098
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.hash;
|
|
1099
|
+
} catch {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
981
1102
|
}
|
|
982
1103
|
function validId(value) {
|
|
983
1104
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
@@ -996,11 +1117,6 @@ function unique(values) {
|
|
|
996
1117
|
return [...new Set(values.filter(Boolean))];
|
|
997
1118
|
}
|
|
998
1119
|
|
|
999
|
-
// src/doctor-checks.ts
|
|
1000
|
-
var import_node_child_process3 = require("child_process");
|
|
1001
|
-
var import_node_fs5 = require("fs");
|
|
1002
|
-
var import_node_path4 = require("path");
|
|
1003
|
-
|
|
1004
1120
|
// src/redact.ts
|
|
1005
1121
|
var REPLACEMENTS = [
|
|
1006
1122
|
[/odla_(sk|dev)_[A-Za-z0-9._-]+/g, "odla_$1_[redacted]"],
|
|
@@ -1020,6 +1136,426 @@ function looksSecret(value) {
|
|
|
1020
1136
|
return redactSecrets(value) !== value || value.includes("-----BEGIN");
|
|
1021
1137
|
}
|
|
1022
1138
|
|
|
1139
|
+
// src/calendar-http.ts
|
|
1140
|
+
var CALENDAR_STATES = [
|
|
1141
|
+
"not_connected",
|
|
1142
|
+
"authorizing",
|
|
1143
|
+
"needs_sync",
|
|
1144
|
+
"initial_sync",
|
|
1145
|
+
"healthy",
|
|
1146
|
+
"degraded",
|
|
1147
|
+
"disconnected",
|
|
1148
|
+
"failed"
|
|
1149
|
+
];
|
|
1150
|
+
async function readCalendarStatus(ctx) {
|
|
1151
|
+
return parseCalendarStatus(await calendarJson(ctx, "", {}), ctx.env);
|
|
1152
|
+
}
|
|
1153
|
+
async function discoverGoogleCalendars(ctx) {
|
|
1154
|
+
const raw = await calendarJson(ctx, "/calendars", {});
|
|
1155
|
+
const value = record(raw);
|
|
1156
|
+
if (!value || !Array.isArray(value.calendars)) throw new Error("calendar discovery returned an invalid response");
|
|
1157
|
+
return value.calendars.map((item, index) => {
|
|
1158
|
+
const calendar = record(item);
|
|
1159
|
+
const id = textField(calendar?.id, 1024);
|
|
1160
|
+
if (!calendar || !id) throw new Error(`calendar discovery returned an invalid calendar at index ${index}`);
|
|
1161
|
+
const role = calendar.accessRole;
|
|
1162
|
+
if (role !== void 0 && role !== "freeBusyReader" && role !== "reader" && role !== "writer" && role !== "owner") {
|
|
1163
|
+
throw new Error(`calendar discovery returned an invalid access role at index ${index}`);
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
id,
|
|
1167
|
+
...optionalText("summary", calendar.summary, 500),
|
|
1168
|
+
...typeof calendar.primary === "boolean" ? { primary: calendar.primary } : {},
|
|
1169
|
+
...typeof calendar.selected === "boolean" ? { selected: calendar.selected } : {},
|
|
1170
|
+
...typeof role === "string" ? { accessRole: role } : {}
|
|
1171
|
+
};
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
async function applyCalendarSettings(ctx, bookingPageUrl) {
|
|
1175
|
+
return parseCalendarStatus(await calendarJson(ctx, "", { method: "PUT", body: JSON.stringify({ bookingPageUrl }) }), ctx.env);
|
|
1176
|
+
}
|
|
1177
|
+
async function startCalendarConnection(ctx) {
|
|
1178
|
+
const raw = await calendarJson(ctx, "/google/connect", { method: "POST", body: "{}" });
|
|
1179
|
+
const value = wrapped(raw, "attempt");
|
|
1180
|
+
const attemptId = textField(value.attemptId, 180);
|
|
1181
|
+
const consentUrl = textField(value.consentUrl, 4096);
|
|
1182
|
+
const expiresAt = timestamp3(value.expiresAt);
|
|
1183
|
+
if (!attemptId || !consentUrl || expiresAt === void 0) throw new Error("calendar connect returned an invalid attempt");
|
|
1184
|
+
return { attemptId, consentUrl, expiresAt };
|
|
1185
|
+
}
|
|
1186
|
+
async function pollCalendarConnection(ctx, attemptId) {
|
|
1187
|
+
return parseCalendarStatus(
|
|
1188
|
+
await calendarJson(ctx, `/google/connect/${encodeURIComponent(identifier(attemptId, "attemptId"))}`, {}),
|
|
1189
|
+
ctx.env
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
async function requestCalendarResync(ctx) {
|
|
1193
|
+
return parseCalendarStatus(await calendarJson(ctx, "/resync", { method: "POST", body: "{}" }), ctx.env);
|
|
1194
|
+
}
|
|
1195
|
+
async function requestCalendarDisconnect(ctx) {
|
|
1196
|
+
return parseCalendarStatus(
|
|
1197
|
+
await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
|
|
1198
|
+
ctx.env
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
function parseCalendarStatus(raw, env) {
|
|
1202
|
+
const outer = wrapped(raw, "calendar");
|
|
1203
|
+
const value = record(outer.attempt) ?? record(outer.status) ?? outer;
|
|
1204
|
+
const connection = record(value.connection) ?? {};
|
|
1205
|
+
const sync = record(value.sync) ?? record(connection.sync) ?? {};
|
|
1206
|
+
const config = record(value.config) ?? record(outer.config) ?? {};
|
|
1207
|
+
const googleConfig = record(config.google) ?? config;
|
|
1208
|
+
const watches = record(value.watches) ?? {};
|
|
1209
|
+
const stateValue = calendarState(value.status ?? value.state ?? connection.status ?? connection.state);
|
|
1210
|
+
if (!stateValue) {
|
|
1211
|
+
throw new Error("calendar status returned an invalid connection state");
|
|
1212
|
+
}
|
|
1213
|
+
const providerValue = value.provider ?? connection.provider ?? config.provider ?? (config.google ? "google" : void 0);
|
|
1214
|
+
if (providerValue !== void 0 && providerValue !== null && providerValue !== "google") {
|
|
1215
|
+
throw new Error("calendar status returned an unsupported provider");
|
|
1216
|
+
}
|
|
1217
|
+
const accessValue = value.access ?? connection.access ?? config.access ?? googleConfig.access;
|
|
1218
|
+
if (accessValue !== void 0 && accessValue !== "read") throw new Error("calendar status returned unsupported access");
|
|
1219
|
+
const policyValue = value.attendeePolicy ?? config.attendeePolicy ?? googleConfig.attendeePolicy;
|
|
1220
|
+
if (policyValue !== void 0 && policyValue !== "full" && policyValue !== "hashed") {
|
|
1221
|
+
throw new Error("calendar status returned an invalid attendee policy");
|
|
1222
|
+
}
|
|
1223
|
+
const errorValue = record(value.error) ?? record(connection.error);
|
|
1224
|
+
const errorCode = textField(value.lastErrorCode, 128);
|
|
1225
|
+
const bookingPageValue = Object.hasOwn(value, "bookingPageUrl") ? value.bookingPageUrl : Object.hasOwn(config, "bookingPageUrl") ? config.bookingPageUrl : googleConfig.bookingPageUrl;
|
|
1226
|
+
return {
|
|
1227
|
+
env,
|
|
1228
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
|
1229
|
+
connected: typeof (value.connected ?? connection.connected) === "boolean" ? Boolean(value.connected ?? connection.connected) : ["needs_sync", "initial_sync", "healthy", "degraded"].includes(stateValue),
|
|
1230
|
+
provider: providerValue === "google" ? "google" : null,
|
|
1231
|
+
status: stateValue,
|
|
1232
|
+
...accessValue === "read" ? { access: "read" } : {},
|
|
1233
|
+
calendars: calendarIds(value.configuredCalendars ?? value.calendars ?? config.calendars ?? googleConfig.calendars),
|
|
1234
|
+
...policyValue === "full" || policyValue === "hashed" ? { attendeePolicy: policyValue } : {},
|
|
1235
|
+
...typeof value.attendeePolicyLocked === "boolean" ? { attendeePolicyLocked: value.attendeePolicyLocked } : {},
|
|
1236
|
+
...optionalNullableUrl("bookingPageUrl", bookingPageValue),
|
|
1237
|
+
grantedScopes: stringList(value.grantedScopes ?? connection.grantedScopes ?? connection.scopes),
|
|
1238
|
+
...optionalText("attemptId", value.attemptId ?? connection.attemptId, 180),
|
|
1239
|
+
...optionalNumber("lastSuccessfulSyncAt", value.lastSuccessfulSyncAt ?? sync.lastSuccessfulSyncAt),
|
|
1240
|
+
...optionalNumber("lastFullSyncAt", value.lastFullSyncAt ?? sync.lastFullSyncAt),
|
|
1241
|
+
...optionalNumber("watchExpiresAt", value.watchExpiresAt ?? sync.watchExpiresAt ?? watches.nextExpirationAt),
|
|
1242
|
+
...optionalInteger("bookingCount", value.bookingCount ?? sync.bookingCount),
|
|
1243
|
+
...errorValue || errorCode ? { error: {
|
|
1244
|
+
...textField(errorValue?.code, 128) ? { code: textField(errorValue?.code, 128) } : {},
|
|
1245
|
+
...textField(errorValue?.message, 500) ? { message: textField(errorValue?.message, 500) } : {},
|
|
1246
|
+
...!errorValue && errorCode ? { code: errorCode } : {}
|
|
1247
|
+
} } : {}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
function calendarState(value) {
|
|
1251
|
+
if (typeof value !== "string") return null;
|
|
1252
|
+
if (CALENDAR_STATES.includes(value)) return value;
|
|
1253
|
+
const legacy = {
|
|
1254
|
+
disabled: "not_connected",
|
|
1255
|
+
disconnected: "disconnected",
|
|
1256
|
+
connecting: "authorizing",
|
|
1257
|
+
syncing: "initial_sync",
|
|
1258
|
+
ready: "healthy",
|
|
1259
|
+
error: "failed"
|
|
1260
|
+
};
|
|
1261
|
+
return legacy[value] ?? null;
|
|
1262
|
+
}
|
|
1263
|
+
async function calendarJson(ctx, suffix, init) {
|
|
1264
|
+
const platform = platformAudience(ctx.platform);
|
|
1265
|
+
const appId = identifier(ctx.appId, "appId");
|
|
1266
|
+
const env = identifier(ctx.env, "env");
|
|
1267
|
+
const url = new URL(`/registry/apps/${encodeURIComponent(appId)}/calendar${suffix}`, platform);
|
|
1268
|
+
url.searchParams.set("env", env);
|
|
1269
|
+
const token = credential(ctx.token);
|
|
1270
|
+
let response;
|
|
1271
|
+
try {
|
|
1272
|
+
response = await (ctx.fetch ?? fetch)(url, {
|
|
1273
|
+
...init,
|
|
1274
|
+
redirect: "error",
|
|
1275
|
+
credentials: "omit",
|
|
1276
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }
|
|
1277
|
+
});
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
throw new Error(`calendar request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1280
|
+
}
|
|
1281
|
+
const body = await response.json().catch(() => ({}));
|
|
1282
|
+
if (!response.ok) {
|
|
1283
|
+
const code = textField(body.error?.code, 128);
|
|
1284
|
+
const message = textField(body.error?.message, 500);
|
|
1285
|
+
throw new Error(redactSecrets(`calendar request failed (${response.status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`));
|
|
1286
|
+
}
|
|
1287
|
+
return body;
|
|
1288
|
+
}
|
|
1289
|
+
function wrapped(raw, key) {
|
|
1290
|
+
const outer = record(raw);
|
|
1291
|
+
if (!outer) throw new Error("calendar returned an invalid response");
|
|
1292
|
+
return record(outer[key]) ?? outer;
|
|
1293
|
+
}
|
|
1294
|
+
function record(value) {
|
|
1295
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1296
|
+
}
|
|
1297
|
+
function textField(value, max) {
|
|
1298
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) ? value : void 0;
|
|
1299
|
+
}
|
|
1300
|
+
function stringList(value) {
|
|
1301
|
+
return Array.isArray(value) ? [...new Set(value.filter((item) => !!textField(item, 4096)))] : [];
|
|
1302
|
+
}
|
|
1303
|
+
function calendarIds(value) {
|
|
1304
|
+
if (!Array.isArray(value)) return [];
|
|
1305
|
+
return [...new Set(value.flatMap((item) => {
|
|
1306
|
+
if (typeof item === "string") return textField(item, 4096) ? [item] : [];
|
|
1307
|
+
const calendar = record(item);
|
|
1308
|
+
const id = textField(calendar?.id, 4096);
|
|
1309
|
+
return id && calendar?.selected !== false ? [id] : [];
|
|
1310
|
+
}))];
|
|
1311
|
+
}
|
|
1312
|
+
function timestamp3(value) {
|
|
1313
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
1314
|
+
if (typeof value === "string") {
|
|
1315
|
+
const parsed = Date.parse(value);
|
|
1316
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
1317
|
+
}
|
|
1318
|
+
return void 0;
|
|
1319
|
+
}
|
|
1320
|
+
function optionalText(key, value, max) {
|
|
1321
|
+
const parsed = textField(value, max);
|
|
1322
|
+
return parsed ? { [key]: parsed } : {};
|
|
1323
|
+
}
|
|
1324
|
+
function optionalNumber(key, value) {
|
|
1325
|
+
const parsed = timestamp3(value);
|
|
1326
|
+
return parsed === void 0 ? {} : { [key]: parsed };
|
|
1327
|
+
}
|
|
1328
|
+
function optionalInteger(key, value) {
|
|
1329
|
+
return Number.isSafeInteger(value) && value >= 0 ? { [key]: value } : {};
|
|
1330
|
+
}
|
|
1331
|
+
function optionalNullableUrl(key, value) {
|
|
1332
|
+
if (value === null) return { [key]: null };
|
|
1333
|
+
const parsed = textField(value, 4096);
|
|
1334
|
+
return parsed ? { [key]: parsed } : {};
|
|
1335
|
+
}
|
|
1336
|
+
function identifier(value, name) {
|
|
1337
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
1338
|
+
return value;
|
|
1339
|
+
}
|
|
1340
|
+
function credential(value) {
|
|
1341
|
+
if (typeof value !== "string" || value.length < 8 || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) {
|
|
1342
|
+
throw new Error("calendar requires an odla developer token");
|
|
1343
|
+
}
|
|
1344
|
+
return value;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/calendar-poll.ts
|
|
1348
|
+
async function waitForCalendarPoll(milliseconds, signal) {
|
|
1349
|
+
if (signal?.aborted) throw signal.reason ?? new Error("calendar connection aborted");
|
|
1350
|
+
await new Promise((resolve7, reject) => {
|
|
1351
|
+
const timer = setTimeout(resolve7, milliseconds);
|
|
1352
|
+
signal?.addEventListener("abort", () => {
|
|
1353
|
+
clearTimeout(timer);
|
|
1354
|
+
reject(signal.reason ?? new Error("calendar connection aborted"));
|
|
1355
|
+
}, { once: true });
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// src/calendar.ts
|
|
1360
|
+
async function calendarStatus(options) {
|
|
1361
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1362
|
+
const status = await readCalendarStatus(ctx);
|
|
1363
|
+
printStatus(status, options.json === true, out);
|
|
1364
|
+
return status;
|
|
1365
|
+
}
|
|
1366
|
+
async function calendarCalendars(options) {
|
|
1367
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1368
|
+
const calendars = await discoverGoogleCalendars(ctx);
|
|
1369
|
+
if (options.json) out.log(JSON.stringify({ env: ctx.env, calendars }, null, 2));
|
|
1370
|
+
else if (calendars.length === 0) out.log(`${ctx.env}: no Google calendars available`);
|
|
1371
|
+
else for (const calendar of calendars) {
|
|
1372
|
+
out.log(`${calendar.selected ? "\u2713" : calendar.primary ? "*" : " "} ${calendar.id}${calendar.summary ? ` \u2014 ${calendar.summary}` : ""}${calendar.accessRole ? ` (${calendar.accessRole})` : ""}`);
|
|
1373
|
+
}
|
|
1374
|
+
return calendars;
|
|
1375
|
+
}
|
|
1376
|
+
async function calendarConnect(options) {
|
|
1377
|
+
const { cfg, ctx, out } = await lifecycleContext(options);
|
|
1378
|
+
productionConsent(ctx.env, options.yes, "connect calendar");
|
|
1379
|
+
const page = calendarBookingPageUrl(cfg, ctx.env);
|
|
1380
|
+
const applied = page === void 0 ? await readCalendarStatus(ctx) : await applyCalendarSettings(ctx, page);
|
|
1381
|
+
const connectOptions = connectionOptions(options, out);
|
|
1382
|
+
return await continueConnectedCalendar(ctx, applied, connectOptions, false) ?? connectWithContext(ctx, connectOptions);
|
|
1383
|
+
}
|
|
1384
|
+
async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
|
|
1385
|
+
if (bookingPageUrl === void 0) return null;
|
|
1386
|
+
const status = await applyCalendarSettings(ctx, bookingPageUrl);
|
|
1387
|
+
out.log(`${ctx.env}: calendar booking page ${bookingPageUrl ? "set" : "cleared"}`);
|
|
1388
|
+
return status;
|
|
1389
|
+
}
|
|
1390
|
+
async function calendarResync(options) {
|
|
1391
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1392
|
+
productionConsent(ctx.env, options.yes, "resync calendar");
|
|
1393
|
+
const status = await requestCalendarResync(ctx);
|
|
1394
|
+
out.log(`${ctx.env}: calendar resync requested (${status.status})`);
|
|
1395
|
+
return status;
|
|
1396
|
+
}
|
|
1397
|
+
async function calendarDisconnect(options) {
|
|
1398
|
+
if (!options.yes) throw new Error("calendar disconnect requires --yes");
|
|
1399
|
+
const { ctx, out } = await lifecycleContext(options);
|
|
1400
|
+
const status = await requestCalendarDisconnect(ctx);
|
|
1401
|
+
out.log(`${ctx.env}: calendar disconnected; retained mirror rows may now be stale`);
|
|
1402
|
+
return status;
|
|
1403
|
+
}
|
|
1404
|
+
async function ensureCalendarConnected(ctx, options) {
|
|
1405
|
+
const out = options.stdout ?? console;
|
|
1406
|
+
const current = await readCalendarStatus(ctx);
|
|
1407
|
+
const connectOptions = connectionOptions(options, out);
|
|
1408
|
+
return await continueConnectedCalendar(ctx, current, connectOptions, true) ?? connectWithContext(ctx, connectOptions);
|
|
1409
|
+
}
|
|
1410
|
+
async function lifecycleContext(options) {
|
|
1411
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
1412
|
+
if (!cfg.services.includes("calendar")) throw new Error("calendar service is not enabled in config services");
|
|
1413
|
+
const env = options.env ?? (cfg.envs.includes("dev") ? "dev" : cfg.envs[0]);
|
|
1414
|
+
if (!env || !cfg.envs.includes(env)) throw new Error(`env "${env ?? ""}" is not declared in ${options.configPath}`);
|
|
1415
|
+
calendarServiceConfig(cfg, env);
|
|
1416
|
+
const out = options.stdout ?? console;
|
|
1417
|
+
const doFetch = options.fetch ?? fetch;
|
|
1418
|
+
const token = await getDeveloperToken(
|
|
1419
|
+
cfg,
|
|
1420
|
+
{
|
|
1421
|
+
configPath: cfg.configPath,
|
|
1422
|
+
token: options.token,
|
|
1423
|
+
open: options.open,
|
|
1424
|
+
interactive: options.interactive,
|
|
1425
|
+
openApprovalUrl: options.openConsentUrl
|
|
1426
|
+
},
|
|
1427
|
+
doFetch,
|
|
1428
|
+
out
|
|
1429
|
+
);
|
|
1430
|
+
return { cfg, ctx: { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch }, out };
|
|
1431
|
+
}
|
|
1432
|
+
function connectionOptions(options, out) {
|
|
1433
|
+
const browser = approvalBrowser({ configPath: "odla.config.mjs", open: options.open, interactive: options.interactive });
|
|
1434
|
+
return {
|
|
1435
|
+
out,
|
|
1436
|
+
open: browser.open,
|
|
1437
|
+
openConsentUrl: options.openConsentUrl,
|
|
1438
|
+
wait: options.wait,
|
|
1439
|
+
pollTimeoutMs: options.pollTimeoutMs,
|
|
1440
|
+
now: options.now,
|
|
1441
|
+
signal: options.signal
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
async function continueConnectedCalendar(ctx, current, options, reuseDegraded) {
|
|
1445
|
+
if (current.status === "healthy" || reuseDegraded && current.status === "degraded") {
|
|
1446
|
+
options.out.log(`${ctx.env}: calendar ${reuseDegraded ? "connected" : "already connected"} (${current.status})`);
|
|
1447
|
+
return current;
|
|
1448
|
+
}
|
|
1449
|
+
if (current.status === "degraded") return null;
|
|
1450
|
+
if (current.status === "needs_sync") {
|
|
1451
|
+
if (!current.connected) return null;
|
|
1452
|
+
options.out.log(`${ctx.env}: calendar configuration needs sync`);
|
|
1453
|
+
const synced = await requestCalendarResync(ctx);
|
|
1454
|
+
options.out.log(`${ctx.env}: calendar ${synced.status.replaceAll("_", " ")}`);
|
|
1455
|
+
return synced;
|
|
1456
|
+
}
|
|
1457
|
+
if (current.status === "failed" && current.connected) {
|
|
1458
|
+
const reason = current.error?.message ?? current.error?.code;
|
|
1459
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1460
|
+
}
|
|
1461
|
+
const waitingForExisting = current.status === "authorizing" || current.status === "initial_sync" && current.connected;
|
|
1462
|
+
if (!waitingForExisting) return null;
|
|
1463
|
+
const now = options.now ?? Date.now;
|
|
1464
|
+
const deadline = now() + pollTimeout(options.pollTimeoutMs);
|
|
1465
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1466
|
+
let prior = "";
|
|
1467
|
+
while (now() < deadline) {
|
|
1468
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1469
|
+
const status = await readCalendarStatus(ctx);
|
|
1470
|
+
if (status.status !== prior) {
|
|
1471
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1472
|
+
prior = status.status;
|
|
1473
|
+
}
|
|
1474
|
+
if (status.status === "healthy" || reuseDegraded && status.status === "degraded") return status;
|
|
1475
|
+
if (status.status === "degraded") return null;
|
|
1476
|
+
if (status.status === "needs_sync") return requestCalendarResync(ctx);
|
|
1477
|
+
if (status.status === "failed" && status.connected) {
|
|
1478
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1479
|
+
throw new Error(`calendar connection failed${reason ? `: ${reason}` : ""}; inspect status and resync or reconnect explicitly`);
|
|
1480
|
+
}
|
|
1481
|
+
if (status.status !== "authorizing" && (status.status !== "initial_sync" || !status.connected)) return null;
|
|
1482
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1483
|
+
}
|
|
1484
|
+
throw new Error('Existing Google Calendar authorization or initial sync timed out; run "odla-ai calendar status" and retry');
|
|
1485
|
+
}
|
|
1486
|
+
async function connectWithContext(ctx, options) {
|
|
1487
|
+
const attempt = await startCalendarConnection(ctx);
|
|
1488
|
+
const consentUrl = trustedCalendarConsentUrl(ctx.platform, attempt.consentUrl);
|
|
1489
|
+
options.out.log(`${ctx.env}: Google Calendar consent: ${consentUrl}`);
|
|
1490
|
+
if (options.open) {
|
|
1491
|
+
await (options.openConsentUrl ?? openUrl)(consentUrl);
|
|
1492
|
+
options.out.log(`${ctx.env}: opened Google Calendar consent in your browser`);
|
|
1493
|
+
}
|
|
1494
|
+
const now = options.now ?? Date.now;
|
|
1495
|
+
const timeout = pollTimeout(options.pollTimeoutMs);
|
|
1496
|
+
const deadline = Math.min(attempt.expiresAt, now() + timeout);
|
|
1497
|
+
const wait = options.wait ?? waitForCalendarPoll;
|
|
1498
|
+
let prior = "";
|
|
1499
|
+
while (now() < deadline) {
|
|
1500
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new Error("calendar connection aborted");
|
|
1501
|
+
const status = await pollCalendarConnection(ctx, attempt.attemptId);
|
|
1502
|
+
if (status.status !== prior) {
|
|
1503
|
+
options.out.log(`${ctx.env}: calendar ${status.status.replaceAll("_", " ")}`);
|
|
1504
|
+
prior = status.status;
|
|
1505
|
+
}
|
|
1506
|
+
if (status.status === "healthy" || status.status === "degraded") return status;
|
|
1507
|
+
if (status.status === "failed" || status.status === "disconnected" || status.status === "not_connected") {
|
|
1508
|
+
const reason = status.error?.message ?? status.error?.code;
|
|
1509
|
+
throw new Error(`calendar connection ${status.status}${reason ? `: ${reason}` : ""}`);
|
|
1510
|
+
}
|
|
1511
|
+
await wait(Math.min(2e3, Math.max(0, deadline - now())), options.signal);
|
|
1512
|
+
}
|
|
1513
|
+
throw new Error('Google Calendar consent or initial sync timed out; run "odla-ai calendar status" and retry connect');
|
|
1514
|
+
}
|
|
1515
|
+
function trustedCalendarConsentUrl(platform, value) {
|
|
1516
|
+
const origin = platformAudience(platform);
|
|
1517
|
+
let url;
|
|
1518
|
+
try {
|
|
1519
|
+
url = value.startsWith("/") ? new URL(value, origin) : new URL(value);
|
|
1520
|
+
} catch {
|
|
1521
|
+
throw new Error("odla.ai returned an invalid calendar consent URL");
|
|
1522
|
+
}
|
|
1523
|
+
const platformPage = url.origin === origin;
|
|
1524
|
+
const googlePage = url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
1525
|
+
if (!platformPage && !googlePage || url.username || url.password || url.hash) {
|
|
1526
|
+
throw new Error("odla.ai returned an untrusted calendar consent URL");
|
|
1527
|
+
}
|
|
1528
|
+
return url.toString();
|
|
1529
|
+
}
|
|
1530
|
+
function pollTimeout(value = 10 * 6e4) {
|
|
1531
|
+
if (!Number.isSafeInteger(value) || value < 1e3 || value > 60 * 6e4) throw new Error("calendar poll timeout must be 1000-3600000ms");
|
|
1532
|
+
return value;
|
|
1533
|
+
}
|
|
1534
|
+
function productionConsent(env, yes, action) {
|
|
1535
|
+
if ((env === "prod" || env === "production") && !yes) throw new Error(`refusing to ${action} for "${env}" without --yes`);
|
|
1536
|
+
}
|
|
1537
|
+
function printStatus(status, json, out) {
|
|
1538
|
+
if (json) {
|
|
1539
|
+
out.log(JSON.stringify(status, null, 2));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
out.log(`calendar: ${status.provider ?? "none"}/${status.status} (${status.env})`);
|
|
1543
|
+
out.log(` access: ${status.access ?? "not granted"}`);
|
|
1544
|
+
out.log(` calendars: ${status.calendars.length ? status.calendars.join(", ") : "none"}`);
|
|
1545
|
+
if (status.attendeePolicy) {
|
|
1546
|
+
out.log(` attendee policy: ${status.attendeePolicy}${status.attendeePolicyLocked ? " (locked)" : ""}`);
|
|
1547
|
+
}
|
|
1548
|
+
if (status.bookingPageUrl !== void 0) out.log(` booking page: ${status.bookingPageUrl ?? "not configured"}`);
|
|
1549
|
+
out.log(` last sync: ${status.lastSuccessfulSyncAt === void 0 ? "never" : new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
1550
|
+
if (status.bookingCount !== void 0) out.log(` bookings: ${status.bookingCount}`);
|
|
1551
|
+
if (status.error?.code || status.error?.message) out.log(` error: ${status.error.code ?? "error"}${status.error.message ? ` \u2014 ${status.error.message}` : ""}`);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// src/doctor-checks.ts
|
|
1555
|
+
var import_node_child_process3 = require("child_process");
|
|
1556
|
+
var import_node_fs5 = require("fs");
|
|
1557
|
+
var import_node_path4 = require("path");
|
|
1558
|
+
|
|
1023
1559
|
// src/wrangler.ts
|
|
1024
1560
|
var import_node_child_process2 = require("child_process");
|
|
1025
1561
|
var import_node_fs4 = require("fs");
|
|
@@ -1200,6 +1736,15 @@ function o11yProjectWarnings(rootDir) {
|
|
|
1200
1736
|
}
|
|
1201
1737
|
return warnings;
|
|
1202
1738
|
}
|
|
1739
|
+
function calendarProjectWarnings(rootDir) {
|
|
1740
|
+
const pkg = readPackageJson(rootDir);
|
|
1741
|
+
const dependencies = pkg?.dependencies;
|
|
1742
|
+
const devDependencies = pkg?.devDependencies;
|
|
1743
|
+
if (dependencies?.["@odla-ai/calendar"]) return [];
|
|
1744
|
+
return [
|
|
1745
|
+
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"
|
|
1746
|
+
];
|
|
1747
|
+
}
|
|
1203
1748
|
function readPackageJson(rootDir) {
|
|
1204
1749
|
try {
|
|
1205
1750
|
return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(rootDir, "package.json"), "utf8"));
|
|
@@ -1225,6 +1770,14 @@ async function doctor(options) {
|
|
|
1225
1770
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
1226
1771
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
1227
1772
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
1773
|
+
if (cfg.services.includes("calendar")) {
|
|
1774
|
+
const calendar = cfg.envs.map((env) => `${env}:${calendarServiceConfig(cfg, env).calendars.length}`).join(", ");
|
|
1775
|
+
out.log(`calendar: google/read-only (${calendar}; attendee ${cfg.calendar?.google.attendeePolicy ?? "full"})`);
|
|
1776
|
+
const pages = cfg.envs.map((env) => `${env}:${calendarBookingPageUrl(cfg, env) ?? "none"}`).join(", ");
|
|
1777
|
+
out.log(`booking pages: ${pages}`);
|
|
1778
|
+
} else {
|
|
1779
|
+
out.log("calendar: not enabled");
|
|
1780
|
+
}
|
|
1228
1781
|
const warnings = [];
|
|
1229
1782
|
if (schema && entities.length === 0) warnings.push("schema has no entities");
|
|
1230
1783
|
if (rules) {
|
|
@@ -1252,6 +1805,8 @@ async function doctor(options) {
|
|
|
1252
1805
|
}
|
|
1253
1806
|
warnings.push(...o11yProjectWarnings(cfg.rootDir));
|
|
1254
1807
|
}
|
|
1808
|
+
if (cfg.services.includes("calendar")) warnings.push(...calendarProjectWarnings(cfg.rootDir));
|
|
1809
|
+
else if (cfg.calendar) warnings.push('calendar config is present but "calendar" is not in services');
|
|
1255
1810
|
warnings.push(...lintRules(rules, entities, cfg.db?.publicRead ?? []));
|
|
1256
1811
|
warnings.push(...trackedSecretFiles(cfg.rootDir, void 0, [
|
|
1257
1812
|
cfg.local.tokenFile,
|
|
@@ -1279,8 +1834,13 @@ function printHelp() {
|
|
|
1279
1834
|
|
|
1280
1835
|
Usage:
|
|
1281
1836
|
odla-ai setup [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1282
|
-
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y] [--env dev --env prod]
|
|
1837
|
+
odla-ai init --app-id <id> --name <name> [--services db,ai,o11y,calendar] [--env dev --env prod]
|
|
1283
1838
|
odla-ai doctor [--config odla.config.mjs]
|
|
1839
|
+
odla-ai calendar status [--env dev] [--json]
|
|
1840
|
+
odla-ai calendar calendars [--env dev] [--json]
|
|
1841
|
+
odla-ai calendar connect [--env dev] [--no-open] [--yes]
|
|
1842
|
+
odla-ai calendar resync [--env dev] [--yes]
|
|
1843
|
+
odla-ai calendar disconnect [--env dev] --yes
|
|
1284
1844
|
odla-ai capabilities [--json]
|
|
1285
1845
|
odla-ai admin ai show [--platform https://odla.ai] [--json]
|
|
1286
1846
|
odla-ai admin ai models [--provider <id>] [--json]
|
|
@@ -1301,15 +1861,18 @@ Usage:
|
|
|
1301
1861
|
odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
|
|
1302
1862
|
odla-ai security run [target] --self --ack-redacted-source
|
|
1303
1863
|
odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
|
|
1304
|
-
odla-ai smoke [--config odla.config.mjs] [--env dev]
|
|
1864
|
+
odla-ai smoke [--config odla.config.mjs] [--env dev] [--no-open]
|
|
1305
1865
|
odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
|
|
1306
1866
|
odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
|
|
1867
|
+
odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1868
|
+
odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
|
|
1307
1869
|
odla-ai version
|
|
1308
1870
|
|
|
1309
1871
|
Commands:
|
|
1310
1872
|
setup Install offline odla runbooks for common coding-agent harnesses.
|
|
1311
1873
|
init Create a generic odla.config.mjs plus starter schema/rules files.
|
|
1312
1874
|
doctor Validate and summarize the project config without network calls.
|
|
1875
|
+
calendar Inspect, connect, resync, or disconnect a read-only Google mirror.
|
|
1313
1876
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
1314
1877
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
1315
1878
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
@@ -1317,7 +1880,9 @@ Commands:
|
|
|
1317
1880
|
smoke Verify local credentials, public-config, live schema, and db aggregate.
|
|
1318
1881
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
1319
1882
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
1320
|
-
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
1883
|
+
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
1884
|
+
stdin; set stores a tenant-vault secret and set-clerk-key the
|
|
1885
|
+
reserved Clerk secret key, write-only from stdin or an env var.
|
|
1321
1886
|
version Print the CLI version.
|
|
1322
1887
|
|
|
1323
1888
|
Safety:
|
|
@@ -1328,6 +1893,8 @@ Safety:
|
|
|
1328
1893
|
preflights Wrangler before any shown-once issuance or destructive rotation.
|
|
1329
1894
|
Provision opens the approval page automatically in interactive terminals;
|
|
1330
1895
|
use --open to force browser launch or --no-open to suppress it.
|
|
1896
|
+
Calendar setup has a second human checkpoint: odla issues a state-bound Google consent URL;
|
|
1897
|
+
OAuth codes and refresh tokens never enter the CLI, repo, chat, or app.
|
|
1331
1898
|
GitHub security uses source-read-only access plus optional metadata-only Checks write: the CLI never asks
|
|
1332
1899
|
for a PAT or provider key. GitHub read access is separate from the explicit
|
|
1333
1900
|
--ack-redacted-source consent and the exact --plan-digest printed by security
|
|
@@ -1372,6 +1939,7 @@ function initProject(options) {
|
|
|
1372
1939
|
}
|
|
1373
1940
|
const envs = options.envs?.length ? options.envs : ["dev"];
|
|
1374
1941
|
const services = options.services?.length ? options.services : ["db", "ai"];
|
|
1942
|
+
if (services.includes("calendar") && !services.includes("db")) throw new Error("--services calendar requires db");
|
|
1375
1943
|
const aiProvider = options.aiProvider ?? "anthropic";
|
|
1376
1944
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(configPath), { recursive: true });
|
|
1377
1945
|
(0, import_node_fs7.mkdirSync)((0, import_node_path5.resolve)(rootDir, "src/odla"), { recursive: true });
|
|
@@ -1389,6 +1957,16 @@ function writeIfMissing(path, text) {
|
|
|
1389
1957
|
(0, import_node_fs7.writeFileSync)(path, text);
|
|
1390
1958
|
}
|
|
1391
1959
|
function configTemplate(input) {
|
|
1960
|
+
const calendar = input.services.includes("calendar") ? ` calendar: {
|
|
1961
|
+
google: {
|
|
1962
|
+
calendars: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, ["primary"]])))},
|
|
1963
|
+
bookingPageUrl: ${JSON.stringify(Object.fromEntries(input.envs.map((env) => [env, null])))},
|
|
1964
|
+
match: { organizerSelf: true, requireAttendees: true },
|
|
1965
|
+
attendeePolicy: "full",
|
|
1966
|
+
// Read-only in this release. Google consent happens in Studio during provision.
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
` : "";
|
|
1392
1970
|
return `export default {
|
|
1393
1971
|
platformUrl: process.env.ODLA_PLATFORM_URL ?? "https://odla.ai",
|
|
1394
1972
|
dbEndpoint: process.env.ODLA_ENDPOINT ?? process.env.ODLA_DB_ENDPOINT ?? "https://db.odla.ai",
|
|
@@ -1410,6 +1988,7 @@ function configTemplate(input) {
|
|
|
1410
1988
|
// key in the platform vault for each tenant.
|
|
1411
1989
|
keyEnv: "${defaultKeyEnv(input.aiProvider)}",
|
|
1412
1990
|
},
|
|
1991
|
+
${calendar}
|
|
1413
1992
|
auth: {
|
|
1414
1993
|
clerk: {
|
|
1415
1994
|
// dev: "$CLERK_PUBLISHABLE_KEY",
|
|
@@ -1475,7 +2054,7 @@ function relativeDisplay(path, rootDir) {
|
|
|
1475
2054
|
// src/provision.ts
|
|
1476
2055
|
var import_apps2 = require("@odla-ai/apps");
|
|
1477
2056
|
var import_ai = require("@odla-ai/ai");
|
|
1478
|
-
var
|
|
2057
|
+
var import_node_process6 = __toESM(require("process"), 1);
|
|
1479
2058
|
|
|
1480
2059
|
// src/provision-credentials.ts
|
|
1481
2060
|
var import_apps = require("@odla-ai/apps");
|
|
@@ -1536,14 +2115,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
1536
2115
|
appId: tenantId
|
|
1537
2116
|
})
|
|
1538
2117
|
});
|
|
1539
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2118
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
|
|
1540
2119
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
1541
2120
|
method: "POST",
|
|
1542
2121
|
headers,
|
|
1543
2122
|
body: "{}"
|
|
1544
2123
|
});
|
|
1545
2124
|
}
|
|
1546
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2125
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1547
2126
|
const body = await res.json();
|
|
1548
2127
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
1549
2128
|
return body.key;
|
|
@@ -1559,12 +2138,12 @@ async function issueO11yToken(opts) {
|
|
|
1559
2138
|
`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`
|
|
1560
2139
|
);
|
|
1561
2140
|
}
|
|
1562
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2141
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
|
|
1563
2142
|
const body = await res.json();
|
|
1564
2143
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
1565
2144
|
return body.token;
|
|
1566
2145
|
}
|
|
1567
|
-
async function
|
|
2146
|
+
async function safeText2(res) {
|
|
1568
2147
|
try {
|
|
1569
2148
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1570
2149
|
} catch {
|
|
@@ -1677,6 +2256,14 @@ async function provision(options) {
|
|
|
1677
2256
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
1678
2257
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
1679
2258
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
2259
|
+
if (cfg.services.includes("calendar")) {
|
|
2260
|
+
for (const env of cfg.envs) {
|
|
2261
|
+
const calendar = calendarServiceConfig(cfg, env);
|
|
2262
|
+
out.log(` calendar.${env}: google/read, ${calendar.calendars.join(", ")} (${calendar.attendeePolicy}; ${GOOGLE_CALENDAR_READ_SCOPE})`);
|
|
2263
|
+
const bookingPage = calendarBookingPageUrl(cfg, env);
|
|
2264
|
+
out.log(` calendar.${env}.bookingPageUrl: ${bookingPage === void 0 ? "unchanged" : bookingPage ?? "clear"}`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
1680
2267
|
out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
|
|
1681
2268
|
return;
|
|
1682
2269
|
}
|
|
@@ -1711,8 +2298,9 @@ async function provision(options) {
|
|
|
1711
2298
|
await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
|
|
1712
2299
|
out.log(`app: created ${cfg.app.id}`);
|
|
1713
2300
|
}
|
|
2301
|
+
const serviceOrder = [...cfg.services].sort((a, b) => serviceRank(a) - serviceRank(b));
|
|
1714
2302
|
for (const env of cfg.envs) {
|
|
1715
|
-
for (const service of
|
|
2303
|
+
for (const service of serviceOrder) {
|
|
1716
2304
|
if (service === "ai") {
|
|
1717
2305
|
if (cfg.ai?.provider) {
|
|
1718
2306
|
await apps.setAi(cfg.app.id, env, { provider: cfg.ai.provider, ...cfg.ai.model ? { model: cfg.ai.model } : {} });
|
|
@@ -1722,7 +2310,8 @@ async function provision(options) {
|
|
|
1722
2310
|
out.log(`${env}: ai enabled`);
|
|
1723
2311
|
}
|
|
1724
2312
|
} else {
|
|
1725
|
-
|
|
2313
|
+
const config = service === "calendar" ? calendarServiceConfig(cfg, env) : void 0;
|
|
2314
|
+
await apps.setService(cfg.app.id, service, true, { env, ...config ? { config } : {} });
|
|
1726
2315
|
out.log(`${env}: ${service} enabled`);
|
|
1727
2316
|
}
|
|
1728
2317
|
}
|
|
@@ -1736,6 +2325,21 @@ async function provision(options) {
|
|
|
1736
2325
|
await apps.setLink(cfg.app.id, env, link ?? null);
|
|
1737
2326
|
out.log(`${env}: link ${link ? "set" : "cleared"}`);
|
|
1738
2327
|
}
|
|
2328
|
+
if (cfg.services.includes("calendar")) {
|
|
2329
|
+
const calendarCtx = { platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch };
|
|
2330
|
+
await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
|
|
2331
|
+
await ensureCalendarConnected(
|
|
2332
|
+
calendarCtx,
|
|
2333
|
+
{
|
|
2334
|
+
open: options.open,
|
|
2335
|
+
interactive: options.interactive,
|
|
2336
|
+
openConsentUrl: options.openApprovalUrl,
|
|
2337
|
+
wait: options.calendarPollWait,
|
|
2338
|
+
pollTimeoutMs: options.calendarPollTimeoutMs,
|
|
2339
|
+
stdout: out
|
|
2340
|
+
}
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
1739
2343
|
}
|
|
1740
2344
|
for (const env of cfg.envs) {
|
|
1741
2345
|
const tenantId = (0, import_apps2.tenantIdFor)(cfg.app.id, env);
|
|
@@ -1779,7 +2383,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1779
2383
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
1780
2384
|
}
|
|
1781
2385
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
1782
|
-
const key =
|
|
2386
|
+
const key = import_node_process6.default.env[cfg.ai.keyEnv];
|
|
1783
2387
|
if (key) {
|
|
1784
2388
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
1785
2389
|
await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
@@ -1799,6 +2403,11 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
1799
2403
|
out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
|
|
1800
2404
|
}
|
|
1801
2405
|
}
|
|
2406
|
+
function serviceRank(service) {
|
|
2407
|
+
if (service === "db") return 0;
|
|
2408
|
+
if (service === "calendar") return 2;
|
|
2409
|
+
return 1;
|
|
2410
|
+
}
|
|
1802
2411
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
1803
2412
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
1804
2413
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -1814,7 +2423,7 @@ async function postJson(doFetch, url, bearer, body) {
|
|
|
1814
2423
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
1815
2424
|
body: JSON.stringify(body)
|
|
1816
2425
|
});
|
|
1817
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2426
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
|
|
1818
2427
|
}
|
|
1819
2428
|
function normalizeClerkConfig(value) {
|
|
1820
2429
|
if (!value) return null;
|
|
@@ -1831,7 +2440,7 @@ function defaultSecretName(provider) {
|
|
|
1831
2440
|
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
1832
2441
|
return names[provider] ?? `${provider}_api_key`;
|
|
1833
2442
|
}
|
|
1834
|
-
async function
|
|
2443
|
+
async function safeText3(res) {
|
|
1835
2444
|
try {
|
|
1836
2445
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
1837
2446
|
} catch {
|
|
@@ -1839,6 +2448,63 @@ async function safeText2(res) {
|
|
|
1839
2448
|
}
|
|
1840
2449
|
}
|
|
1841
2450
|
|
|
2451
|
+
// src/secrets-set.ts
|
|
2452
|
+
var import_ai2 = require("@odla-ai/ai");
|
|
2453
|
+
var import_apps3 = require("@odla-ai/apps");
|
|
2454
|
+
var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2455
|
+
async function secretsSet(options) {
|
|
2456
|
+
const name = (options.name ?? "").trim();
|
|
2457
|
+
if (!name) throw new Error('secret name is required, e.g. "odla-ai secrets set clerk_webhook_secret --env dev --stdin"');
|
|
2458
|
+
if (name.startsWith("$")) {
|
|
2459
|
+
throw new Error('"$"-prefixed vault names are platform-reserved; for the Clerk secret key use "odla-ai secrets set-clerk-key"');
|
|
2460
|
+
}
|
|
2461
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2462
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2463
|
+
try {
|
|
2464
|
+
await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
|
|
2467
|
+
}
|
|
2468
|
+
out.log(`${name} stored in the ${tenantId} vault (write-only; the value was never echoed and cannot be read back here)`);
|
|
2469
|
+
}
|
|
2470
|
+
async function secretsSetClerkKey(options) {
|
|
2471
|
+
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2472
|
+
if (!value.startsWith("sk_")) throw new Error("the Clerk secret key must start with sk_ (sk_test_\u2026 or sk_live_\u2026)");
|
|
2473
|
+
if (value.startsWith("sk_test_") && PROD_ENV_NAMES2.has(options.env)) {
|
|
2474
|
+
throw new Error(`refusing to store an sk_test_ Clerk key for "${options.env}" \u2014 use the production instance's sk_live_ key`);
|
|
2475
|
+
}
|
|
2476
|
+
if (value.startsWith("sk_live_") && !PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2477
|
+
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)`);
|
|
2478
|
+
}
|
|
2479
|
+
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2480
|
+
const res = await doFetch(`${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/clerk-secret`, {
|
|
2481
|
+
method: "POST",
|
|
2482
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2483
|
+
body: JSON.stringify({ value })
|
|
2484
|
+
});
|
|
2485
|
+
if (!res.ok) {
|
|
2486
|
+
const text = scrubValue((await res.text().catch(() => "")).slice(0, 300), value);
|
|
2487
|
+
throw new Error(`store Clerk secret key failed (${res.status}): ${text || "request failed"}`);
|
|
2488
|
+
}
|
|
2489
|
+
out.log(`Clerk secret key stored for ${tenantId} ($clerk_secret, reserved + write-only; the value was never echoed)`);
|
|
2490
|
+
}
|
|
2491
|
+
function scrubValue(text, value) {
|
|
2492
|
+
return redactSecrets(text).split(value).join("[value redacted]");
|
|
2493
|
+
}
|
|
2494
|
+
async function resolveVaultWrite(options) {
|
|
2495
|
+
const out = options.stdout ?? console;
|
|
2496
|
+
const doFetch = options.fetch ?? fetch;
|
|
2497
|
+
const cfg = await loadProjectConfig(options.configPath);
|
|
2498
|
+
if (!cfg.envs.includes(options.env)) {
|
|
2499
|
+
throw new Error(`env "${options.env}" is not in config envs (${cfg.envs.join(", ")})`);
|
|
2500
|
+
}
|
|
2501
|
+
if (PROD_ENV_NAMES2.has(options.env) && !options.yes) {
|
|
2502
|
+
throw new Error(`refusing to store a secret for "${options.env}" without --yes`);
|
|
2503
|
+
}
|
|
2504
|
+
const value = await secretInputValue(options, "secret");
|
|
2505
|
+
return { cfg, tenantId: (0, import_apps3.tenantIdFor)(cfg.app.id, options.env), value, doFetch, out };
|
|
2506
|
+
}
|
|
2507
|
+
|
|
1842
2508
|
// src/security-command-context.ts
|
|
1843
2509
|
var import_promises = require("readline/promises");
|
|
1844
2510
|
async function hostedSecurityContext(parsed, dependencies) {
|
|
@@ -3034,6 +3700,23 @@ async function smoke(options) {
|
|
|
3034
3700
|
}
|
|
3035
3701
|
out.log(` ai: ${provider}`);
|
|
3036
3702
|
}
|
|
3703
|
+
if (cfg.services.includes("calendar")) {
|
|
3704
|
+
const token = await getDeveloperToken(
|
|
3705
|
+
cfg,
|
|
3706
|
+
{
|
|
3707
|
+
configPath: cfg.configPath,
|
|
3708
|
+
token: options.token,
|
|
3709
|
+
open: options.open,
|
|
3710
|
+
interactive: options.interactive,
|
|
3711
|
+
openApprovalUrl: options.openApprovalUrl
|
|
3712
|
+
},
|
|
3713
|
+
doFetch,
|
|
3714
|
+
out
|
|
3715
|
+
);
|
|
3716
|
+
const status = await readCalendarStatus({ platform: cfg.platformUrl, appId: cfg.app.id, env, token, fetch: doFetch });
|
|
3717
|
+
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
3718
|
+
out.log(` calendar: ${status.status}, last sync ${new Date(status.lastSuccessfulSyncAt).toISOString()}`);
|
|
3719
|
+
}
|
|
3037
3720
|
const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
3038
3721
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
3039
3722
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
@@ -3055,13 +3738,35 @@ async function smoke(options) {
|
|
|
3055
3738
|
} else {
|
|
3056
3739
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
3057
3740
|
}
|
|
3741
|
+
if (cfg.services.includes("calendar")) {
|
|
3742
|
+
const bookings = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
3743
|
+
ns: "$bookings",
|
|
3744
|
+
aggregate: { count: true }
|
|
3745
|
+
});
|
|
3746
|
+
const count = bookings.aggregate?.count;
|
|
3747
|
+
out.log(` bookings: ${String(count ?? "ok")}`);
|
|
3748
|
+
}
|
|
3058
3749
|
out.log("ok");
|
|
3059
3750
|
}
|
|
3751
|
+
function assertCalendarHealthy(status, expected) {
|
|
3752
|
+
if (!status.enabled || status.provider !== "google") throw new Error("calendar is not enabled with the Google provider");
|
|
3753
|
+
if (status.status !== "healthy") {
|
|
3754
|
+
throw new Error(`calendar connection is ${status.status}${status.error?.message ? `: ${status.error.message}` : ""}`);
|
|
3755
|
+
}
|
|
3756
|
+
if (status.access !== "read") throw new Error("calendar connection is not read-only");
|
|
3757
|
+
const hasReadScope = status.grantedScopes.some((scope) => scope === GOOGLE_CALENDAR_READ_SCOPE || scope === "calendar.events.readonly");
|
|
3758
|
+
if (!hasReadScope) throw new Error("calendar connection is missing calendar.events.readonly consent");
|
|
3759
|
+
const missing = expected.calendars.filter((id) => !status.calendars.includes(id));
|
|
3760
|
+
if (missing.length) throw new Error(`calendar connection is missing configured calendars: ${missing.join(", ")}`);
|
|
3761
|
+
if (status.lastSuccessfulSyncAt === void 0) throw new Error("calendar has never completed a sync");
|
|
3762
|
+
if (Date.now() - status.lastSuccessfulSyncAt > 30 * 6e4) throw new Error("calendar sync is more than 30 minutes stale");
|
|
3763
|
+
if (status.watchExpiresAt !== void 0 && status.watchExpiresAt <= Date.now()) throw new Error("calendar watch channel is expired");
|
|
3764
|
+
}
|
|
3060
3765
|
async function getJson(doFetch, url, bearer) {
|
|
3061
3766
|
const res = await doFetch(url, {
|
|
3062
3767
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
3063
3768
|
});
|
|
3064
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3769
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3065
3770
|
return res.json();
|
|
3066
3771
|
}
|
|
3067
3772
|
async function postJson2(doFetch, url, bearer, body) {
|
|
@@ -3070,7 +3775,7 @@ async function postJson2(doFetch, url, bearer, body) {
|
|
|
3070
3775
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
3071
3776
|
body: JSON.stringify(body)
|
|
3072
3777
|
});
|
|
3073
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
3778
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
|
|
3074
3779
|
return res.json();
|
|
3075
3780
|
}
|
|
3076
3781
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -3078,7 +3783,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
3078
3783
|
url.searchParams.set("env", env);
|
|
3079
3784
|
return url.toString();
|
|
3080
3785
|
}
|
|
3081
|
-
async function
|
|
3786
|
+
async function safeText4(res) {
|
|
3082
3787
|
try {
|
|
3083
3788
|
return (await res.text()).slice(0, 500);
|
|
3084
3789
|
} catch {
|
|
@@ -3126,6 +3831,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3126
3831
|
await doctor({ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs" });
|
|
3127
3832
|
return;
|
|
3128
3833
|
}
|
|
3834
|
+
if (command === "calendar") {
|
|
3835
|
+
await calendarCommand(parsed, dependencies);
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3129
3838
|
if (command === "capabilities") {
|
|
3130
3839
|
assertArgs(parsed, ["json"], 1);
|
|
3131
3840
|
printCapabilities(parsed.options.json === true);
|
|
@@ -3140,14 +3849,19 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3140
3849
|
return;
|
|
3141
3850
|
}
|
|
3142
3851
|
if (command === "provision") {
|
|
3143
|
-
await provisionCommand(parsed);
|
|
3852
|
+
await provisionCommand(parsed, dependencies);
|
|
3144
3853
|
return;
|
|
3145
3854
|
}
|
|
3146
3855
|
if (command === "smoke") {
|
|
3147
|
-
assertArgs(parsed, ["config", "env"], 1);
|
|
3856
|
+
assertArgs(parsed, ["config", "env", "token", "open"], 1);
|
|
3148
3857
|
await smoke({
|
|
3149
3858
|
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3150
|
-
env: stringOpt(parsed.options.env)
|
|
3859
|
+
env: stringOpt(parsed.options.env),
|
|
3860
|
+
token: stringOpt(parsed.options.token),
|
|
3861
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3862
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3863
|
+
fetch: dependencies.fetch,
|
|
3864
|
+
stdout: dependencies.stdout
|
|
3151
3865
|
});
|
|
3152
3866
|
return;
|
|
3153
3867
|
}
|
|
@@ -3180,8 +3894,26 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3180
3894
|
}
|
|
3181
3895
|
if (command === "secrets") {
|
|
3182
3896
|
const sub = parsed.positionals[1];
|
|
3897
|
+
if (sub === "set" || sub === "set-clerk-key") {
|
|
3898
|
+
assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
|
|
3899
|
+
const options = {
|
|
3900
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3901
|
+
name: parsed.positionals[2],
|
|
3902
|
+
env: requiredString(parsed.options.env, "--env"),
|
|
3903
|
+
fromEnv: stringOpt(parsed.options["from-env"]),
|
|
3904
|
+
stdin: parsed.options.stdin === true,
|
|
3905
|
+
token: stringOpt(parsed.options.token),
|
|
3906
|
+
yes: parsed.options.yes === true,
|
|
3907
|
+
fetch: dependencies.fetch,
|
|
3908
|
+
stdout: dependencies.stdout
|
|
3909
|
+
};
|
|
3910
|
+
await (sub === "set" ? secretsSet(options) : secretsSetClerkKey(options));
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3183
3913
|
if (sub !== "push") {
|
|
3184
|
-
throw new Error(
|
|
3914
|
+
throw new Error(
|
|
3915
|
+
`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".`
|
|
3916
|
+
);
|
|
3185
3917
|
}
|
|
3186
3918
|
assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
|
|
3187
3919
|
await secretsPush({
|
|
@@ -3194,7 +3926,7 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
3194
3926
|
}
|
|
3195
3927
|
throw new Error(`unknown command "${command}". Run "odla-ai help".`);
|
|
3196
3928
|
}
|
|
3197
|
-
async function provisionCommand(parsed) {
|
|
3929
|
+
async function provisionCommand(parsed, dependencies) {
|
|
3198
3930
|
assertArgs(parsed, [
|
|
3199
3931
|
"config",
|
|
3200
3932
|
"dry-run",
|
|
@@ -3218,16 +3950,52 @@ async function provisionCommand(parsed) {
|
|
|
3218
3950
|
writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
|
|
3219
3951
|
token: stringOpt(parsed.options.token),
|
|
3220
3952
|
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3221
|
-
yes: parsed.options.yes === true
|
|
3953
|
+
yes: parsed.options.yes === true,
|
|
3954
|
+
fetch: dependencies.fetch,
|
|
3955
|
+
openApprovalUrl: dependencies.openUrl,
|
|
3956
|
+
stdout: dependencies.stdout
|
|
3222
3957
|
};
|
|
3223
3958
|
await provision(options);
|
|
3224
3959
|
}
|
|
3960
|
+
async function calendarCommand(parsed, dependencies) {
|
|
3961
|
+
const sub = parsed.positionals[1];
|
|
3962
|
+
if (sub !== "status" && sub !== "calendars" && sub !== "connect" && sub !== "resync" && sub !== "disconnect") {
|
|
3963
|
+
throw new Error(`unknown calendar subcommand "${sub ?? ""}". Try "odla-ai calendar status --env dev".`);
|
|
3964
|
+
}
|
|
3965
|
+
assertArgs(parsed, ["config", "env", "json", "token", "open", "yes"], 2);
|
|
3966
|
+
if (sub !== "status" && sub !== "calendars" && parsed.options.json !== void 0) throw new Error(`--json is supported only by calendar status/calendars`);
|
|
3967
|
+
if ((sub === "status" || sub === "calendars") && parsed.options.yes !== void 0) throw new Error(`--yes is not used by "calendar ${sub}"`);
|
|
3968
|
+
const options = {
|
|
3969
|
+
configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
|
|
3970
|
+
env: stringOpt(parsed.options.env),
|
|
3971
|
+
token: stringOpt(parsed.options.token),
|
|
3972
|
+
open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
|
|
3973
|
+
yes: parsed.options.yes === true,
|
|
3974
|
+
json: parsed.options.json === true,
|
|
3975
|
+
fetch: dependencies.fetch,
|
|
3976
|
+
stdout: dependencies.stdout,
|
|
3977
|
+
openConsentUrl: dependencies.openUrl
|
|
3978
|
+
};
|
|
3979
|
+
if (sub === "status") await calendarStatus(options);
|
|
3980
|
+
else if (sub === "calendars") await calendarCalendars(options);
|
|
3981
|
+
else if (sub === "connect") await calendarConnect(options);
|
|
3982
|
+
else if (sub === "resync") await calendarResync(options);
|
|
3983
|
+
else await calendarDisconnect(options);
|
|
3984
|
+
}
|
|
3225
3985
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3226
3986
|
0 && (module.exports = {
|
|
3227
3987
|
AGENT_HARNESSES,
|
|
3228
3988
|
CAPABILITIES,
|
|
3989
|
+
GOOGLE_CALENDAR_READ_SCOPE,
|
|
3229
3990
|
SYSTEM_AI_PURPOSES,
|
|
3230
3991
|
adminAi,
|
|
3992
|
+
calendarBookingPageUrl,
|
|
3993
|
+
calendarCalendars,
|
|
3994
|
+
calendarConnect,
|
|
3995
|
+
calendarDisconnect,
|
|
3996
|
+
calendarResync,
|
|
3997
|
+
calendarServiceConfig,
|
|
3998
|
+
calendarStatus,
|
|
3231
3999
|
connectGitHubSecuritySource,
|
|
3232
4000
|
disconnectGitHubSecuritySource,
|
|
3233
4001
|
doctor,
|
|
@@ -3250,6 +4018,8 @@ async function provisionCommand(parsed) {
|
|
|
3250
4018
|
runCli,
|
|
3251
4019
|
runHostedSecurity,
|
|
3252
4020
|
secretsPush,
|
|
4021
|
+
secretsSet,
|
|
4022
|
+
secretsSetClerkKey,
|
|
3253
4023
|
smoke,
|
|
3254
4024
|
startHostedSecurityJob
|
|
3255
4025
|
});
|