@happyvertical/smrt-cli 0.51.39 → 0.53.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/AGENTS.md +7 -2
- package/README.md +22 -0
- package/agents/type-drift.md +16 -0
- package/dist/{ApiKey-BZmbOTnL-DEwZtcGA.js → ApiKey-BZmbOTnL-d61__hH4.js} +2 -2
- package/dist/ApiKeyCollection-Bfb_nw81-BVJ-i1Aw.js +2 -0
- package/dist/AuditLogCollection-B1YonCAP-bpq7rM2b.js +2 -0
- package/dist/{NostrIdentity-n70E0vck-DiBJPv76.js → NostrIdentity-n70E0vck-CmFPKTaE.js} +3 -3
- package/dist/NostrIdentityCollection-Ca74dkaL-C0rNgSy-.js +2 -0
- package/dist/{OidcIdentity-K8V4u0CR-B_ek3cPN.js → OidcIdentity-Cb1bxreM-L5LCgsel.js} +3 -3
- package/dist/OidcIdentityCollection-TV8VdK69-CUmZ-j4r.js +2 -0
- package/dist/ProfileAssetCollection-B9hacUJa-C53RAi1e.js +2 -0
- package/dist/{ProfileCollection-DTT4KNpQ-D1RNYfjm.js → ProfileCollection-DuJT7Mum-zuWR2h3v.js} +69 -31
- package/dist/ProfileLinkCollection-4uNURz32-Fmbnrmyr.js +2 -0
- package/dist/ProfileMetadataCollection-DQhbfEfa-D30yWyzB.js +2 -0
- package/dist/{ProfileMetafieldCollection-CMKhVi2I-mh3UgeZA.js → ProfileMetafieldCollection-CoBuM_5G-TNxSMsBJ.js} +1 -1
- package/dist/{ProfileRelationshipCollection-DUVgaIqf-CXHrAni4.js → ProfileRelationshipCollection-DUVgaIqf-CKcxS2xW.js} +1 -1
- package/dist/{ProfileRelationshipTermCollection-DtA9OM23-Cj4bLTpU.js → ProfileRelationshipTermCollection-DtA9OM23-h-4GhOFl.js} +1 -1
- package/dist/{ProfileRelationshipType-BRiRka6t-ICv2cawn.js → ProfileRelationshipType-BRiRka6t-BFgCVe6p.js} +1 -1
- package/dist/{ProfileRelationshipTypeCollection-BdXwd7Kf-CaYLxJmh.js → ProfileRelationshipTypeCollection-BdXwd7Kf-Djb3_k5Z.js} +1 -1
- package/dist/cli-generator.d.ts.map +1 -1
- package/dist/commands/db-diff.d.ts.map +1 -1
- package/dist/commands/db-history.d.ts.map +1 -1
- package/dist/commands/db-migrate-actions.d.ts +7 -0
- package/dist/commands/db-migrate-actions.d.ts.map +1 -1
- package/dist/commands/db-parity.d.ts +7 -0
- package/dist/commands/db-parity.d.ts.map +1 -1
- package/dist/commands/db-qualified-names.d.ts +39 -0
- package/dist/commands/db-qualified-names.d.ts.map +1 -0
- package/dist/commands/db-status.d.ts.map +1 -1
- package/dist/commands/gnode.d.ts.map +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/mcp-apps-packaging.d.ts +24 -0
- package/dist/commands/mcp-apps-packaging.d.ts.map +1 -0
- package/dist/commands/tenant-natural-keys.d.ts +58 -0
- package/dist/commands/tenant-natural-keys.d.ts.map +1 -0
- package/dist/commands/utilities.d.ts.map +1 -1
- package/dist/{commands-C2PjCUio.js → commands-e1tsvozP.js} +650 -22
- package/dist/{dist-B3npUhW6.js → dist-8C4XjDhz.js} +151 -6
- package/dist/{dist-C8sNOzr2.js → dist-CLA60jwS.js} +4 -4
- package/dist/dist-CksLvqnZ.js +1705 -0
- package/dist/dist-DtTkpUee.js +3937 -0
- package/dist/index.js +17 -11
- package/dist/{json-validator-oEN7eUI3.js → json-validator-BiAdsE1O.js} +4 -4
- package/dist/{oidcProvisioningPrimitives-0-3n3D6f-yUBRl2DC.js → oidcProvisioningPrimitives-D9OYu5-F-CJ6bTipi.js} +7 -7
- package/dist/resolveIdentity-B-PK6RwU-DxJFF8ad.js +2 -0
- package/package.json +16 -15
- package/dist/ApiKeyCollection-Bfb_nw81-D4pLjDGS.js +0 -2
- package/dist/AuditLogCollection-B1YonCAP-w1iOHW4A.js +0 -2
- package/dist/NostrIdentityCollection-Ca74dkaL-Ce-O_zQz.js +0 -2
- package/dist/OidcIdentityCollection-gnrLoF08-DJI-69Ow.js +0 -2
- package/dist/ProfileAssetCollection-CmbeSgDi-BpJ7XXsP.js +0 -2
- package/dist/ProfileMetadataCollection-DxX_BJKV-orYFlYii.js +0 -2
- package/dist/dist-DsMohSo_.js +0 -1487
- package/dist/dist-iMaSv6C_.js +0 -3712
- package/dist/resolveIdentity-BrJDCjYi-kdAssLr0.js +0 -2
|
@@ -2,7 +2,7 @@ import { t as resolveManifestEntryPackageName } from "./index.js";
|
|
|
2
2
|
import { t as DEFAULT_CLI_CONFIG } from "./config-hqNo4lzH.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -13,7 +13,7 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
13
13
|
import glob from "fast-glob";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { getPackageConfig } from "@happyvertical/smrt-config";
|
|
16
|
-
import { toSnakeCase } from "@happyvertical/smrt-core/utils";
|
|
16
|
+
import { toSnakeCase as toSnakeCase$1 } from "@happyvertical/smrt-core/utils";
|
|
17
17
|
import { checkKnowledgeGraphFreshness, discoverScopedPackageDirectories, readAgentModuleDocs, readPackageAgentDoc, resolveAgentModuleDocPaths } from "@happyvertical/smrt-core/knowledge";
|
|
18
18
|
import { MCPGenerator } from "@happyvertical/smrt-core/generators";
|
|
19
19
|
import { generateDeclarationsFromCLI } from "@happyvertical/smrt-core/prebuild";
|
|
@@ -21,9 +21,10 @@ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
|
21
21
|
import https from "node:https";
|
|
22
22
|
import { homedir, tmpdir } from "node:os";
|
|
23
23
|
import { extract } from "tar";
|
|
24
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
24
25
|
import { importWorkspaceModule } from "@happyvertical/smrt-core/utils/import-workspace-module";
|
|
25
26
|
import { renderForeignKeyAddStatements } from "@happyvertical/smrt-core/schema";
|
|
26
|
-
import { FRAMEWORK_BASE_TABLE_NAMES, buildIntegerWidthTableStatements, collectIntegerWidthTargets, collectNullEqualIndexTargets, dropFrameworkBaseTables, migrateNullEqualIndexes, nullEqualIndexStatements, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, preflightIntegerWidthWidening, preflightNullEqualIndexes, widenIntegerColumnsToBigInt } from "@happyvertical/smrt-core/migrations";
|
|
27
|
+
import { FRAMEWORK_BASE_TABLE_NAMES, backfillLegacyQualifiedNames, buildIntegerWidthTableStatements, collectIntegerWidthTargets, collectNullEqualIndexTargets, countLegacyQualifiedNameReferences, dropFrameworkBaseTables, migrateNullEqualIndexes, nullEqualIndexStatements, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, preflightIntegerWidthWidening, preflightNullEqualIndexes, widenIntegerColumnsToBigInt } from "@happyvertical/smrt-core/migrations";
|
|
27
28
|
import { buildArchitectureContext, buildKnowledgeIndex, buildReviewContext, checkKnowledgeFreshness, compactContextResult, diffKnowledgeIndex, renderFreshnessResult, renderKnowledgeIndexMarkdown } from "@happyvertical/smrt-dev-mcp/knowledge";
|
|
28
29
|
import { RUNTIME_TOOLS } from "@happyvertical/smrt-dev-mcp/runtime";
|
|
29
30
|
//#region src/commands/db-command-utils.ts
|
|
@@ -767,7 +768,8 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
767
768
|
...change.foreignKey ? { foreignKey: change.foreignKey } : {},
|
|
768
769
|
...change.orphanBlocked !== void 0 ? { orphanBlocked: change.orphanBlocked } : {},
|
|
769
770
|
...change.orphanNullable !== void 0 ? { orphanNullable: change.orphanNullable } : {},
|
|
770
|
-
...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {}
|
|
771
|
+
...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {},
|
|
772
|
+
...change.note ? { note: change.note } : {}
|
|
771
773
|
};
|
|
772
774
|
if (isAdvisoryOnlyChangeLike(change)) manualInterventions.push(action);
|
|
773
775
|
else migrations.push(action);
|
|
@@ -833,7 +835,8 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
833
835
|
actual: mm.actual
|
|
834
836
|
},
|
|
835
837
|
sql: change.sql,
|
|
836
|
-
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
838
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {},
|
|
839
|
+
...change.note ? { note: change.note } : {}
|
|
837
840
|
};
|
|
838
841
|
const executionKind = classifyTypeUpgradeSql(change.sql);
|
|
839
842
|
if (executionKind === "executable") migrations.push(action);
|
|
@@ -1230,6 +1233,11 @@ var dbDiffCommand = {
|
|
|
1230
1233
|
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
1231
1234
|
default: false
|
|
1232
1235
|
},
|
|
1236
|
+
"drop-legacy-natural-key": {
|
|
1237
|
+
type: "boolean",
|
|
1238
|
+
description: "Include the drop of the legacy global (slug, context) unique index a tenant-led natural key superseded (see db:migrate --drop-legacy-natural-key).",
|
|
1239
|
+
default: false
|
|
1240
|
+
},
|
|
1233
1241
|
"drop-columns": {
|
|
1234
1242
|
type: "boolean",
|
|
1235
1243
|
description: "Include orphan-column drops in the diff (columns in DB but not in the manifest). Off by default; orphans are always reported.",
|
|
@@ -1243,6 +1251,11 @@ var dbDiffCommand = {
|
|
|
1243
1251
|
"postgres-timestamp-legacy-timezone": {
|
|
1244
1252
|
type: "string",
|
|
1245
1253
|
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
1254
|
+
},
|
|
1255
|
+
"empty-text-as-null": {
|
|
1256
|
+
type: "boolean",
|
|
1257
|
+
description: "Preview storing empty or whitespace-only text as NULL when converging a nullable legacy text column to a typed column (timestamp, JSON, integer). Any other value that does not convert still blocks. Off by default; the diff names the empty-text count instead.",
|
|
1258
|
+
default: false
|
|
1246
1259
|
}
|
|
1247
1260
|
},
|
|
1248
1261
|
handler: async (_args, options) => {
|
|
@@ -1298,8 +1311,10 @@ var dbDiffCommand = {
|
|
|
1298
1311
|
includeDroppedTables: false,
|
|
1299
1312
|
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
1300
1313
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
1314
|
+
dropLegacyNaturalKey: Boolean(options["drop-legacy-natural-key"]),
|
|
1301
1315
|
relaxColumns: Boolean(options["relax-columns"]),
|
|
1302
|
-
postgresTimestampMigration
|
|
1316
|
+
postgresTimestampMigration,
|
|
1317
|
+
emptyTextAsNull: Boolean(options["empty-text-as-null"])
|
|
1303
1318
|
}).compare(schemaDefinitions);
|
|
1304
1319
|
if (options.json) {
|
|
1305
1320
|
console.log(JSON.stringify({
|
|
@@ -1394,9 +1409,21 @@ var dbDiffCommand = {
|
|
|
1394
1409
|
}
|
|
1395
1410
|
if (typeUpgrades.length > 0) {
|
|
1396
1411
|
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
1397
|
-
for (const change of typeUpgrades)
|
|
1412
|
+
for (const change of typeUpgrades) {
|
|
1413
|
+
console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
1414
|
+
if (change.note) console.log(` (${change.note})`);
|
|
1415
|
+
}
|
|
1398
1416
|
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
1399
1417
|
}
|
|
1418
|
+
const foreignKeyChanges = diff.changes.filter((c) => c.type === "add_foreign_key" && (c.sql || c.sqlStatements?.length));
|
|
1419
|
+
if (foreignKeyChanges.length > 0) {
|
|
1420
|
+
console.log(` 🔗 Foreign keys (${foreignKeyChanges.length}):`);
|
|
1421
|
+
for (const change of foreignKeyChanges) {
|
|
1422
|
+
console.log(` + ${change.name} on ${change.table}${change.note ? ` (${change.note})` : ""}`);
|
|
1423
|
+
if (change.advisory?.severity === "warning") console.log(` ⚠️ ${change.advisory.message}`);
|
|
1424
|
+
}
|
|
1425
|
+
console.log();
|
|
1426
|
+
}
|
|
1400
1427
|
if (typeMismatches.length > 0) {
|
|
1401
1428
|
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
1402
1429
|
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
@@ -1581,6 +1608,11 @@ var dbHistoryCommand = {
|
|
|
1581
1608
|
description: "Show full details including checksums",
|
|
1582
1609
|
default: false,
|
|
1583
1610
|
short: "v"
|
|
1611
|
+
},
|
|
1612
|
+
"empty-text-as-null": {
|
|
1613
|
+
type: "boolean",
|
|
1614
|
+
description: "Assess the same convergence `db:migrate --empty-text-as-null` would apply: a nullable legacy text column whose only obstacle is empty or whitespace-only text is pending, not blocked. Off by default.",
|
|
1615
|
+
default: false
|
|
1584
1616
|
}
|
|
1585
1617
|
},
|
|
1586
1618
|
handler: async (_args, options) => {
|
|
@@ -1619,7 +1651,7 @@ var dbHistoryCommand = {
|
|
|
1619
1651
|
if (failedHistory.length > 0 && typeof db.getTableSchema === "function") try {
|
|
1620
1652
|
await autoDiscoverAndLoad();
|
|
1621
1653
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
1622
|
-
const diff = await new SchemaComparer(db).compare(manifestSchemas);
|
|
1654
|
+
const diff = await new SchemaComparer(db, { emptyTextAsNull: Boolean(options["empty-text-as-null"]) }).compare(manifestSchemas);
|
|
1623
1655
|
liveSchemaCompared = true;
|
|
1624
1656
|
failedAssessments = assessFailedMigrations(failedHistory, diff);
|
|
1625
1657
|
} catch {
|
|
@@ -2868,7 +2900,7 @@ async function evaluateSchemaContract(options) {
|
|
|
2868
2900
|
present = (await ObjectRegistry.getAllFields(entry.name)).has(fieldName);
|
|
2869
2901
|
if (present) {
|
|
2870
2902
|
table = inferTableName(entry);
|
|
2871
|
-
column = toSnakeCase(fieldName);
|
|
2903
|
+
column = toSnakeCase$1(fieldName);
|
|
2872
2904
|
if (table) {
|
|
2873
2905
|
inferredTables.add(table);
|
|
2874
2906
|
inferredColumns.add(`${table}.${column}`);
|
|
@@ -2928,6 +2960,99 @@ function assertSchemaContract(report) {
|
|
|
2928
2960
|
if (!report.ok) throw new SchemaContractError(report);
|
|
2929
2961
|
}
|
|
2930
2962
|
//#endregion
|
|
2963
|
+
//#region src/commands/tenant-natural-keys.ts
|
|
2964
|
+
/**
|
|
2965
|
+
* Tenant natural-key detector for `db:status`.
|
|
2966
|
+
*
|
|
2967
|
+
* A tenant-owned table (declared tenant scope, or an undeclared `tenantId`
|
|
2968
|
+
* field — `ObjectRegistry.getOwnershipTenantColumn()`) upserts on a
|
|
2969
|
+
* tenant-led key such as `(tenant_id, slug, context)`. A live database created
|
|
2970
|
+
* before that default can still carry the GLOBAL `(slug, context)` unique:
|
|
2971
|
+
* then a second tenant's same-slug save collides with a row it cannot see.
|
|
2972
|
+
* Before the save-time ownership guard, `ON CONFLICT … DO UPDATE` rewrote that
|
|
2973
|
+
* row's `id` and `tenant_id` (the Anytown Ludis takeover); now the save is
|
|
2974
|
+
* refused, and on engines that require an exact ON CONFLICT target the create
|
|
2975
|
+
* fails until `smrt db:migrate` swaps the index.
|
|
2976
|
+
*
|
|
2977
|
+
* Rollout is expand, then contract: `smrt db:migrate` builds the tenant-led
|
|
2978
|
+
* unique beside the global one (an `error` until it exists, so db:status exits
|
|
2979
|
+
* 1), and the global one stays — reported as a `warning` — until
|
|
2980
|
+
* `smrt db:migrate --drop-legacy-natural-key` once no old code is running.
|
|
2981
|
+
* Explicit `@smrt({ conflictColumns })` that omit the tenant column are the
|
|
2982
|
+
* author's declared contract and are not reported here (the tenancy
|
|
2983
|
+
* package's `auditTenantScopedRegistrations()` flags runtime-registered ones).
|
|
2984
|
+
*/
|
|
2985
|
+
function sameColumnSet(a, b) {
|
|
2986
|
+
return a.length === b.length && a.every((column) => b.includes(column));
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Compare every tenant-owned table's upsert target with its live unique
|
|
2990
|
+
* indexes. Tables absent from `liveSchemas` (not created yet, or not
|
|
2991
|
+
* introspected) are skipped — the schema diff reports those.
|
|
2992
|
+
*/
|
|
2993
|
+
function checkTenantNaturalKeyUniques(liveSchemas) {
|
|
2994
|
+
const findings = [];
|
|
2995
|
+
const seenTables = /* @__PURE__ */ new Set();
|
|
2996
|
+
for (const className of ObjectRegistry.getQualifiedClassNames()) {
|
|
2997
|
+
if (isCollectionRegistration(className)) continue;
|
|
2998
|
+
const tableName = ObjectRegistry.getTableName(className);
|
|
2999
|
+
if (!tableName || seenTables.has(tableName)) continue;
|
|
3000
|
+
const tenantColumn = ObjectRegistry.getOwnershipTenantColumn(className);
|
|
3001
|
+
if (!tenantColumn) continue;
|
|
3002
|
+
const conflictColumns = ObjectRegistry.getConflictColumns(className);
|
|
3003
|
+
if (!conflictColumns.includes(tenantColumn)) continue;
|
|
3004
|
+
const live = liveSchemas.get(tableName);
|
|
3005
|
+
if (!live) continue;
|
|
3006
|
+
seenTables.add(tableName);
|
|
3007
|
+
const uniques = (live.indexes ?? []).filter((index) => index.unique === true && !index.where);
|
|
3008
|
+
const naturalKey = conflictColumns.filter((column) => column !== tenantColumn);
|
|
3009
|
+
const global = uniques.find((index) => sameColumnSet(index.columns ?? [], naturalKey));
|
|
3010
|
+
const target = `(${conflictColumns.join(", ")})`;
|
|
3011
|
+
if (uniques.some((index) => sameColumnSet(index.columns ?? [], conflictColumns))) {
|
|
3012
|
+
if (global) findings.push({
|
|
3013
|
+
name: `${tableName}.${global.name ?? "(unnamed unique)"}`,
|
|
3014
|
+
status: "warning",
|
|
3015
|
+
message: `Tenant-owned table "${tableName}" (${className}) still carries the legacy unique index on (${naturalKey.join(", ")}) beside the tenant-led ${target}. Until it is dropped, a second tenant cannot store a key another tenant already uses (the save fails with a unique violation; nothing is overwritten).`,
|
|
3016
|
+
recommendation: "Once every running instance is on the release that upserts on the tenant-led key, run `smrt db:migrate --drop-legacy-natural-key` to drop it.",
|
|
3017
|
+
details: {
|
|
3018
|
+
className,
|
|
3019
|
+
tableName,
|
|
3020
|
+
conflictColumns,
|
|
3021
|
+
liveIndex: global.name,
|
|
3022
|
+
kind: "legacy_global_unique"
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
continue;
|
|
3026
|
+
}
|
|
3027
|
+
if (global) findings.push({
|
|
3028
|
+
name: `${tableName}.${global.name ?? "(unnamed unique)"}`,
|
|
3029
|
+
status: "error",
|
|
3030
|
+
message: `Tenant-owned table "${tableName}" (${className}) has a unique index on (${naturalKey.join(", ")}) shared by every tenant, but the model upserts on ${target}. A second tenant cannot store a key another tenant already uses; saves that collide are refused (TENANT_ISOLATION_VIOLATION), and creates can fail outright until the index matches the upsert target.`,
|
|
3031
|
+
recommendation: "Run `smrt db:migrate` BEFORE deploying the release that upserts on the tenant-led key: it builds the tenant-inclusive unique under its own name (a superset, so it cannot fail on existing rows) and keeps this one, so old code (ON CONFLICT on the global key) and new code (ON CONFLICT on the tenant-led key) both keep working. After the rollout, `smrt db:migrate --drop-legacy-natural-key` drops the legacy index.",
|
|
3032
|
+
details: {
|
|
3033
|
+
className,
|
|
3034
|
+
tableName,
|
|
3035
|
+
conflictColumns,
|
|
3036
|
+
liveIndex: global.name,
|
|
3037
|
+
kind: "global_unique"
|
|
3038
|
+
}
|
|
3039
|
+
});
|
|
3040
|
+
else findings.push({
|
|
3041
|
+
name: `${tableName}.${target}`,
|
|
3042
|
+
status: "warning",
|
|
3043
|
+
message: `Tenant-owned table "${tableName}" (${className}) has no unique index on ${target}, the key the model upserts on.`,
|
|
3044
|
+
recommendation: "Run `smrt db:migrate` to create the tenant-inclusive unique index.",
|
|
3045
|
+
details: {
|
|
3046
|
+
className,
|
|
3047
|
+
tableName,
|
|
3048
|
+
conflictColumns,
|
|
3049
|
+
kind: "missing_tenant_unique"
|
|
3050
|
+
}
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
return findings;
|
|
3054
|
+
}
|
|
3055
|
+
//#endregion
|
|
2931
3056
|
//#region src/commands/db-status.ts
|
|
2932
3057
|
/**
|
|
2933
3058
|
* db:status Command
|
|
@@ -3148,6 +3273,11 @@ var dbStatusCommand = {
|
|
|
3148
3273
|
description: "Also compare the live schema to the expected shape (incl. _smrt_* system tables and index policy)",
|
|
3149
3274
|
default: false,
|
|
3150
3275
|
short: "p"
|
|
3276
|
+
},
|
|
3277
|
+
"empty-text-as-null": {
|
|
3278
|
+
type: "boolean",
|
|
3279
|
+
description: "Assess the same convergence `db:migrate --empty-text-as-null` would apply: a nullable legacy text column whose only obstacle is empty or whitespace-only text is pending, not blocked. Off by default.",
|
|
3280
|
+
default: false
|
|
3151
3281
|
}
|
|
3152
3282
|
},
|
|
3153
3283
|
handler: async (_args, options) => {
|
|
@@ -3246,16 +3376,16 @@ var dbStatusCommand = {
|
|
|
3246
3376
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
3247
3377
|
let comparer;
|
|
3248
3378
|
if (typeof db.getTableSchema === "function") {
|
|
3249
|
-
comparer = new SchemaComparer(db);
|
|
3379
|
+
comparer = new SchemaComparer(db, { emptyTextAsNull: Boolean(options["empty-text-as-null"]) });
|
|
3250
3380
|
diff = await comparer.compare(manifestSchemas);
|
|
3251
3381
|
status.drift = summarizeSchemaDiff(diff);
|
|
3252
3382
|
status.notes = summarizeSchemaNotes(diff);
|
|
3253
|
-
status.preconditions = await checkTenantIdUuidPreconditions({
|
|
3383
|
+
status.preconditions = [...await checkTenantIdUuidPreconditions({
|
|
3254
3384
|
db,
|
|
3255
3385
|
dbType,
|
|
3256
3386
|
dbUrl,
|
|
3257
3387
|
manifestSchemas
|
|
3258
|
-
});
|
|
3388
|
+
}), ...checkTenantNaturalKeyUniques(comparer.getLiveSchemaSnapshot())];
|
|
3259
3389
|
status.failedMigrations = summarizeFailedMigrations(failed, getUnresolvedGeneratedMigrationNames(diff.changes));
|
|
3260
3390
|
if (options.parity) try {
|
|
3261
3391
|
status.parity = await checkLiveSchemaParity({
|
|
@@ -4139,14 +4269,14 @@ var docsCommands = {
|
|
|
4139
4269
|
* This replaces per-agent export commands (praeco export, caelus export, etc.)
|
|
4140
4270
|
* with a single, schema-driven export that respects field-level export annotations.
|
|
4141
4271
|
*/
|
|
4142
|
-
function toSnakeCase$
|
|
4272
|
+
function toSnakeCase$2(str) {
|
|
4143
4273
|
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
|
|
4144
4274
|
}
|
|
4145
4275
|
function toColumnName(fieldName) {
|
|
4146
4276
|
const dotIndex = fieldName.indexOf(".");
|
|
4147
4277
|
const baseFieldName = dotIndex >= 0 ? fieldName.substring(0, dotIndex) : fieldName;
|
|
4148
4278
|
const jsonPath = dotIndex >= 0 ? fieldName.substring(dotIndex) : "";
|
|
4149
|
-
return `${baseFieldName.startsWith("_") ? `_${toSnakeCase$
|
|
4279
|
+
return `${baseFieldName.startsWith("_") ? `_${toSnakeCase$2(baseFieldName.slice(1))}` : toSnakeCase$2(baseFieldName)}${jsonPath}`;
|
|
4150
4280
|
}
|
|
4151
4281
|
function assertSafeIdentifier(identifier, label) {
|
|
4152
4282
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$/.test(identifier)) throw new Error(`Invalid ${label}: ${identifier}`);
|
|
@@ -5554,6 +5684,19 @@ async function loadGitTemplate(gitUrl) {
|
|
|
5554
5684
|
}
|
|
5555
5685
|
}
|
|
5556
5686
|
/**
|
|
5687
|
+
* Get the template directory path (for copying overlay files)
|
|
5688
|
+
*
|
|
5689
|
+
* Prefers the subdir-resolved `__templateRoot` so that subdirectory templates
|
|
5690
|
+
* (`github:user/repo/subdir`) overlay from the subdir rather than the repo root.
|
|
5691
|
+
* Falls back to `__tempDir` for configs loaded before the field existed.
|
|
5692
|
+
*/
|
|
5693
|
+
function getGitTemplateDir(config) {
|
|
5694
|
+
const gitConfig = config;
|
|
5695
|
+
const templateRoot = gitConfig.__templateRoot ?? gitConfig.__tempDir;
|
|
5696
|
+
if (!templateRoot) throw new Error("Template was not loaded from git repository");
|
|
5697
|
+
return templateRoot;
|
|
5698
|
+
}
|
|
5699
|
+
/**
|
|
5557
5700
|
* Cleanup temporary directory after template is used
|
|
5558
5701
|
*/
|
|
5559
5702
|
async function cleanupGitTemplate(config) {
|
|
@@ -6102,8 +6245,310 @@ async function processPlaceholders(outputDir, config, options) {
|
|
|
6102
6245
|
console.log(` Processed placeholders in ${replacedCount} files`);
|
|
6103
6246
|
}
|
|
6104
6247
|
//#endregion
|
|
6248
|
+
//#region src/commands/mcp-apps-packaging.ts
|
|
6249
|
+
/**
|
|
6250
|
+
* Portable MCP Apps plugin packaging helpers.
|
|
6251
|
+
*
|
|
6252
|
+
* These helpers intentionally validate distribution metadata only. Runtime
|
|
6253
|
+
* MCP extension metadata is owned by the v2 server adapter and is not an
|
|
6254
|
+
* install-plugin manifest.
|
|
6255
|
+
*/
|
|
6256
|
+
var requireSchema = createRequire(import.meta.url);
|
|
6257
|
+
var pluginSchema = requireSchema("@happyvertical/smrt-dev-mcp/schemas/agent-plugins-1.0.0/plugin.schema.json");
|
|
6258
|
+
var mcpSchema = requireSchema("@happyvertical/smrt-dev-mcp/schemas/agent-plugins-1.0.0/mcp.schema.json");
|
|
6259
|
+
var ajv = new Ajv2020({
|
|
6260
|
+
strict: true,
|
|
6261
|
+
allErrors: true
|
|
6262
|
+
});
|
|
6263
|
+
var validatePluginSchema = ajv.compile(pluginSchema);
|
|
6264
|
+
var validateMcpSchema = ajv.compile(mcpSchema);
|
|
6265
|
+
var PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
6266
|
+
var MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
|
|
6267
|
+
var CANONICAL_SCOPE_REGISTRY = "@happyvertical:registry=https://npm.happyvertical.com/";
|
|
6268
|
+
var SECRET_KEY = /(?:api[_-]?key|secret|token|password|authorization|credential)/i;
|
|
6269
|
+
var ENVIRONMENT_CREDENTIAL_FILE = /^\.env(?:\.|$)/;
|
|
6270
|
+
var PRIVATE_KEY_CARRIER = /^(?:id_(?:rsa|dsa|ecdsa|ed25519)|private(?:[._-]key)?|.*\.key)$/i;
|
|
6271
|
+
var PRIVATE_KEY_HEADER = /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/;
|
|
6272
|
+
function add(result, code, message) {
|
|
6273
|
+
result.findings.push({
|
|
6274
|
+
code,
|
|
6275
|
+
message
|
|
6276
|
+
});
|
|
6277
|
+
}
|
|
6278
|
+
function readJson$1(path, result) {
|
|
6279
|
+
try {
|
|
6280
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
6281
|
+
} catch (error) {
|
|
6282
|
+
add(result, "json-malformed", `${relative(process.cwd(), path)} is not valid JSON`);
|
|
6283
|
+
return;
|
|
6284
|
+
}
|
|
6285
|
+
}
|
|
6286
|
+
function isObject(value) {
|
|
6287
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6288
|
+
}
|
|
6289
|
+
function safeRelativePath(value, field, root, result) {
|
|
6290
|
+
if (typeof value !== "string" || !value.startsWith("./") || value.includes("\\") || value.split("/").includes("..")) {
|
|
6291
|
+
add(result, "unsafe-path", `${field} must be a root-relative ./ path without parent segments`);
|
|
6292
|
+
return;
|
|
6293
|
+
}
|
|
6294
|
+
const target = resolve(root, value);
|
|
6295
|
+
if (!target.startsWith(`${root}${sep}`)) {
|
|
6296
|
+
add(result, "unsafe-path", `${field} resolves outside the plugin root`);
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6299
|
+
try {
|
|
6300
|
+
const stat = lstatSync(target);
|
|
6301
|
+
if (stat.isSymbolicLink() || !stat.isFile()) add(result, "invalid-resource", `${field} must name a regular file`);
|
|
6302
|
+
} catch {
|
|
6303
|
+
add(result, "missing-resource", `${field} does not exist`);
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
function scanTree(root, current, result) {
|
|
6307
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
6308
|
+
const path = join(current, entry.name);
|
|
6309
|
+
const display = relative(root, path);
|
|
6310
|
+
const stat = lstatSync(path);
|
|
6311
|
+
if (stat.isSymbolicLink()) add(result, "symlink", `${display} is a symbolic link; plugin packages must contain regular files`);
|
|
6312
|
+
else if (stat.isDirectory()) scanTree(root, path, result);
|
|
6313
|
+
else if (stat.isFile()) if (ENVIRONMENT_CREDENTIAL_FILE.test(entry.name) || PRIVATE_KEY_CARRIER.test(entry.name)) add(result, "secret-artifact", `${display} is a credential carrier and cannot be packaged`);
|
|
6314
|
+
else try {
|
|
6315
|
+
if (PRIVATE_KEY_HEADER.test(readFileSync(path, "utf8"))) add(result, "secret-artifact", `${display} contains a private key and cannot be packaged`);
|
|
6316
|
+
} catch {
|
|
6317
|
+
add(result, "artifact-unreadable", `${display} could not be inspected and cannot be packaged`);
|
|
6318
|
+
}
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
function scanSecrets(value, source, result) {
|
|
6322
|
+
if (Array.isArray(value)) {
|
|
6323
|
+
value.forEach((item) => {
|
|
6324
|
+
scanSecrets(item, source, result);
|
|
6325
|
+
});
|
|
6326
|
+
return;
|
|
6327
|
+
}
|
|
6328
|
+
if (!isObject(value)) return;
|
|
6329
|
+
for (const [key, child] of Object.entries(value)) {
|
|
6330
|
+
if (SECRET_KEY.test(key) && typeof child === "string" && child.trim()) add(result, "secret-artifact", `${source} contains a credential-shaped ${key} field`);
|
|
6331
|
+
scanSecrets(child, source, result);
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
function validateServerUrl(value, name, result) {
|
|
6335
|
+
if (typeof value !== "string") return add(result, "server-url", `mcpServers.${name}.url must be a string`);
|
|
6336
|
+
try {
|
|
6337
|
+
const url = new URL(value);
|
|
6338
|
+
const isLoopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
6339
|
+
if (url.username || url.password) add(result, "server-url", `mcpServers.${name}.url must not contain credentials`);
|
|
6340
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) add(result, "server-url", `mcpServers.${name}.url must use HTTPS, except loopback development URLs`);
|
|
6341
|
+
} catch {
|
|
6342
|
+
add(result, "server-url", `mcpServers.${name}.url is not a URL`);
|
|
6343
|
+
}
|
|
6344
|
+
}
|
|
6345
|
+
/** Validate portable plugin files and the optional OpenAI install extension. */
|
|
6346
|
+
function validateMcpAppsPackage(rootPath) {
|
|
6347
|
+
const root = resolve(rootPath);
|
|
6348
|
+
const result = {
|
|
6349
|
+
findings: [],
|
|
6350
|
+
valid: false
|
|
6351
|
+
};
|
|
6352
|
+
try {
|
|
6353
|
+
scanTree(root, root, result);
|
|
6354
|
+
} catch {
|
|
6355
|
+
add(result, "plugin-root", "Plugin root must be a readable directory");
|
|
6356
|
+
return result;
|
|
6357
|
+
}
|
|
6358
|
+
const pluginPath = join(root, "plugin.json");
|
|
6359
|
+
const mcpPath = join(root, "mcp.json");
|
|
6360
|
+
if (!lstatExists(pluginPath)) add(result, "plugin-missing", "plugin.json is required at the plugin root");
|
|
6361
|
+
if (!lstatExists(mcpPath)) add(result, "mcp-missing", "mcp.json is required at the plugin root");
|
|
6362
|
+
const plugin = lstatExists(pluginPath) ? readJson$1(pluginPath, result) : void 0;
|
|
6363
|
+
const mcp = lstatExists(mcpPath) ? readJson$1(mcpPath, result) : void 0;
|
|
6364
|
+
if (plugin !== void 0 && !isObject(plugin)) add(result, "plugin-object", "plugin.json must contain an object");
|
|
6365
|
+
if (mcp !== void 0 && !isObject(mcp)) add(result, "mcp-object", "mcp.json must contain an object");
|
|
6366
|
+
if (plugin !== void 0 && !validatePluginSchema(plugin)) add(result, "plugin-format", "plugin.json violates the pinned Agent Plugins 1.0.0 schema");
|
|
6367
|
+
if (mcp !== void 0 && !validateMcpSchema(mcp)) add(result, "mcp-format", "mcp.json violates the pinned Agent Plugins 1.0.0 schema");
|
|
6368
|
+
if (isObject(plugin)) {
|
|
6369
|
+
scanSecrets(plugin, "plugin.json", result);
|
|
6370
|
+
if (plugin.$schema !== PLUGIN_SCHEMA) add(result, "plugin-schema", `plugin.json.$schema must be ${PLUGIN_SCHEMA}`);
|
|
6371
|
+
if (plugin.extensions !== void 0 && !isObject(plugin.extensions)) add(result, "extensions", "plugin.json.extensions must be an object");
|
|
6372
|
+
const openai = (isObject(plugin.extensions) ? plugin.extensions : void 0)?.["com.openai"];
|
|
6373
|
+
if (openai !== void 0 && !isObject(openai)) add(result, "openai-extension", "extensions.com.openai must be an object");
|
|
6374
|
+
if (isObject(openai)) {
|
|
6375
|
+
if (openai.apps !== void 0) safeRelativePath(openai.apps, "extensions.com.openai.apps", root, result);
|
|
6376
|
+
const ui = openai.interface;
|
|
6377
|
+
if (ui !== void 0 && !isObject(ui)) add(result, "openai-interface", "extensions.com.openai.interface must be an object");
|
|
6378
|
+
if (isObject(ui)) {
|
|
6379
|
+
for (const key of ["composerIcon", "logo"]) if (ui[key] !== void 0) safeRelativePath(ui[key], `extensions.com.openai.interface.${key}`, root, result);
|
|
6380
|
+
if (ui.screenshots !== void 0) if (!Array.isArray(ui.screenshots) || ui.screenshots.some((path) => typeof path !== "string")) add(result, "openai-screenshots", "interface.screenshots must be an array of paths");
|
|
6381
|
+
else ui.screenshots.forEach((path, index) => {
|
|
6382
|
+
safeRelativePath(path, `extensions.com.openai.interface.screenshots[${index}]`, root, result);
|
|
6383
|
+
});
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6386
|
+
}
|
|
6387
|
+
if (isObject(mcp)) {
|
|
6388
|
+
scanSecrets(mcp, "mcp.json", result);
|
|
6389
|
+
if (mcp.$schema !== MCP_SCHEMA) add(result, "mcp-schema", `mcp.json.$schema must be ${MCP_SCHEMA}`);
|
|
6390
|
+
if (!isObject(mcp.mcpServers) || Object.keys(mcp.mcpServers).length === 0) add(result, "mcp-servers", "mcp.json requires a non-empty mcpServers object");
|
|
6391
|
+
else for (const [name, server] of Object.entries(mcp.mcpServers)) {
|
|
6392
|
+
if (!name.trim() || !isObject(server)) {
|
|
6393
|
+
add(result, "mcp-server", `mcpServers.${name || "<empty>"} must be an object with a name`);
|
|
6394
|
+
continue;
|
|
6395
|
+
}
|
|
6396
|
+
if (server.type !== "streamable-http") add(result, "mcp-transport", `mcpServers.${name}.type must be streamable-http`);
|
|
6397
|
+
validateServerUrl(server.url, name, result);
|
|
6398
|
+
}
|
|
6399
|
+
}
|
|
6400
|
+
result.valid = result.findings.length === 0;
|
|
6401
|
+
return result;
|
|
6402
|
+
}
|
|
6403
|
+
function lstatExists(path) {
|
|
6404
|
+
try {
|
|
6405
|
+
return lstatSync(path).isFile();
|
|
6406
|
+
} catch {
|
|
6407
|
+
return false;
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
/**
|
|
6411
|
+
* Add the public, credential-free SMRT package route to a generated app.
|
|
6412
|
+
* A pre-existing scope route is an explicit consumer decision and must not be
|
|
6413
|
+
* silently replaced.
|
|
6414
|
+
*/
|
|
6415
|
+
function configureMcpAppsConsumerRegistry(appRoot) {
|
|
6416
|
+
const npmrcPath = join(resolve(appRoot), ".npmrc");
|
|
6417
|
+
let existing = "";
|
|
6418
|
+
try {
|
|
6419
|
+
const stat = lstatSync(npmrcPath);
|
|
6420
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Generated application .npmrc must be a regular file");
|
|
6421
|
+
existing = readFileSync(npmrcPath, "utf8");
|
|
6422
|
+
} catch (error) {
|
|
6423
|
+
if (error.code !== "ENOENT") throw error;
|
|
6424
|
+
}
|
|
6425
|
+
const scopeLines = existing.split(/\r?\n/).filter((line) => /^\s*@happyvertical:registry(?:\s|=|$)/.test(line));
|
|
6426
|
+
if (scopeLines.length > 0) {
|
|
6427
|
+
for (const line of scopeLines) {
|
|
6428
|
+
const match = /^\s*@happyvertical:registry\s*=\s*(\S+?)(?:\s+[;#].*)?\s*$/.exec(line);
|
|
6429
|
+
if (!match || match[1] !== "https://npm.happyvertical.com/") throw new Error("Generated application already declares a different or malformed @happyvertical registry; refusing to replace it");
|
|
6430
|
+
}
|
|
6431
|
+
return;
|
|
6432
|
+
}
|
|
6433
|
+
writeFileSync(npmrcPath, `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${CANONICAL_SCOPE_REGISTRY}\n`);
|
|
6434
|
+
}
|
|
6435
|
+
/** Copy the opt-in app route/resources and merge only its declared dependencies. */
|
|
6436
|
+
function addMcpAppsRuntime(sourceRoot, appRoot) {
|
|
6437
|
+
const resolvedSource = resolve(sourceRoot);
|
|
6438
|
+
const source = join(lstatSync(resolvedSource).isFile() ? dirname(resolvedSource) : resolvedSource, "mcp-apps-template");
|
|
6439
|
+
const dependencies = JSON.parse(readFileSync(join(source, "package.dependencies.json"), "utf8"));
|
|
6440
|
+
cpSync(join(source, "src"), join(resolve(appRoot), "src"), {
|
|
6441
|
+
recursive: true,
|
|
6442
|
+
force: false,
|
|
6443
|
+
errorOnExist: true
|
|
6444
|
+
});
|
|
6445
|
+
const packagePath = join(resolve(appRoot), "package.json");
|
|
6446
|
+
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
6447
|
+
packageJson.dependencies = {
|
|
6448
|
+
...packageJson.dependencies,
|
|
6449
|
+
...dependencies
|
|
6450
|
+
};
|
|
6451
|
+
writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
6452
|
+
}
|
|
6453
|
+
function assertMcpAppsPluginName(name) {
|
|
6454
|
+
if (!validatePluginSchema({
|
|
6455
|
+
$schema: PLUGIN_SCHEMA,
|
|
6456
|
+
name
|
|
6457
|
+
})) throw new Error("Plugin name must satisfy Agent Plugins 1.0.0 (1–64 lowercase letters, digits, dots or hyphens; no repeated separators).");
|
|
6458
|
+
}
|
|
6459
|
+
/** Create the minimal local portable package. It intentionally has no .app.json: registered IDs are host-owned. */
|
|
6460
|
+
function scaffoldMcpAppsPackage(rootPath, name, version = "0.1.0") {
|
|
6461
|
+
assertMcpAppsPluginName(name);
|
|
6462
|
+
const root = resolve(rootPath);
|
|
6463
|
+
mkdirSync(root, { recursive: true });
|
|
6464
|
+
const pluginPath = join(root, "plugin.json");
|
|
6465
|
+
const mcpPath = join(root, "mcp.json");
|
|
6466
|
+
if (lstatExists(pluginPath) || lstatExists(mcpPath)) throw new Error("Refusing to overwrite existing MCP Apps package files");
|
|
6467
|
+
writeFileSync(pluginPath, `${JSON.stringify({
|
|
6468
|
+
$schema: PLUGIN_SCHEMA,
|
|
6469
|
+
name,
|
|
6470
|
+
version,
|
|
6471
|
+
description: "SMRT MCP Apps package",
|
|
6472
|
+
extensions: { "com.openai": { interface: {
|
|
6473
|
+
displayName: name,
|
|
6474
|
+
shortDescription: "SMRT MCP Apps package"
|
|
6475
|
+
} } }
|
|
6476
|
+
}, null, 2)}\n`);
|
|
6477
|
+
writeFileSync(mcpPath, `${JSON.stringify({
|
|
6478
|
+
$schema: MCP_SCHEMA,
|
|
6479
|
+
mcpServers: { smrt: {
|
|
6480
|
+
type: "streamable-http",
|
|
6481
|
+
url: "http://127.0.0.1:3000/api/mcp"
|
|
6482
|
+
} }
|
|
6483
|
+
}, null, 2)}\n`);
|
|
6484
|
+
}
|
|
6485
|
+
var mcpAppsCommands = {
|
|
6486
|
+
"mcp-apps scaffold": {
|
|
6487
|
+
name: "mcp-apps scaffold",
|
|
6488
|
+
description: "Create opt-in portable MCP Apps plugin metadata for a local SMRT app",
|
|
6489
|
+
options: {
|
|
6490
|
+
"output-dir": {
|
|
6491
|
+
type: "string",
|
|
6492
|
+
description: "Plugin directory (defaults to ./mcp-apps)"
|
|
6493
|
+
},
|
|
6494
|
+
name: {
|
|
6495
|
+
type: "string",
|
|
6496
|
+
description: "Portable plugin name",
|
|
6497
|
+
default: "smrt-app"
|
|
6498
|
+
},
|
|
6499
|
+
version: {
|
|
6500
|
+
type: "string",
|
|
6501
|
+
description: "Portable plugin version",
|
|
6502
|
+
default: "0.1.0"
|
|
6503
|
+
}
|
|
6504
|
+
},
|
|
6505
|
+
handler: async (_args, options) => {
|
|
6506
|
+
const root = typeof options["output-dir"] === "string" ? options["output-dir"] : "./mcp-apps";
|
|
6507
|
+
scaffoldMcpAppsPackage(root, typeof options.name === "string" ? options.name : "smrt-app", typeof options.version === "string" ? options.version : "0.1.0");
|
|
6508
|
+
console.log(`Created portable MCP Apps metadata in ${root}.`);
|
|
6509
|
+
console.log("Mount and authorize the native v2 /api/mcp endpoint before changing the loopback URL.");
|
|
6510
|
+
}
|
|
6511
|
+
},
|
|
6512
|
+
"mcp-apps validate": {
|
|
6513
|
+
name: "mcp-apps validate",
|
|
6514
|
+
description: "Fail closed when portable MCP Apps package metadata is unsafe or incomplete",
|
|
6515
|
+
options: { "output-dir": {
|
|
6516
|
+
type: "string",
|
|
6517
|
+
description: "Plugin directory (defaults to ./mcp-apps)"
|
|
6518
|
+
} },
|
|
6519
|
+
handler: async (_args, options) => {
|
|
6520
|
+
const root = typeof options["output-dir"] === "string" ? options["output-dir"] : "./mcp-apps";
|
|
6521
|
+
const result = validateMcpAppsPackage(root);
|
|
6522
|
+
for (const finding of result.findings) console.error(`${finding.code}: ${finding.message}`);
|
|
6523
|
+
if (!result.valid) throw new Error(`MCP Apps package validation failed with ${result.findings.length} finding(s)`);
|
|
6524
|
+
console.log(`MCP Apps package at ${root} is valid.`);
|
|
6525
|
+
}
|
|
6526
|
+
}
|
|
6527
|
+
};
|
|
6528
|
+
//#endregion
|
|
6105
6529
|
//#region src/commands/gnode.ts
|
|
6106
6530
|
/**
|
|
6531
|
+
* Gnode CLI Commands
|
|
6532
|
+
*
|
|
6533
|
+
* Commands for creating and managing gnodes (federated local knowledge bases)
|
|
6534
|
+
*/
|
|
6535
|
+
var SCOPED_PACKAGE_NAME = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
|
|
6536
|
+
function projectIdentity(name, outputDir) {
|
|
6537
|
+
const packagePath = join(outputDir, "package.json");
|
|
6538
|
+
if (existsSync(packagePath)) {
|
|
6539
|
+
const existing = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
6540
|
+
if (typeof existing.name !== "string" || !SCOPED_PACKAGE_NAME.test(existing.name)) throw new Error("Generated applications require a scoped package identity (for example @smrt-app/my-app); rename the existing project before generating into it");
|
|
6541
|
+
return existing.name;
|
|
6542
|
+
}
|
|
6543
|
+
if (SCOPED_PACKAGE_NAME.test(name)) return name;
|
|
6544
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
6545
|
+
if (!slug) throw new Error("MCP Apps project name must contain a package slug");
|
|
6546
|
+
return `@smrt-app/${slug}`;
|
|
6547
|
+
}
|
|
6548
|
+
function mcpAppsPluginName(packageName) {
|
|
6549
|
+
return packageName.split("/").at(-1) ?? packageName;
|
|
6550
|
+
}
|
|
6551
|
+
/**
|
|
6107
6552
|
* Gnode commands for CLI
|
|
6108
6553
|
*/
|
|
6109
6554
|
var gnodeCommands = {
|
|
@@ -6137,6 +6582,10 @@ var gnodeCommands = {
|
|
|
6137
6582
|
type: "string",
|
|
6138
6583
|
description: "IANA timezone (e.g., \"America/Edmonton\")",
|
|
6139
6584
|
default: "America/Edmonton"
|
|
6585
|
+
},
|
|
6586
|
+
"mcp-apps": {
|
|
6587
|
+
type: "boolean",
|
|
6588
|
+
description: "Add opt-in portable MCP Apps plugin metadata under mcp-apps/"
|
|
6140
6589
|
}
|
|
6141
6590
|
},
|
|
6142
6591
|
handler: async (args, options) => {
|
|
@@ -6144,26 +6593,38 @@ var gnodeCommands = {
|
|
|
6144
6593
|
if (!name) throw new Error("Project name is required: smrt gnode create <name>");
|
|
6145
6594
|
const outputDir = options.outputDir || `./${name}`;
|
|
6146
6595
|
const templateName = options.template || "sveltekit";
|
|
6596
|
+
const mcpApps = options.mcpApps || options["mcp-apps"];
|
|
6597
|
+
const projectName = projectIdentity(name, outputDir);
|
|
6598
|
+
if (mcpApps) assertMcpAppsPluginName(mcpAppsPluginName(projectName));
|
|
6147
6599
|
const siteOptions = options.location || options.lat || options.lon ? {
|
|
6148
6600
|
location: options.location,
|
|
6149
6601
|
latitude: options.lat ? Number(options.lat) : void 0,
|
|
6150
6602
|
longitude: options.lon ? Number(options.lon) : void 0,
|
|
6151
6603
|
timezone: options.timezone
|
|
6152
6604
|
} : void 0;
|
|
6605
|
+
let gitConfig;
|
|
6153
6606
|
try {
|
|
6154
6607
|
console.log(`🔍 Resolving template: ${templateName}...`);
|
|
6155
6608
|
const source = await resolveTemplate(templateName);
|
|
6156
6609
|
console.log(`✓ Found template: ${source.type}:${source.location}\n`);
|
|
6157
6610
|
const config = await loadTemplate(source);
|
|
6611
|
+
if (source.type === "git") gitConfig = config;
|
|
6158
6612
|
await generate(source, config, {
|
|
6159
|
-
name,
|
|
6613
|
+
name: projectName,
|
|
6160
6614
|
template: templateName,
|
|
6161
6615
|
outputDir,
|
|
6162
6616
|
site: siteOptions
|
|
6163
6617
|
});
|
|
6164
|
-
if (
|
|
6618
|
+
if (mcpApps || config.name === "sveltekit") configureMcpAppsConsumerRegistry(outputDir);
|
|
6619
|
+
if (mcpApps) {
|
|
6620
|
+
addMcpAppsRuntime(source.type === "git" ? getGitTemplateDir(config) : source.resolved, outputDir);
|
|
6621
|
+
scaffoldMcpAppsPackage(`${outputDir}/mcp-apps`, mcpAppsPluginName(projectName));
|
|
6622
|
+
console.log("✓ Added MCP Apps metadata and the canonical @happyvertical package registry. Configure the authorized v2 MCP endpoint before deployment.");
|
|
6623
|
+
}
|
|
6165
6624
|
} catch (error) {
|
|
6166
6625
|
throw new Error(`Failed to create gnode: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error });
|
|
6626
|
+
} finally {
|
|
6627
|
+
if (gitConfig) await cleanupGitTemplate(gitConfig);
|
|
6167
6628
|
}
|
|
6168
6629
|
}
|
|
6169
6630
|
},
|
|
@@ -6948,7 +7409,7 @@ var dbMaterializeTenantHierarchyCommand = {
|
|
|
6948
7409
|
if (!config.database?.url || config.database.url === ":memory:") throw new Error("Database configuration required for db:materialize-tenant-hierarchy. Configure database.url in smrt.config.ts (or DATABASE_URL).");
|
|
6949
7410
|
const dbType = config.database.type || "sqlite";
|
|
6950
7411
|
const { getDatabase } = await import("@happyvertical/sql");
|
|
6951
|
-
const { materializeTenantHierarchy, TenantHierarchyMaterializationError } = await import("./dist-
|
|
7412
|
+
const { materializeTenantHierarchy, TenantHierarchyMaterializationError } = await import("./dist-DtTkpUee.js");
|
|
6952
7413
|
db = await getDatabase({
|
|
6953
7414
|
type: dbType,
|
|
6954
7415
|
url: config.database.url
|
|
@@ -7157,7 +7618,7 @@ var dbMigrateLedgerAccountsCommand = {
|
|
|
7157
7618
|
if (dbType !== "postgres") throw new Error("db:migrate-ledger-accounts currently supports PostgreSQL only; no changes were made. See the smrt-ledgers README for SQLite.");
|
|
7158
7619
|
await autoDiscoverAndLoad();
|
|
7159
7620
|
const { getDatabase } = await import("@happyvertical/sql");
|
|
7160
|
-
const { migrateLedgerAccountsTable, planLedgerAccountsTableMove } = await import("./dist-
|
|
7621
|
+
const { migrateLedgerAccountsTable, planLedgerAccountsTableMove } = await import("./dist-CLA60jwS.js");
|
|
7161
7622
|
db = await getDatabase({
|
|
7162
7623
|
type: dbType,
|
|
7163
7624
|
url: config.database.url
|
|
@@ -8225,6 +8686,139 @@ async function columnIsUuid(db, table, column) {
|
|
|
8225
8686
|
return rows[0]?.data_type === "uuid";
|
|
8226
8687
|
}
|
|
8227
8688
|
//#endregion
|
|
8689
|
+
//#region src/commands/db-qualified-names.ts
|
|
8690
|
+
/**
|
|
8691
|
+
* Stored references to deprecated qualified names (#3338).
|
|
8692
|
+
*
|
|
8693
|
+
* `smrt doctor --db` reports how many rows still store a name a class
|
|
8694
|
+
* declared in `@smrt({ previousQualifiedNames })` — the signal that an alias
|
|
8695
|
+
* can (or cannot yet) be removed. `smrt db:migrate-qualified-names` is the
|
|
8696
|
+
* opt-in, idempotent cleanup; nothing rewrites stored names automatically.
|
|
8697
|
+
*/
|
|
8698
|
+
async function connect() {
|
|
8699
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
8700
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-hqNo4lzH.js").then((n) => n.n);
|
|
8701
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
8702
|
+
if (!config.database?.url || config.database.url === ":memory:") throw new Error("No persistent database is configured. Set `database.url` in smrt.config.ts (or DATABASE_URL).");
|
|
8703
|
+
const dbType = config.database.type || "sqlite";
|
|
8704
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
8705
|
+
return {
|
|
8706
|
+
db: await getDatabase({
|
|
8707
|
+
type: dbType,
|
|
8708
|
+
url: config.database.url
|
|
8709
|
+
}),
|
|
8710
|
+
dbType,
|
|
8711
|
+
url: config.database.url
|
|
8712
|
+
};
|
|
8713
|
+
}
|
|
8714
|
+
/**
|
|
8715
|
+
* Count stored deprecated qualified names in the configured database.
|
|
8716
|
+
* Never throws for an operational problem.
|
|
8717
|
+
*/
|
|
8718
|
+
async function runLegacyQualifiedNameReport(options = {}) {
|
|
8719
|
+
let db;
|
|
8720
|
+
try {
|
|
8721
|
+
if (options.discover ?? true) await autoDiscoverAndLoad();
|
|
8722
|
+
const connection = await connect();
|
|
8723
|
+
db = connection.db;
|
|
8724
|
+
return {
|
|
8725
|
+
report: await countLegacyQualifiedNameReferences(db, { engineHint: connection.dbType }),
|
|
8726
|
+
error: null
|
|
8727
|
+
};
|
|
8728
|
+
} catch (error) {
|
|
8729
|
+
return {
|
|
8730
|
+
report: null,
|
|
8731
|
+
error: redactConnectionStringsInText(error instanceof Error ? error.message : String(error))
|
|
8732
|
+
};
|
|
8733
|
+
} finally {
|
|
8734
|
+
await closeDatabaseConnection(db);
|
|
8735
|
+
}
|
|
8736
|
+
}
|
|
8737
|
+
/**
|
|
8738
|
+
* Render a legacy-name report as the lines `smrt doctor --db` and
|
|
8739
|
+
* `smrt db:migrate-qualified-names` print.
|
|
8740
|
+
*
|
|
8741
|
+
* A zero count is a removal signal only when it is global and complete: a
|
|
8742
|
+
* tenant-scoped count (`tenantId`) or one that skipped untenanted tables
|
|
8743
|
+
* never says the aliases are removable. Stored rows are also only half the
|
|
8744
|
+
* picture, so every report reminds the operator that source still naming an
|
|
8745
|
+
* old name must move first.
|
|
8746
|
+
*/
|
|
8747
|
+
function formatLegacyQualifiedNameReport(report, options = {}) {
|
|
8748
|
+
if (report.aliases.length === 0) return [" No class declares previousQualifiedNames."];
|
|
8749
|
+
const scope = options.tenantId !== void 0 ? ` for tenant ${options.tenantId}` : "";
|
|
8750
|
+
const lines = [` Declared aliases: ${report.aliases.length}`, ...report.aliases.map(({ alias, current }) => ` - ${alias} → ${current}`)];
|
|
8751
|
+
if (report.total > 0) {
|
|
8752
|
+
lines.push(` ⚠️ ${report.total} stored reference(s)${scope} still use a deprecated name:`);
|
|
8753
|
+
for (const reference of report.references) lines.push(` - ${reference.table}.${reference.column} = ${reference.alias}: ${reference.count} row(s)`);
|
|
8754
|
+
lines.push(" They keep resolving. Rewrite them early with `smrt db:migrate-qualified-names` (opt-in); keep the aliases until this count is zero.");
|
|
8755
|
+
} else if (options.tenantId !== void 0) lines.push(` ✅ No counted row${scope} uses a deprecated name. This is a tenant-scoped count, not a removal signal: other tenants and untenanted tables were not counted. Run without --tenant (or \`smrt doctor --db\`) before deciding to remove an alias.`);
|
|
8756
|
+
else if (report.untenantedTables.length > 0) lines.push(" ✅ No counted row uses a deprecated name, but some tables were skipped (below), so this is not a removal signal.");
|
|
8757
|
+
else lines.push(" ✅ No stored row in this database uses a deprecated name. The aliases may be removed in a breaking release once every deployment reports zero and no source still names them.");
|
|
8758
|
+
if (report.untenantedTables.length > 0) lines.push(` Skipped (no tenant column, not counted): ${report.untenantedTables.join(", ")}`);
|
|
8759
|
+
if (report.missingTables.length > 0) lines.push(` (not migrated in this database: ${report.missingTables.join(", ")})`);
|
|
8760
|
+
lines.push(" Before removing an alias, also update source still naming an old qualified name (@crossPackageRef / relationship targets, playbook step models) to the current name.");
|
|
8761
|
+
return lines;
|
|
8762
|
+
}
|
|
8763
|
+
/** `smrt db:migrate-qualified-names` — the opt-in rewrite. */
|
|
8764
|
+
var dbMigrateQualifiedNamesCommand = {
|
|
8765
|
+
name: "db:migrate-qualified-names",
|
|
8766
|
+
description: "Rewrite stored deprecated qualified names (declared via @smrt({ previousQualifiedNames })) to current names. Opt-in and idempotent (#3338).",
|
|
8767
|
+
aliases: ["migrate-qualified-names"],
|
|
8768
|
+
args: [],
|
|
8769
|
+
options: {
|
|
8770
|
+
"dry-run": {
|
|
8771
|
+
type: "boolean",
|
|
8772
|
+
description: "Report the rows that would be rewritten without writing.",
|
|
8773
|
+
default: false
|
|
8774
|
+
},
|
|
8775
|
+
tenant: {
|
|
8776
|
+
type: "string",
|
|
8777
|
+
description: "Rewrite only this tenant's rows (tables without a tenant column are skipped)."
|
|
8778
|
+
},
|
|
8779
|
+
force: {
|
|
8780
|
+
type: "boolean",
|
|
8781
|
+
description: "Run again even if this alias set was already recorded.",
|
|
8782
|
+
default: false
|
|
8783
|
+
}
|
|
8784
|
+
},
|
|
8785
|
+
handler: async (_args, options) => {
|
|
8786
|
+
let db;
|
|
8787
|
+
try {
|
|
8788
|
+
await autoDiscoverAndLoad();
|
|
8789
|
+
const connection = await connect();
|
|
8790
|
+
db = connection.db;
|
|
8791
|
+
console.log("\n🏷️ Deprecated qualified-name backfill\n");
|
|
8792
|
+
console.log(`✓ Connected to ${formatDatabaseDisplayUrl(connection.dbType, connection.url)}\n`);
|
|
8793
|
+
const result = await backfillLegacyQualifiedNames(db, {
|
|
8794
|
+
engineHint: connection.dbType,
|
|
8795
|
+
dryRun: options["dry-run"] === true,
|
|
8796
|
+
force: options.force === true,
|
|
8797
|
+
...options.tenant ? { tenantId: options.tenant } : {}
|
|
8798
|
+
});
|
|
8799
|
+
for (const line of formatLegacyQualifiedNameReport(result.before, { ...options.tenant ? { tenantId: options.tenant } : {} })) console.log(line);
|
|
8800
|
+
console.log();
|
|
8801
|
+
if (result.dryRun) {
|
|
8802
|
+
console.log("DRY RUN: no changes made.\n");
|
|
8803
|
+
return;
|
|
8804
|
+
}
|
|
8805
|
+
if (!result.ran) {
|
|
8806
|
+
console.log(result.before.aliases.length === 0 ? "Nothing to do.\n" : `Already applied (${result.backfillName}); pass --force to run again.\n`);
|
|
8807
|
+
return;
|
|
8808
|
+
}
|
|
8809
|
+
for (const reference of result.rewritten) console.log(`✓ ${reference.table}.${reference.column}: ${reference.count} row(s) ${reference.alias} → ${reference.current}`);
|
|
8810
|
+
for (const reference of result.skippedDuplicates) console.log(`⚠️ ${reference.table}.${reference.column}: ${reference.count} row(s) kept as ${reference.alias} — a row with the same identity already uses ${reference.current}. Resolve the duplicates, then re-run.`);
|
|
8811
|
+
if (result.skippedDuplicates.length > 0) process.exitCode = 1;
|
|
8812
|
+
console.log(result.recorded ? `\n✓ Recorded ${result.backfillName}.\n` : "\nNot recorded: references remain.\n");
|
|
8813
|
+
} catch (error) {
|
|
8814
|
+
console.error(`\n❌ Qualified-name backfill failed: ${redactConnectionStringsInText(error instanceof Error ? error.message : String(error))}\n`);
|
|
8815
|
+
process.exitCode = 1;
|
|
8816
|
+
} finally {
|
|
8817
|
+
await closeDatabaseConnection(db);
|
|
8818
|
+
}
|
|
8819
|
+
}
|
|
8820
|
+
};
|
|
8821
|
+
//#endregion
|
|
8228
8822
|
//#region src/commands/dev-knowledge.ts
|
|
8229
8823
|
/**
|
|
8230
8824
|
* dev:knowledge-* commands
|
|
@@ -9418,6 +10012,7 @@ function renderAgentSurfaceReport(report) {
|
|
|
9418
10012
|
* Utility commands for CLI
|
|
9419
10013
|
*/
|
|
9420
10014
|
var utilityCommands = {
|
|
10015
|
+
...mcpAppsCommands,
|
|
9421
10016
|
introspect: {
|
|
9422
10017
|
name: "introspect",
|
|
9423
10018
|
description: "Analyze project and discover SMRT objects",
|
|
@@ -9876,7 +10471,7 @@ export default testManifest;
|
|
|
9876
10471
|
}
|
|
9877
10472
|
}
|
|
9878
10473
|
try {
|
|
9879
|
-
const { resolveDataPath, discoverJsonFiles, JsonDatabaseValidator, displayValidationResults } = await import("./json-validator-
|
|
10474
|
+
const { resolveDataPath, discoverJsonFiles, JsonDatabaseValidator, displayValidationResults } = await import("./json-validator-BiAdsE1O.js");
|
|
9880
10475
|
const dataPath = await resolveDataPath(options.data);
|
|
9881
10476
|
if (!dataPath) {
|
|
9882
10477
|
console.error("\n❌ Could not find data directory");
|
|
@@ -9998,6 +10593,11 @@ export default testManifest;
|
|
|
9998
10593
|
type: "string",
|
|
9999
10594
|
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before converting them to timestamptz. Exact value required: UTC; omitted by default."
|
|
10000
10595
|
},
|
|
10596
|
+
"empty-text-as-null": {
|
|
10597
|
+
type: "boolean",
|
|
10598
|
+
description: "Store empty or whitespace-only text as NULL when converging a nullable legacy text column to a typed column (timestamp, JSON, integer). Any other value that does not convert still blocks, and NOT NULL columns are never changed. Off by default.",
|
|
10599
|
+
default: false
|
|
10600
|
+
},
|
|
10001
10601
|
force: {
|
|
10002
10602
|
type: "boolean",
|
|
10003
10603
|
description: "Force re-apply even if already applied (skip checksum validation)",
|
|
@@ -10023,6 +10623,11 @@ export default testManifest;
|
|
|
10023
10623
|
description: "Drop orphan indexes (in DB but not in manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default.",
|
|
10024
10624
|
default: false
|
|
10025
10625
|
},
|
|
10626
|
+
"drop-legacy-natural-key": {
|
|
10627
|
+
type: "boolean",
|
|
10628
|
+
description: "Drop the legacy global (slug, context) unique index a tenant-led natural key superseded on a table that is tenant-owned by its tenantId field. Off by default: db:migrate builds the tenant-led index beside it so code still on the previous release keeps upserting. Pass this once every instance runs the release that upserts on the tenant-led key.",
|
|
10629
|
+
default: false
|
|
10630
|
+
},
|
|
10026
10631
|
"drop-columns": {
|
|
10027
10632
|
type: "boolean",
|
|
10028
10633
|
description: "DESTRUCTIVE: drop orphan columns (in DB but not in manifest). Off by default; orphans are always reported.",
|
|
@@ -10171,8 +10776,10 @@ export default testManifest;
|
|
|
10171
10776
|
const diff = await new SchemaComparer(db, {
|
|
10172
10777
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
10173
10778
|
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
10779
|
+
dropLegacyNaturalKey: Boolean(options["drop-legacy-natural-key"]),
|
|
10174
10780
|
relaxColumns: Boolean(options["relax-columns"]),
|
|
10175
|
-
postgresTimestampMigration
|
|
10781
|
+
postgresTimestampMigration,
|
|
10782
|
+
emptyTextAsNull: Boolean(options["empty-text-as-null"])
|
|
10176
10783
|
}).compare(manifestSchemas);
|
|
10177
10784
|
const engine = tracker.getEngine();
|
|
10178
10785
|
const tablePlan = planForeignKeyCreation(diff.added_tables, engine);
|
|
@@ -10255,6 +10862,12 @@ export default testManifest;
|
|
|
10255
10862
|
pendingOrphanDispositions.length = 0;
|
|
10256
10863
|
pendingOrphanDispositions.push(...stillPending);
|
|
10257
10864
|
}
|
|
10865
|
+
const destructiveForeignKeys = migrations.filter((migration) => migration.type === "add_foreign_key" && migration.advisory?.severity === "warning" && Boolean(migration.sql || migration.sqlStatements?.length));
|
|
10866
|
+
if (destructiveForeignKeys.length > 0) {
|
|
10867
|
+
console.log(`\n⚠️ ${destructiveForeignKeys.length} foreign-key change(s) make deletes DESTRUCTIVE:`);
|
|
10868
|
+
for (const migration of destructiveForeignKeys) console.log(` ${migration.advisory?.message}`);
|
|
10869
|
+
console.log();
|
|
10870
|
+
}
|
|
10258
10871
|
const preForeignKeyMigrations = migrations.filter((migration) => migration.phase === "pre_foreign_key");
|
|
10259
10872
|
const remainingMigrations = migrations.filter((migration) => migration.phase !== "pre_foreign_key");
|
|
10260
10873
|
assertForceMigrationTargetsExist(forceSelection.forceMigrations, [
|
|
@@ -10407,7 +11020,7 @@ export default testManifest;
|
|
|
10407
11020
|
actionDesc = `Dropped column ${migration.tableName}.${migration.columnName}`;
|
|
10408
11021
|
} else if (migration.type === "type_upgrade" && migration.column) {
|
|
10409
11022
|
migrationSql = migration.sql || "";
|
|
10410
|
-
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}`;
|
|
11023
|
+
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}${migration.note ? ` (${migration.note})` : ""}`;
|
|
10411
11024
|
} else if (migration.type === "add_index" && migration.index) {
|
|
10412
11025
|
migrationSql = migration.sql || "";
|
|
10413
11026
|
actionDesc = `Created index ${migration.index.name} on ${migration.tableName}`;
|
|
@@ -10416,7 +11029,7 @@ export default testManifest;
|
|
|
10416
11029
|
actionDesc = `Dropped index ${migration.indexName} on ${migration.tableName}`;
|
|
10417
11030
|
} else if (migration.type === "add_foreign_key") {
|
|
10418
11031
|
migrationSql = migration.sql || "";
|
|
10419
|
-
actionDesc = `Added foreign-key constraint on ${migration.tableName}`;
|
|
11032
|
+
actionDesc = migration.note ? `Replaced foreign-key constraint on ${migration.tableName} (${migration.note})` : `Added foreign-key constraint on ${migration.tableName}`;
|
|
10420
11033
|
} else if (migration.type === "drop_foreign_key") {
|
|
10421
11034
|
migrationSql = migration.sql || "";
|
|
10422
11035
|
actionDesc = `Dropped foreign-key constraint on ${migration.tableName}`;
|
|
@@ -10814,6 +11427,11 @@ export default testManifest;
|
|
|
10814
11427
|
check("SMRT objects discovered", false, void 0, "Could not discover manifests");
|
|
10815
11428
|
}
|
|
10816
11429
|
const runtimeCheck = await runRuntimeCheckSafely(cwd);
|
|
11430
|
+
const mcpAppsPath = resolve(cwd, "mcp-apps");
|
|
11431
|
+
if (existsSync(mcpAppsPath)) {
|
|
11432
|
+
const mcpApps = validateMcpAppsPackage(mcpAppsPath);
|
|
11433
|
+
check("Portable MCP Apps package", mcpApps.valid, mcpApps.findings.map((finding) => `${finding.code}: ${finding.message}`).join("; "));
|
|
11434
|
+
}
|
|
10817
11435
|
const projectManifestPath = resolve(cwd, ".smrt", "manifest.json");
|
|
10818
11436
|
const registerPath = resolve(cwd, ".smrt", "register.js");
|
|
10819
11437
|
if (existsSync(projectManifestPath)) try {
|
|
@@ -10879,6 +11497,15 @@ export default testManifest;
|
|
|
10879
11497
|
for (const line of formatPermissionsOutcome(permissions)) console.log(line);
|
|
10880
11498
|
if (!permissions.skipped) check("PostgreSQL role permissions", !permissions.error && permissions.plan?.diagnostics.length === 0, permissions.error ?? "PostgreSQL role permissions differ from the configured contract");
|
|
10881
11499
|
console.log();
|
|
11500
|
+
console.log("🏷️ Deprecated Qualified Names\n");
|
|
11501
|
+
const legacyNames = await runLegacyQualifiedNameReport({ discover: false });
|
|
11502
|
+
if (!legacyNames.report) check("Deprecated qualified-name references", false, legacyNames.error ?? "Could not count stored references");
|
|
11503
|
+
else {
|
|
11504
|
+
for (const line of formatLegacyQualifiedNameReport(legacyNames.report)) console.log(line);
|
|
11505
|
+
if (legacyNames.report.total > 0) warnings.push(`deprecated qualified names: ${legacyNames.report.total} stored reference(s)`);
|
|
11506
|
+
else passed.push("Deprecated qualified-name references");
|
|
11507
|
+
}
|
|
11508
|
+
console.log();
|
|
10882
11509
|
}
|
|
10883
11510
|
console.log("━".repeat(50));
|
|
10884
11511
|
console.log(`\n📊 Summary\n`);
|
|
@@ -10916,6 +11543,7 @@ export default testManifest;
|
|
|
10916
11543
|
"db:migrate-null-equal-indexes": dbMigrateNullEqualIndexesCommand,
|
|
10917
11544
|
"db:migrate-agent-schedule-slugs": dbMigrateAgentScheduleSlugsCommand,
|
|
10918
11545
|
"db:migrate-ledger-accounts": dbMigrateLedgerAccountsCommand,
|
|
11546
|
+
"db:migrate-qualified-names": dbMigrateQualifiedNamesCommand,
|
|
10919
11547
|
"db:materialize-tenant-hierarchy": dbMaterializeTenantHierarchyCommand,
|
|
10920
11548
|
"db:drop-framework-base-tables": dbDropFrameworkBaseTablesCommand,
|
|
10921
11549
|
"db:orphans": dbOrphansCommand,
|