@odla-ai/cli 0.12.0 → 0.13.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 +43 -7
- package/REQUIREMENTS.md +11 -3
- package/dist/bin.cjs +318 -60
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-Y4HWUEFF.js → chunk-I7XDJZB6.js} +314 -56
- package/dist/chunk-I7XDJZB6.js.map +1 -0
- package/dist/index.cjs +318 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -13
- package/dist/index.d.ts +66 -13
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skills/odla/SKILL.md +5 -3
- package/skills/odla/references/build.md +9 -2
- package/skills/odla/references/sdks.md +25 -0
- package/skills/odla-migrate/references/phase-2-db.md +2 -2
- package/dist/chunk-Y4HWUEFF.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1003,6 +1003,77 @@ var import_promises = require("stream/promises");
|
|
|
1003
1003
|
var import_node_fs4 = require("fs");
|
|
1004
1004
|
var import_node_path3 = require("path");
|
|
1005
1005
|
var import_node_url = require("url");
|
|
1006
|
+
|
|
1007
|
+
// src/integration-validation.ts
|
|
1008
|
+
function validateIntegrations(cfg, path, defaultServices) {
|
|
1009
|
+
if (cfg.integrations === void 0) return;
|
|
1010
|
+
if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
|
|
1011
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1012
|
+
for (const [index, integration] of cfg.integrations.entries()) {
|
|
1013
|
+
const at = `${path}: integrations[${index}]`;
|
|
1014
|
+
if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
|
|
1015
|
+
if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
|
|
1016
|
+
if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
|
|
1017
|
+
ids.add(integration.id);
|
|
1018
|
+
if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
|
|
1019
|
+
if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
|
|
1020
|
+
if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
|
|
1021
|
+
throw new Error(`${at}.schema must contain an entities object`);
|
|
1022
|
+
}
|
|
1023
|
+
if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
|
|
1024
|
+
validateSeeds(integration, at);
|
|
1025
|
+
validateProbes(integration, at);
|
|
1026
|
+
}
|
|
1027
|
+
const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
|
|
1028
|
+
const services = unique(cfg.services?.length ? cfg.services : defaultServices);
|
|
1029
|
+
if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
|
|
1030
|
+
}
|
|
1031
|
+
function validateSeeds(integration, at) {
|
|
1032
|
+
if (integration.seeds === void 0) return;
|
|
1033
|
+
if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
|
|
1034
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1035
|
+
for (const [index, seed] of integration.seeds.entries()) {
|
|
1036
|
+
const sat = `${at}.seeds[${index}]`;
|
|
1037
|
+
if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
|
|
1038
|
+
if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
|
|
1039
|
+
ids.add(seed.id);
|
|
1040
|
+
if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
|
|
1041
|
+
throw new Error(`${sat}.key requires string attr and value`);
|
|
1042
|
+
}
|
|
1043
|
+
if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
|
|
1044
|
+
if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
|
|
1045
|
+
throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function validateProbes(integration, at) {
|
|
1050
|
+
if (integration.probes === void 0) return;
|
|
1051
|
+
if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
|
|
1052
|
+
for (const [index, probe] of integration.probes.entries()) {
|
|
1053
|
+
const pat = `${at}.probes[${index}]`;
|
|
1054
|
+
if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
|
|
1055
|
+
if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
|
|
1056
|
+
throw new Error(`${pat}.expectedStatus must be an HTTP status`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function isRecord4(value) {
|
|
1061
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1062
|
+
}
|
|
1063
|
+
function safeText(value, max) {
|
|
1064
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1065
|
+
}
|
|
1066
|
+
function safeProbePath(value) {
|
|
1067
|
+
return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
|
|
1068
|
+
}
|
|
1069
|
+
function validId(value) {
|
|
1070
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
1071
|
+
}
|
|
1072
|
+
function unique(values) {
|
|
1073
|
+
return [...new Set(values.filter(Boolean))];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// src/config.ts
|
|
1006
1077
|
var DEFAULT_PLATFORM = "https://odla.ai";
|
|
1007
1078
|
var DEFAULT_ENVS = ["dev"];
|
|
1008
1079
|
var DEFAULT_SERVICES = ["db", "ai"];
|
|
@@ -1017,8 +1088,8 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
|
|
|
1017
1088
|
validateRawConfig(raw, resolved);
|
|
1018
1089
|
const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
|
|
1019
1090
|
const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
|
|
1020
|
-
const envs =
|
|
1021
|
-
const services =
|
|
1091
|
+
const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
|
|
1092
|
+
const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
|
|
1022
1093
|
validateCalendarConfig(raw, envs, services, resolved);
|
|
1023
1094
|
const local = {
|
|
1024
1095
|
tokenFile: (0, import_node_path3.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
|
|
@@ -1052,6 +1123,8 @@ async function resolveDataExport(cfg, value, names) {
|
|
|
1052
1123
|
throw new Error(`${value} did not export ${names.join(", ")} or default`);
|
|
1053
1124
|
}
|
|
1054
1125
|
function buildPlan(cfg) {
|
|
1126
|
+
const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
|
|
1127
|
+
const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
|
|
1055
1128
|
return {
|
|
1056
1129
|
appId: cfg.app.id,
|
|
1057
1130
|
appName: cfg.app.name,
|
|
@@ -1059,8 +1132,9 @@ function buildPlan(cfg) {
|
|
|
1059
1132
|
dbEndpoint: cfg.dbEndpoint,
|
|
1060
1133
|
envs: cfg.envs,
|
|
1061
1134
|
services: cfg.services,
|
|
1062
|
-
|
|
1063
|
-
|
|
1135
|
+
integrations: (cfg.integrations ?? []).map((integration) => integration.id),
|
|
1136
|
+
hasSchema: !!cfg.db?.schema || integrationSchema,
|
|
1137
|
+
hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
|
|
1064
1138
|
aiProvider: cfg.ai?.provider
|
|
1065
1139
|
};
|
|
1066
1140
|
}
|
|
@@ -1069,7 +1143,7 @@ function calendarServiceConfig(cfg, env) {
|
|
|
1069
1143
|
if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
|
|
1070
1144
|
const google = cfg.calendar?.google;
|
|
1071
1145
|
if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
|
|
1072
|
-
const availability =
|
|
1146
|
+
const availability = unique2(
|
|
1073
1147
|
(google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
|
|
1074
1148
|
);
|
|
1075
1149
|
return {
|
|
@@ -1105,15 +1179,16 @@ function validateRawConfig(raw, path) {
|
|
|
1105
1179
|
if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
|
|
1106
1180
|
const cfg = raw;
|
|
1107
1181
|
if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
|
|
1108
|
-
if (!
|
|
1182
|
+
if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
|
|
1109
1183
|
if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
|
|
1110
1184
|
if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
|
|
1111
1185
|
throw new Error(`${path}: envs must be an array of names`);
|
|
1112
1186
|
}
|
|
1113
|
-
if (cfg.envs?.some((env) => !
|
|
1187
|
+
if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
|
|
1114
1188
|
if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
|
|
1115
1189
|
throw new Error(`${path}: services must be an array of non-empty names`);
|
|
1116
1190
|
}
|
|
1191
|
+
validateIntegrations(cfg, path, DEFAULT_SERVICES);
|
|
1117
1192
|
}
|
|
1118
1193
|
function validateCalendarConfig(cfg, envs, services, path) {
|
|
1119
1194
|
const enabled = services.includes("calendar");
|
|
@@ -1121,9 +1196,9 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
1121
1196
|
if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
|
|
1122
1197
|
return;
|
|
1123
1198
|
}
|
|
1124
|
-
if (!
|
|
1199
|
+
if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
|
|
1125
1200
|
assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
|
|
1126
|
-
if (!
|
|
1201
|
+
if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
|
|
1127
1202
|
const google = cfg.calendar.google;
|
|
1128
1203
|
assertOnly(
|
|
1129
1204
|
google,
|
|
@@ -1135,7 +1210,7 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
1135
1210
|
throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
|
|
1136
1211
|
}
|
|
1137
1212
|
const availability = google[availabilityKey];
|
|
1138
|
-
if (!
|
|
1213
|
+
if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
|
|
1139
1214
|
const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
|
|
1140
1215
|
if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
|
|
1141
1216
|
for (const env of envs) {
|
|
@@ -1146,22 +1221,22 @@ function validateCalendarConfig(cfg, envs, services, path) {
|
|
|
1146
1221
|
if (ids.length > 10) {
|
|
1147
1222
|
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
|
|
1148
1223
|
}
|
|
1149
|
-
if (ids.some((id) => !
|
|
1224
|
+
if (ids.some((id) => !safeText2(id, 1024))) {
|
|
1150
1225
|
throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
|
|
1151
1226
|
}
|
|
1152
1227
|
}
|
|
1153
1228
|
if (google.bookingCalendar !== void 0) {
|
|
1154
|
-
if (!
|
|
1229
|
+
if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
|
|
1155
1230
|
const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
|
|
1156
1231
|
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
|
|
1157
1232
|
for (const [env, value] of Object.entries(google.bookingCalendar)) {
|
|
1158
|
-
if (!
|
|
1233
|
+
if (!safeText2(value, 1024)) {
|
|
1159
1234
|
throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
|
|
1160
1235
|
}
|
|
1161
1236
|
}
|
|
1162
1237
|
}
|
|
1163
1238
|
if (google.bookingPageUrl !== void 0) {
|
|
1164
|
-
if (!
|
|
1239
|
+
if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
|
|
1165
1240
|
const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
|
|
1166
1241
|
if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
|
|
1167
1242
|
for (const [env, value] of Object.entries(google.bookingPageUrl)) {
|
|
@@ -1176,10 +1251,10 @@ function assertOnly(value, allowed, label) {
|
|
|
1176
1251
|
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
1177
1252
|
if (extra) throw new Error(`${label}.${extra} is not supported`);
|
|
1178
1253
|
}
|
|
1179
|
-
function
|
|
1254
|
+
function isRecord5(value) {
|
|
1180
1255
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1181
1256
|
}
|
|
1182
|
-
function
|
|
1257
|
+
function safeText2(value, max) {
|
|
1183
1258
|
return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1184
1259
|
}
|
|
1185
1260
|
function safeHttpsUrl(value) {
|
|
@@ -1191,7 +1266,7 @@ function safeHttpsUrl(value) {
|
|
|
1191
1266
|
return false;
|
|
1192
1267
|
}
|
|
1193
1268
|
}
|
|
1194
|
-
function
|
|
1269
|
+
function validId2(value) {
|
|
1195
1270
|
return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
|
|
1196
1271
|
}
|
|
1197
1272
|
async function loadConfigModule(path) {
|
|
@@ -1204,7 +1279,7 @@ async function loadConfigModule(path) {
|
|
|
1204
1279
|
function trimSlash(value) {
|
|
1205
1280
|
return value.replace(/\/+$/, "");
|
|
1206
1281
|
}
|
|
1207
|
-
function
|
|
1282
|
+
function unique2(values) {
|
|
1208
1283
|
return [...new Set(values.filter(Boolean))];
|
|
1209
1284
|
}
|
|
1210
1285
|
|
|
@@ -1334,9 +1409,9 @@ var CAPABILITIES = {
|
|
|
1334
1409
|
"issue, persist, and explicitly rotate configured service credentials",
|
|
1335
1410
|
"write local Worker values and push deployed Worker secrets through Wrangler stdin",
|
|
1336
1411
|
"store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
|
|
1337
|
-
"
|
|
1412
|
+
"compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
|
|
1338
1413
|
"apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
|
|
1339
|
-
"validate
|
|
1414
|
+
"validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
|
|
1340
1415
|
"run app-attributed hosted security discovery and independent validation without provider keys",
|
|
1341
1416
|
"connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
|
|
1342
1417
|
"let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
|
|
@@ -1344,7 +1419,7 @@ var CAPABILITIES = {
|
|
|
1344
1419
|
agent: [
|
|
1345
1420
|
"install and import the selected odla SDKs",
|
|
1346
1421
|
"wrap the Worker with withObservability and choose useful telemetry",
|
|
1347
|
-
"make application-specific schema, rules, auth, UI, and migration decisions",
|
|
1422
|
+
"install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
|
|
1348
1423
|
"wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
|
|
1349
1424
|
],
|
|
1350
1425
|
human: [
|
|
@@ -2016,20 +2091,121 @@ function readPackageJson(rootDir) {
|
|
|
2016
2091
|
}
|
|
2017
2092
|
}
|
|
2018
2093
|
|
|
2094
|
+
// src/integrations.ts
|
|
2095
|
+
var import_node_util = require("util");
|
|
2096
|
+
async function resolveDatabaseConfig(cfg, options = {}) {
|
|
2097
|
+
const integrations = cfg.integrations ?? [];
|
|
2098
|
+
if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
|
|
2099
|
+
const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
|
|
2100
|
+
const schema = mergeSchemas(authoredSchema, integrations);
|
|
2101
|
+
if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
|
|
2102
|
+
const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
|
|
2103
|
+
const explicitRules = mergeRules(configuredRules, integrations);
|
|
2104
|
+
const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
|
|
2105
|
+
return { schema, rules, integrations };
|
|
2106
|
+
}
|
|
2107
|
+
function integrationWarnings(integrations, schema, rules) {
|
|
2108
|
+
const warnings = [];
|
|
2109
|
+
const entities = new Set(serializedEntities(schema));
|
|
2110
|
+
for (const integration of integrations) {
|
|
2111
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {});
|
|
2112
|
+
for (const ns of namespaces) {
|
|
2113
|
+
if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
|
|
2114
|
+
const rule = rules?.[ns];
|
|
2115
|
+
if (!rule) {
|
|
2116
|
+
warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
for (const action of ["view", "create", "update", "delete"]) {
|
|
2120
|
+
if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
for (const seed of integration.seeds ?? []) {
|
|
2124
|
+
if (!entities.has(seed.ns)) {
|
|
2125
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
2126
|
+
} else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
2127
|
+
warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return warnings;
|
|
2132
|
+
}
|
|
2133
|
+
function mergeSchemas(base, integrations) {
|
|
2134
|
+
const fragments = integrations.filter((integration) => integration.schema);
|
|
2135
|
+
if (fragments.length === 0) return base;
|
|
2136
|
+
const merged = normalizeSchema(base);
|
|
2137
|
+
for (const integration of fragments) {
|
|
2138
|
+
const fragment = normalizeSchema(integration.schema);
|
|
2139
|
+
mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
|
|
2140
|
+
mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
|
|
2141
|
+
}
|
|
2142
|
+
return merged;
|
|
2143
|
+
}
|
|
2144
|
+
function mergeRules(base, integrations) {
|
|
2145
|
+
const fragments = integrations.filter((integration) => integration.rules);
|
|
2146
|
+
if (fragments.length === 0) return base;
|
|
2147
|
+
const merged = { ...base ?? {} };
|
|
2148
|
+
for (const integration of fragments) {
|
|
2149
|
+
mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
|
|
2150
|
+
}
|
|
2151
|
+
return merged;
|
|
2152
|
+
}
|
|
2153
|
+
function assertSeedContracts(integrations, schema) {
|
|
2154
|
+
const entities = new Set(serializedEntities(schema));
|
|
2155
|
+
for (const integration of integrations) {
|
|
2156
|
+
for (const seed of integration.seeds ?? []) {
|
|
2157
|
+
if (!entities.has(seed.ns)) {
|
|
2158
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
|
|
2159
|
+
}
|
|
2160
|
+
if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
|
|
2161
|
+
throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
function isUniqueAttr(schema, ns, attr) {
|
|
2167
|
+
if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
|
|
2168
|
+
const entity = schema.entities[ns];
|
|
2169
|
+
if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
|
|
2170
|
+
const definition = entity.attrs[attr];
|
|
2171
|
+
return isRecord6(definition) && definition.unique === true;
|
|
2172
|
+
}
|
|
2173
|
+
function normalizeSchema(value) {
|
|
2174
|
+
if (value === void 0 || value === null) return { entities: {}, links: {} };
|
|
2175
|
+
if (!isRecord6(value) || !isRecord6(value.entities)) {
|
|
2176
|
+
throw new Error("db schema must be a serialized schema object with an entities map");
|
|
2177
|
+
}
|
|
2178
|
+
if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
|
|
2179
|
+
return {
|
|
2180
|
+
entities: { ...value.entities },
|
|
2181
|
+
links: { ...value.links ?? {} }
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
function mergeMap(target, fragment, label) {
|
|
2185
|
+
for (const [name, value] of Object.entries(fragment)) {
|
|
2186
|
+
if (Object.hasOwn(target, name) && !(0, import_node_util.isDeepStrictEqual)(target[name], value)) {
|
|
2187
|
+
throw new Error(`${label} "${name}" conflicts with an existing definition`);
|
|
2188
|
+
}
|
|
2189
|
+
target[name] = value;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
function isRecord6(value) {
|
|
2193
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2019
2196
|
// src/doctor.ts
|
|
2020
2197
|
async function doctor(options) {
|
|
2021
2198
|
const out = options.stdout ?? console;
|
|
2022
2199
|
const cfg = await loadProjectConfig(options.configPath);
|
|
2023
2200
|
const plan = buildPlan(cfg);
|
|
2024
|
-
const
|
|
2025
|
-
const
|
|
2026
|
-
const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
|
|
2027
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
2201
|
+
const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
|
|
2202
|
+
const { schema, rules } = database;
|
|
2028
2203
|
const entities = serializedEntities(schema);
|
|
2029
2204
|
out.log(`config: ${cfg.configPath}`);
|
|
2030
2205
|
out.log(`app: ${plan.appName} (${plan.appId})`);
|
|
2031
2206
|
out.log(`envs: ${plan.envs.join(", ")}`);
|
|
2032
2207
|
out.log(`svc: ${plan.services.join(", ")}`);
|
|
2208
|
+
out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
2033
2209
|
out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
|
|
2034
2210
|
out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
|
|
2035
2211
|
out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
@@ -2051,6 +2227,7 @@ async function doctor(options) {
|
|
|
2051
2227
|
if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
|
|
2052
2228
|
}
|
|
2053
2229
|
}
|
|
2230
|
+
warnings.push(...integrationWarnings(database.integrations, schema, rules));
|
|
2054
2231
|
if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
|
|
2055
2232
|
if (cfg.auth?.clerk) {
|
|
2056
2233
|
for (const [env, value] of Object.entries(cfg.auth.clerk)) {
|
|
@@ -2151,8 +2328,8 @@ Commands:
|
|
|
2151
2328
|
capabilities Show what the CLI automates vs agent edits and human checkpoints.
|
|
2152
2329
|
admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
|
|
2153
2330
|
security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
|
|
2154
|
-
provision Register services,
|
|
2155
|
-
smoke Verify
|
|
2331
|
+
provision Register services, compose integrations, persist credentials, optionally push secrets.
|
|
2332
|
+
smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
|
|
2156
2333
|
skill Same installer; --agent accepts all, claude, codex, cursor,
|
|
2157
2334
|
copilot, gemini, or agents (repeatable or comma-separated).
|
|
2158
2335
|
secrets Push configured db/o11y secrets into the Worker via wrangler
|
|
@@ -2262,6 +2439,9 @@ function configTemplate(input) {
|
|
|
2262
2439
|
},
|
|
2263
2440
|
envs: ${JSON.stringify(input.envs)},
|
|
2264
2441
|
services: ${JSON.stringify(input.services)},
|
|
2442
|
+
// App capabilities are npm modules, not hosted services. Import their
|
|
2443
|
+
// data-only descriptor and add it here (for example createCrmIntegration()).
|
|
2444
|
+
integrations: [],
|
|
2265
2445
|
db: {
|
|
2266
2446
|
schema: "./src/odla/schema.mjs",
|
|
2267
2447
|
rules: "./src/odla/rules.mjs",
|
|
@@ -2339,9 +2519,61 @@ function relativeDisplay(path, rootDir) {
|
|
|
2339
2519
|
|
|
2340
2520
|
// src/provision.ts
|
|
2341
2521
|
var import_apps2 = require("@odla-ai/apps");
|
|
2342
|
-
var
|
|
2522
|
+
var import_ai2 = require("@odla-ai/ai");
|
|
2343
2523
|
var import_node_process7 = __toESM(require("process"), 1);
|
|
2344
2524
|
|
|
2525
|
+
// src/integration-provision.ts
|
|
2526
|
+
var import_db3 = require("@odla-ai/db");
|
|
2527
|
+
async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
|
|
2528
|
+
const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
|
|
2529
|
+
for (const integration of integrations) {
|
|
2530
|
+
for (const seed of integration.seeds ?? []) {
|
|
2531
|
+
const payload = await postJson(doFetch, `${base}/query`, dbKey, {
|
|
2532
|
+
query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
|
|
2533
|
+
});
|
|
2534
|
+
const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
|
|
2535
|
+
if (!Array.isArray(rows)) {
|
|
2536
|
+
throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
|
|
2537
|
+
}
|
|
2538
|
+
if (rows.length > 0) {
|
|
2539
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
await postJson(doFetch, `${base}/transact`, dbKey, {
|
|
2543
|
+
mutationId: `integration:${integration.id}:seed:${seed.id}`,
|
|
2544
|
+
ops: [{
|
|
2545
|
+
t: "update",
|
|
2546
|
+
ns: seed.ns,
|
|
2547
|
+
// A fresh id plus the declared-unique natural key is create-only: a
|
|
2548
|
+
// concurrent creator gets a unique violation instead of being overwritten.
|
|
2549
|
+
id: (0, import_db3.uuidv7)(),
|
|
2550
|
+
attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
|
|
2551
|
+
}]
|
|
2552
|
+
});
|
|
2553
|
+
out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
async function postJson(doFetch, url, bearer, body) {
|
|
2558
|
+
const res = await doFetch(url, {
|
|
2559
|
+
method: "POST",
|
|
2560
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2561
|
+
body: JSON.stringify(body)
|
|
2562
|
+
});
|
|
2563
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
|
|
2564
|
+
return res.json().catch(() => ({}));
|
|
2565
|
+
}
|
|
2566
|
+
function isRecord7(value) {
|
|
2567
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2568
|
+
}
|
|
2569
|
+
async function responseText(res) {
|
|
2570
|
+
try {
|
|
2571
|
+
return redactSecrets((await res.text()).slice(0, 500));
|
|
2572
|
+
} catch {
|
|
2573
|
+
return "";
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2345
2577
|
// src/provision-credentials.ts
|
|
2346
2578
|
var import_apps = require("@odla-ai/apps");
|
|
2347
2579
|
async function provisionEnvCredentials(opts) {
|
|
@@ -2401,14 +2633,14 @@ async function mintDbKey(opts, tenantId) {
|
|
|
2401
2633
|
appId: tenantId
|
|
2402
2634
|
})
|
|
2403
2635
|
});
|
|
2404
|
-
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await
|
|
2636
|
+
if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
|
|
2405
2637
|
res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
|
|
2406
2638
|
method: "POST",
|
|
2407
2639
|
headers,
|
|
2408
2640
|
body: "{}"
|
|
2409
2641
|
});
|
|
2410
2642
|
}
|
|
2411
|
-
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await
|
|
2643
|
+
if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
|
|
2412
2644
|
const body = await res.json();
|
|
2413
2645
|
if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
|
|
2414
2646
|
return body.key;
|
|
@@ -2424,12 +2656,12 @@ async function issueO11yToken(opts) {
|
|
|
2424
2656
|
`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`
|
|
2425
2657
|
);
|
|
2426
2658
|
}
|
|
2427
|
-
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await
|
|
2659
|
+
if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
|
|
2428
2660
|
const body = await res.json();
|
|
2429
2661
|
if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
|
|
2430
2662
|
return body.token;
|
|
2431
2663
|
}
|
|
2432
|
-
async function
|
|
2664
|
+
async function safeText3(res) {
|
|
2433
2665
|
try {
|
|
2434
2666
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
2435
2667
|
} catch {
|
|
@@ -2437,6 +2669,18 @@ async function safeText2(res) {
|
|
|
2437
2669
|
}
|
|
2438
2670
|
}
|
|
2439
2671
|
|
|
2672
|
+
// src/provision-helpers.ts
|
|
2673
|
+
var import_ai = require("@odla-ai/ai");
|
|
2674
|
+
function serviceRank(service) {
|
|
2675
|
+
if (service === "db") return 0;
|
|
2676
|
+
if (service === "calendar") return 2;
|
|
2677
|
+
return 1;
|
|
2678
|
+
}
|
|
2679
|
+
function defaultSecretName(provider) {
|
|
2680
|
+
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
2681
|
+
return names[provider] ?? `${provider}_api_key`;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2440
2684
|
// src/secrets.ts
|
|
2441
2685
|
var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2442
2686
|
async function secretsPush(options) {
|
|
@@ -2528,19 +2772,23 @@ async function provision(options) {
|
|
|
2528
2772
|
out.log(` db: ${plan.dbEndpoint}`);
|
|
2529
2773
|
out.log(` envs: ${plan.envs.join(", ")}`);
|
|
2530
2774
|
out.log(` services: ${plan.services.join(", ")}`);
|
|
2775
|
+
out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
|
|
2531
2776
|
const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
|
|
2532
2777
|
if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
|
|
2533
2778
|
throw new Error(
|
|
2534
2779
|
`refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
|
|
2535
2780
|
);
|
|
2536
2781
|
}
|
|
2537
|
-
const
|
|
2538
|
-
const
|
|
2539
|
-
const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
|
|
2782
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
2783
|
+
const { schema, rules } = database;
|
|
2540
2784
|
if (options.dryRun) {
|
|
2541
2785
|
out.log("dry run: no network calls or file writes");
|
|
2542
2786
|
out.log(` schema: ${schema ? "yes" : "no"}`);
|
|
2543
2787
|
out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
|
|
2788
|
+
for (const integration of database.integrations) {
|
|
2789
|
+
const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
|
|
2790
|
+
out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
|
|
2791
|
+
}
|
|
2544
2792
|
out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
|
|
2545
2793
|
if (cfg.services.includes("calendar")) {
|
|
2546
2794
|
for (const env of cfg.envs) {
|
|
@@ -2650,18 +2898,21 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2650
2898
|
}
|
|
2651
2899
|
}
|
|
2652
2900
|
if (schema && dbKey) {
|
|
2653
|
-
await
|
|
2901
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
|
|
2654
2902
|
out.log(`${env}: schema pushed`);
|
|
2655
2903
|
}
|
|
2656
2904
|
if (rules) {
|
|
2657
|
-
await
|
|
2905
|
+
await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
|
|
2658
2906
|
out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
|
|
2659
2907
|
}
|
|
2908
|
+
if (dbKey) {
|
|
2909
|
+
await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
|
|
2910
|
+
}
|
|
2660
2911
|
if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
|
|
2661
2912
|
const key = import_node_process7.default.env[cfg.ai.keyEnv];
|
|
2662
2913
|
if (key) {
|
|
2663
2914
|
const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
|
|
2664
|
-
await (0,
|
|
2915
|
+
await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
|
|
2665
2916
|
out.log(`${env}: ${cfg.ai.provider} key stored in vault (${secretName})`);
|
|
2666
2917
|
} else {
|
|
2667
2918
|
out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
|
|
@@ -2697,11 +2948,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
|
|
|
2697
2948
|
}
|
|
2698
2949
|
}
|
|
2699
2950
|
}
|
|
2700
|
-
function serviceRank(service) {
|
|
2701
|
-
if (service === "db") return 0;
|
|
2702
|
-
if (service === "calendar") return 2;
|
|
2703
|
-
return 1;
|
|
2704
|
-
}
|
|
2705
2951
|
async function readRegistryApp(cfg, token, doFetch) {
|
|
2706
2952
|
const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
|
|
2707
2953
|
headers: { authorization: `Bearer ${token}` }
|
|
@@ -2711,13 +2957,13 @@ async function readRegistryApp(cfg, token, doFetch) {
|
|
|
2711
2957
|
const json = await res.json();
|
|
2712
2958
|
return json.app ?? null;
|
|
2713
2959
|
}
|
|
2714
|
-
async function
|
|
2960
|
+
async function postJson2(doFetch, url, bearer, body) {
|
|
2715
2961
|
const res = await doFetch(url, {
|
|
2716
2962
|
method: "POST",
|
|
2717
2963
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
2718
2964
|
body: JSON.stringify(body)
|
|
2719
2965
|
});
|
|
2720
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await
|
|
2966
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
|
|
2721
2967
|
}
|
|
2722
2968
|
function normalizeClerkConfig(value) {
|
|
2723
2969
|
if (!value) return null;
|
|
@@ -2730,11 +2976,7 @@ function normalizeClerkConfig(value) {
|
|
|
2730
2976
|
const publishableKey = envValue(cfg.publishableKey);
|
|
2731
2977
|
return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
|
|
2732
2978
|
}
|
|
2733
|
-
function
|
|
2734
|
-
const names = import_ai.DEFAULT_SECRET_NAMES;
|
|
2735
|
-
return names[provider] ?? `${provider}_api_key`;
|
|
2736
|
-
}
|
|
2737
|
-
async function safeText3(res) {
|
|
2979
|
+
async function safeText4(res) {
|
|
2738
2980
|
try {
|
|
2739
2981
|
return redactSecrets((await res.text()).slice(0, 500));
|
|
2740
2982
|
} catch {
|
|
@@ -2743,7 +2985,7 @@ async function safeText3(res) {
|
|
|
2743
2985
|
}
|
|
2744
2986
|
|
|
2745
2987
|
// src/secrets-set.ts
|
|
2746
|
-
var
|
|
2988
|
+
var import_ai3 = require("@odla-ai/ai");
|
|
2747
2989
|
var import_apps3 = require("@odla-ai/apps");
|
|
2748
2990
|
var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
|
|
2749
2991
|
async function secretsSet(options) {
|
|
@@ -2755,7 +2997,7 @@ async function secretsSet(options) {
|
|
|
2755
2997
|
const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
|
|
2756
2998
|
const token = await getDeveloperToken(cfg, options, doFetch, out);
|
|
2757
2999
|
try {
|
|
2758
|
-
await (0,
|
|
3000
|
+
await (0, import_ai3.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
|
|
2759
3001
|
} catch (err) {
|
|
2760
3002
|
throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
|
|
2761
3003
|
}
|
|
@@ -3061,7 +3303,7 @@ function formatBudget(usage) {
|
|
|
3061
3303
|
|
|
3062
3304
|
// src/security-hosted-github.ts
|
|
3063
3305
|
var import_node_child_process4 = require("child_process");
|
|
3064
|
-
var
|
|
3306
|
+
var import_node_util2 = require("util");
|
|
3065
3307
|
|
|
3066
3308
|
// src/security-hosted-request.ts
|
|
3067
3309
|
async function requestHostedSecurityJson(options, path, init, action) {
|
|
@@ -3265,7 +3507,7 @@ async function inferGitHubRepository(cwd = process.cwd(), readOrigin = defaultRe
|
|
|
3265
3507
|
return repositoryFromGitRemote(remote);
|
|
3266
3508
|
}
|
|
3267
3509
|
async function defaultReadOrigin(cwd) {
|
|
3268
|
-
const result = await (0,
|
|
3510
|
+
const result = await (0, import_node_util2.promisify)(import_node_child_process4.execFile)(
|
|
3269
3511
|
"git",
|
|
3270
3512
|
["remote", "get-url", "origin"],
|
|
3271
3513
|
{ cwd, encoding: "utf8" }
|
|
@@ -4014,7 +4256,8 @@ async function smoke(options) {
|
|
|
4014
4256
|
assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
|
|
4015
4257
|
out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
|
|
4016
4258
|
}
|
|
4017
|
-
const
|
|
4259
|
+
const database = await resolveDatabaseConfig(cfg);
|
|
4260
|
+
const expectedSchema = database.schema;
|
|
4018
4261
|
const expectedEntities = serializedEntities(expectedSchema);
|
|
4019
4262
|
const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
|
|
4020
4263
|
const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
|
|
@@ -4026,7 +4269,7 @@ async function smoke(options) {
|
|
|
4026
4269
|
out.log(` schema: ${liveEntities.length} entities`);
|
|
4027
4270
|
const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
|
|
4028
4271
|
if (aggregateEntity) {
|
|
4029
|
-
const aggregate = await
|
|
4272
|
+
const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
|
|
4030
4273
|
ns: aggregateEntity,
|
|
4031
4274
|
aggregate: { count: true }
|
|
4032
4275
|
});
|
|
@@ -4035,6 +4278,21 @@ async function smoke(options) {
|
|
|
4035
4278
|
} else {
|
|
4036
4279
|
out.log(` aggregate: skipped (schema has no entities)`);
|
|
4037
4280
|
}
|
|
4281
|
+
const probes = database.integrations.flatMap(
|
|
4282
|
+
(integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
|
|
4283
|
+
);
|
|
4284
|
+
if (probes.length) {
|
|
4285
|
+
const link = cfg.links?.[env];
|
|
4286
|
+
if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
|
|
4287
|
+
for (const { integration, probe } of probes) {
|
|
4288
|
+
const probeUrl = new URL(probe.path, link).toString();
|
|
4289
|
+
const res = await doFetch(probeUrl, { redirect: "manual" });
|
|
4290
|
+
if (res.status !== probe.expectedStatus) {
|
|
4291
|
+
throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
|
|
4292
|
+
}
|
|
4293
|
+
out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4038
4296
|
out.log("ok");
|
|
4039
4297
|
}
|
|
4040
4298
|
function assertCalendarHealthy(status, expected) {
|
|
@@ -4052,16 +4310,16 @@ async function getJson(doFetch, url, bearer) {
|
|
|
4052
4310
|
const res = await doFetch(url, {
|
|
4053
4311
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
|
|
4054
4312
|
});
|
|
4055
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
4313
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
4056
4314
|
return res.json();
|
|
4057
4315
|
}
|
|
4058
|
-
async function
|
|
4316
|
+
async function postJson3(doFetch, url, bearer, body) {
|
|
4059
4317
|
const res = await doFetch(url, {
|
|
4060
4318
|
method: "POST",
|
|
4061
4319
|
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
4062
4320
|
body: JSON.stringify(body)
|
|
4063
4321
|
});
|
|
4064
|
-
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await
|
|
4322
|
+
if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
|
|
4065
4323
|
return res.json();
|
|
4066
4324
|
}
|
|
4067
4325
|
function publicConfigUrl(platformUrl, appId, env) {
|
|
@@ -4069,7 +4327,7 @@ function publicConfigUrl(platformUrl, appId, env) {
|
|
|
4069
4327
|
url.searchParams.set("env", env);
|
|
4070
4328
|
return url.toString();
|
|
4071
4329
|
}
|
|
4072
|
-
async function
|
|
4330
|
+
async function safeText5(res) {
|
|
4073
4331
|
try {
|
|
4074
4332
|
return (await res.text()).slice(0, 500);
|
|
4075
4333
|
} catch {
|