@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.
Files changed (56) hide show
  1. package/AGENTS.md +7 -2
  2. package/README.md +22 -0
  3. package/agents/type-drift.md +16 -0
  4. package/dist/{ApiKey-BZmbOTnL-DEwZtcGA.js → ApiKey-BZmbOTnL-d61__hH4.js} +2 -2
  5. package/dist/ApiKeyCollection-Bfb_nw81-BVJ-i1Aw.js +2 -0
  6. package/dist/AuditLogCollection-B1YonCAP-bpq7rM2b.js +2 -0
  7. package/dist/{NostrIdentity-n70E0vck-DiBJPv76.js → NostrIdentity-n70E0vck-CmFPKTaE.js} +3 -3
  8. package/dist/NostrIdentityCollection-Ca74dkaL-C0rNgSy-.js +2 -0
  9. package/dist/{OidcIdentity-K8V4u0CR-B_ek3cPN.js → OidcIdentity-Cb1bxreM-L5LCgsel.js} +3 -3
  10. package/dist/OidcIdentityCollection-TV8VdK69-CUmZ-j4r.js +2 -0
  11. package/dist/ProfileAssetCollection-B9hacUJa-C53RAi1e.js +2 -0
  12. package/dist/{ProfileCollection-DTT4KNpQ-D1RNYfjm.js → ProfileCollection-DuJT7Mum-zuWR2h3v.js} +69 -31
  13. package/dist/ProfileLinkCollection-4uNURz32-Fmbnrmyr.js +2 -0
  14. package/dist/ProfileMetadataCollection-DQhbfEfa-D30yWyzB.js +2 -0
  15. package/dist/{ProfileMetafieldCollection-CMKhVi2I-mh3UgeZA.js → ProfileMetafieldCollection-CoBuM_5G-TNxSMsBJ.js} +1 -1
  16. package/dist/{ProfileRelationshipCollection-DUVgaIqf-CXHrAni4.js → ProfileRelationshipCollection-DUVgaIqf-CKcxS2xW.js} +1 -1
  17. package/dist/{ProfileRelationshipTermCollection-DtA9OM23-Cj4bLTpU.js → ProfileRelationshipTermCollection-DtA9OM23-h-4GhOFl.js} +1 -1
  18. package/dist/{ProfileRelationshipType-BRiRka6t-ICv2cawn.js → ProfileRelationshipType-BRiRka6t-BFgCVe6p.js} +1 -1
  19. package/dist/{ProfileRelationshipTypeCollection-BdXwd7Kf-CaYLxJmh.js → ProfileRelationshipTypeCollection-BdXwd7Kf-Djb3_k5Z.js} +1 -1
  20. package/dist/cli-generator.d.ts.map +1 -1
  21. package/dist/commands/db-diff.d.ts.map +1 -1
  22. package/dist/commands/db-history.d.ts.map +1 -1
  23. package/dist/commands/db-migrate-actions.d.ts +7 -0
  24. package/dist/commands/db-migrate-actions.d.ts.map +1 -1
  25. package/dist/commands/db-parity.d.ts +7 -0
  26. package/dist/commands/db-parity.d.ts.map +1 -1
  27. package/dist/commands/db-qualified-names.d.ts +39 -0
  28. package/dist/commands/db-qualified-names.d.ts.map +1 -0
  29. package/dist/commands/db-status.d.ts.map +1 -1
  30. package/dist/commands/gnode.d.ts.map +1 -1
  31. package/dist/commands/index.d.ts +1 -0
  32. package/dist/commands/index.d.ts.map +1 -1
  33. package/dist/commands/mcp-apps-packaging.d.ts +24 -0
  34. package/dist/commands/mcp-apps-packaging.d.ts.map +1 -0
  35. package/dist/commands/tenant-natural-keys.d.ts +58 -0
  36. package/dist/commands/tenant-natural-keys.d.ts.map +1 -0
  37. package/dist/commands/utilities.d.ts.map +1 -1
  38. package/dist/{commands-C2PjCUio.js → commands-e1tsvozP.js} +650 -22
  39. package/dist/{dist-B3npUhW6.js → dist-8C4XjDhz.js} +151 -6
  40. package/dist/{dist-C8sNOzr2.js → dist-CLA60jwS.js} +4 -4
  41. package/dist/dist-CksLvqnZ.js +1705 -0
  42. package/dist/dist-DtTkpUee.js +3937 -0
  43. package/dist/index.js +17 -11
  44. package/dist/{json-validator-oEN7eUI3.js → json-validator-BiAdsE1O.js} +4 -4
  45. package/dist/{oidcProvisioningPrimitives-0-3n3D6f-yUBRl2DC.js → oidcProvisioningPrimitives-D9OYu5-F-CJ6bTipi.js} +7 -7
  46. package/dist/resolveIdentity-B-PK6RwU-DxJFF8ad.js +2 -0
  47. package/package.json +16 -15
  48. package/dist/ApiKeyCollection-Bfb_nw81-D4pLjDGS.js +0 -2
  49. package/dist/AuditLogCollection-B1YonCAP-w1iOHW4A.js +0 -2
  50. package/dist/NostrIdentityCollection-Ca74dkaL-Ce-O_zQz.js +0 -2
  51. package/dist/OidcIdentityCollection-gnrLoF08-DJI-69Ow.js +0 -2
  52. package/dist/ProfileAssetCollection-CmbeSgDi-BpJ7XXsP.js +0 -2
  53. package/dist/ProfileMetadataCollection-DxX_BJKV-orYFlYii.js +0 -2
  54. package/dist/dist-DsMohSo_.js +0 -1487
  55. package/dist/dist-iMaSv6C_.js +0 -3712
  56. 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) console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
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$1(str) {
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$1(baseFieldName.slice(1))}` : toSnakeCase$1(baseFieldName)}${jsonPath}`;
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 (source.type === "git") await cleanupGitTemplate(config);
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-iMaSv6C_.js");
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-C8sNOzr2.js");
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-oEN7eUI3.js");
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,