@happyvertical/smrt-dev-mcp 0.47.1 → 0.47.2
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 +3 -0
- package/README.md +63 -0
- package/dist/http.d.ts +95 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1775 -1298
- package/dist/index.js.map +1 -1
- package/dist/{knowledge-DmZ-7GO9.js → knowledge-CY6sQzpj.js} +61 -2
- package/dist/knowledge-CY6sQzpj.js.map +1 -0
- package/dist/knowledge.js +1 -1
- package/dist/server-info.d.ts +3 -0
- package/dist/server-info.d.ts.map +1 -0
- package/dist/tool-catalog.d.ts.map +1 -1
- package/dist/tools/runtime/boot.d.ts +60 -0
- package/dist/tools/runtime/boot.d.ts.map +1 -0
- package/dist/tools/runtime/connection.d.ts.map +1 -1
- package/dist/tools/runtime/observation.d.ts +31 -0
- package/dist/tools/runtime/observation.d.ts.map +1 -0
- package/dist/tools/runtime/tools.d.ts +19 -0
- package/dist/tools/runtime/tools.d.ts.map +1 -1
- package/package.json +6 -6
- package/dist/knowledge-DmZ-7GO9.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as checkKnowledgeFreshness, f as smrtArchitecture, h as TOOLS, i as buildReviewContext, m as REVIEW_SKILL_NAME, n as buildKnowledgeIndex, o as checkKnowledgeFreshnessFromIndex, p as smrtReview, r as buildPackageSpecialistContext, s as compactContextResult, t as buildArchitectureContext } from "./knowledge-
|
|
2
|
+
import { a as checkKnowledgeFreshness, f as smrtArchitecture, h as TOOLS, i as buildReviewContext, m as REVIEW_SKILL_NAME, n as buildKnowledgeIndex, o as checkKnowledgeFreshnessFromIndex, p as smrtReview, r as buildPackageSpecialistContext, s as compactContextResult, t as buildArchitectureContext } from "./knowledge-CY6sQzpj.js";
|
|
3
3
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { ProtocolError, ProtocolErrorCode, Server } from "@modelcontextprotocol/server";
|
|
5
|
+
import { ProtocolError, ProtocolErrorCode, Server, createMcpHandler } from "@modelcontextprotocol/server";
|
|
7
6
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
8
|
-
import {
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { createServer as createServer$1 } from "node:http";
|
|
10
|
+
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
11
|
+
import { ObjectRegistry, readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth, snapshotRegistry } from "@happyvertical/smrt-core";
|
|
12
|
+
import { discoverSmrtPackages, resolveManifestPath } from "@happyvertical/smrt-core/manifest/discover-smrt-packages";
|
|
13
|
+
import { SchemaComparer } from "@happyvertical/smrt-core/migrations";
|
|
14
|
+
import { getPackageConfig, loadConfig } from "@happyvertical/smrt-config";
|
|
15
|
+
import { getDatabase } from "@happyvertical/sql";
|
|
9
16
|
import { ManifestAdapter, OxcScanner } from "@happyvertical/smrt-scanner";
|
|
10
17
|
import { access, readFile, readdir } from "node:fs/promises";
|
|
11
18
|
import { ManifestGenerator } from "@happyvertical/smrt-core/scanner";
|
|
12
|
-
import { readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth } from "@happyvertical/smrt-core";
|
|
13
|
-
import { getPackageConfig, loadConfig } from "@happyvertical/smrt-config";
|
|
14
|
-
import { getDatabase } from "@happyvertical/sql";
|
|
15
19
|
//#region src/agent-skills.ts
|
|
16
20
|
var AGENT_SKILLS = [{
|
|
17
21
|
name: "smrt-code-review",
|
|
@@ -47,1402 +51,1835 @@ function resolvePackageRoot() {
|
|
|
47
51
|
return packageRoot;
|
|
48
52
|
}
|
|
49
53
|
//#endregion
|
|
50
|
-
//#region src/
|
|
51
|
-
var
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
},
|
|
60
|
-
decimal: {
|
|
61
|
-
tsType: "number",
|
|
62
|
-
defaultValue: "0.0"
|
|
63
|
-
},
|
|
64
|
-
boolean: {
|
|
65
|
-
tsType: "boolean",
|
|
66
|
-
defaultValue: "false"
|
|
67
|
-
},
|
|
68
|
-
datetime: {
|
|
69
|
-
tsType: "Date",
|
|
70
|
-
defaultValue: "new Date()"
|
|
71
|
-
},
|
|
72
|
-
json: {
|
|
73
|
-
tsType: "any",
|
|
74
|
-
defaultValue: "{}"
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
async function generateSmrtClass(args) {
|
|
78
|
-
const { className, properties, relationships, baseClass, tableName, conflictColumns, tenantScoped, includeTenantIdField, includeApiConfig, includeMcpConfig, includeCliConfig, includeCompanionSnippets } = normalizeArgs(args);
|
|
79
|
-
const coreImports = /* @__PURE__ */ new Set([baseClass, "smrt"]);
|
|
80
|
-
if (needsFieldDecorator(properties)) coreImports.add("field");
|
|
81
|
-
for (const relationship of relationships) coreImports.add(relationship.type);
|
|
82
|
-
const imports = [`import { ${Array.from(coreImports).join(", ")} } from '@happyvertical/smrt-core';`];
|
|
83
|
-
if (tenantScoped) {
|
|
84
|
-
const tenancyImports = ["TenantScoped"];
|
|
85
|
-
if (includeTenantIdField) tenancyImports.push("tenantId");
|
|
86
|
-
imports.push(`import { ${tenancyImports.join(", ")} } from '@happyvertical/smrt-tenancy';`);
|
|
54
|
+
//#region src/server-info.ts
|
|
55
|
+
var SERVER_NAME = "smrt-dev-mcp";
|
|
56
|
+
function readPackageVersion() {
|
|
57
|
+
try {
|
|
58
|
+
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
59
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
60
|
+
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
|
|
61
|
+
} catch {
|
|
62
|
+
return "0.0.0";
|
|
87
63
|
}
|
|
88
|
-
const decoratorLines = [...tenantScoped ? [`@TenantScoped(${renderObjectLiteral({ ...tenantScoped })})`] : [], renderSmrtDecorator({
|
|
89
|
-
includeApiConfig,
|
|
90
|
-
includeMcpConfig,
|
|
91
|
-
includeCliConfig,
|
|
92
|
-
tableName,
|
|
93
|
-
conflictColumns
|
|
94
|
-
})];
|
|
95
|
-
const classMembers = [
|
|
96
|
-
...includeTenantIdField && tenantScoped ? [renderTenantIdField(tenantScoped)] : [],
|
|
97
|
-
...properties.map(renderProperty),
|
|
98
|
-
...relationships.map(renderRelationship)
|
|
99
|
-
].filter(Boolean);
|
|
100
|
-
const companionSnippets = includeCompanionSnippets ? `\n${renderCompanionSnippets(className, Boolean(tenantScoped))}` : "";
|
|
101
|
-
return `${imports.join("\n")}
|
|
102
|
-
|
|
103
|
-
${decoratorLines.join("\n")}
|
|
104
|
-
export class ${className} extends ${baseClass} {
|
|
105
|
-
${classMembers.join("\n\n")}
|
|
106
|
-
|
|
107
|
-
constructor(options: any = {}) {
|
|
108
|
-
super(options);
|
|
109
|
-
Object.assign(this, options);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
${companionSnippets}`;
|
|
113
|
-
}
|
|
114
|
-
function normalizeArgs(args) {
|
|
115
|
-
const template = args.template ?? "basic";
|
|
116
|
-
const templateDefaults = defaultsForTemplate(template);
|
|
117
|
-
const tenantScoped = args.tenantScoped === true ? templateDefaults.tenantScoped ?? { mode: "required" } : args.tenantScoped === false ? void 0 : args.tenantScoped ?? templateDefaults.tenantScoped;
|
|
118
|
-
return {
|
|
119
|
-
className: args.className,
|
|
120
|
-
properties: args.properties,
|
|
121
|
-
baseClass: args.baseClass ?? "SmrtObject",
|
|
122
|
-
template,
|
|
123
|
-
tableName: args.tableName ?? templateDefaults.tableName,
|
|
124
|
-
conflictColumns: args.conflictColumns ?? templateDefaults.conflictColumns ?? [],
|
|
125
|
-
tenantScoped: normalizeTenantScoped(tenantScoped),
|
|
126
|
-
includeTenantIdField: args.includeTenantIdField ?? templateDefaults.includeTenantIdField ?? Boolean(tenantScoped),
|
|
127
|
-
relationships: args.relationships ?? [],
|
|
128
|
-
includeApiConfig: args.includeApiConfig ?? true,
|
|
129
|
-
includeMcpConfig: args.includeMcpConfig ?? true,
|
|
130
|
-
includeCliConfig: args.includeCliConfig ?? true,
|
|
131
|
-
includeCompanionSnippets: args.includeCompanionSnippets ?? false
|
|
132
|
-
};
|
|
133
64
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
65
|
+
var SERVER_VERSION = readPackageVersion();
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/tools/runtime/boot.ts
|
|
68
|
+
/**
|
|
69
|
+
* Confined runtime bootstrap for the Level 2 observation plane (#1831).
|
|
70
|
+
*
|
|
71
|
+
* "Booting" here means registering *manifests* into the in-process
|
|
72
|
+
* `ObjectRegistry` — the project's own `.smrt/manifest.json` (or built
|
|
73
|
+
* `dist/manifest.json`) plus every installed SMRT package manifest that the
|
|
74
|
+
* project's dependency tree resolves to. No project source is imported, no
|
|
75
|
+
* module is executed, no database is touched. That confinement is the safety
|
|
76
|
+
* boundary: an observing agent sees what the runtime *would* register, never
|
|
77
|
+
* what arbitrary project code does on import.
|
|
78
|
+
*
|
|
79
|
+
* The registry is a process-global singleton, so a process boots once. There
|
|
80
|
+
* is deliberately no re-boot tool; restart the process to observe a rebuilt
|
|
81
|
+
* manifest.
|
|
82
|
+
*/
|
|
83
|
+
/** Provenance label for facts read from authored/installed manifests. */
|
|
84
|
+
var DECLARED_PROVENANCE = "declared (manifest)";
|
|
85
|
+
/** Project manifest candidates, most authoritative first. */
|
|
86
|
+
var PROJECT_MANIFEST_CANDIDATES = [".smrt/manifest.json", "dist/manifest.json"];
|
|
87
|
+
function relativePath(projectRoot, path) {
|
|
88
|
+
const rel = relative(projectRoot, path);
|
|
89
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return basename(path);
|
|
90
|
+
return rel.split(sep).join("/");
|
|
91
|
+
}
|
|
92
|
+
function readManifest(path) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
95
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
152
98
|
}
|
|
153
99
|
}
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function renderSmrtDecorator(options) {
|
|
165
|
-
const decoratorConfig = {};
|
|
166
|
-
if (options.tableName) decoratorConfig.tableName = options.tableName;
|
|
167
|
-
if (options.conflictColumns.length > 0) decoratorConfig.conflictColumns = options.conflictColumns;
|
|
168
|
-
if (options.includeApiConfig) decoratorConfig.api = {
|
|
169
|
-
include: [
|
|
170
|
-
"list",
|
|
171
|
-
"get",
|
|
172
|
-
"create",
|
|
173
|
-
"update"
|
|
174
|
-
],
|
|
175
|
-
exclude: ["delete"]
|
|
176
|
-
};
|
|
177
|
-
if (options.includeMcpConfig) decoratorConfig.mcp = { include: ["list", "get"] };
|
|
178
|
-
if (options.includeCliConfig) decoratorConfig.cli = true;
|
|
179
|
-
return Object.keys(decoratorConfig).length > 0 ? `@smrt(${JSON.stringify(decoratorConfig, null, 2)})` : "@smrt()";
|
|
180
|
-
}
|
|
181
|
-
function renderTenantIdField(tenantScoped) {
|
|
182
|
-
const nullable = tenantScoped.mode === "optional";
|
|
183
|
-
const field = tenantScoped.field ?? "tenantId";
|
|
184
|
-
return nullable ? ` @tenantId({ nullable: true })\n ${field}: string | null = null;` : ` @tenantId()\n ${field}: string = '';`;
|
|
100
|
+
function registerManifest(manifest, fallbackPackageName) {
|
|
101
|
+
const manifestPackageName = typeof manifest.packageName === "string" && manifest.packageName ? manifest.packageName : fallbackPackageName ?? void 0;
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const [name, definition] of Object.entries(manifest.objects ?? {})) {
|
|
104
|
+
if (!definition || typeof definition !== "object") continue;
|
|
105
|
+
const ownPackage = definition.packageName;
|
|
106
|
+
ObjectRegistry.registerFromManifest(name, definition, typeof ownPackage === "string" && ownPackage ? ownPackage : manifestPackageName);
|
|
107
|
+
count += 1;
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
185
110
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
});
|
|
196
|
-
return `${prop.description ? ` /** ${prop.description} */\n` : ""}${Object.keys(fieldOptions).length > 0 ? ` @field(${JSON.stringify(fieldOptions)})\n` : ""} ${prop.name}: ${tsType} = ${defaultValue};`;
|
|
111
|
+
var booted = null;
|
|
112
|
+
var bootedProjectRoot = null;
|
|
113
|
+
/**
|
|
114
|
+
* The resolved root the process booted from. Consumers must relativize
|
|
115
|
+
* paths against *this* root, never a per-request argument, or a caller could
|
|
116
|
+
* widen the root (e.g. `/`) and read the layout back through "relative" paths.
|
|
117
|
+
*/
|
|
118
|
+
function getBootedProjectRoot() {
|
|
119
|
+
return bootedProjectRoot;
|
|
197
120
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Boot the confined runtime once per process. A second call returns the
|
|
123
|
+
* existing record without touching the registry.
|
|
124
|
+
*/
|
|
125
|
+
async function bootRuntime(options = {}) {
|
|
126
|
+
if (booted) return booted;
|
|
127
|
+
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
128
|
+
const diagnostics = [];
|
|
129
|
+
const manifests = [];
|
|
130
|
+
const projectManifestPath = PROJECT_MANIFEST_CANDIDATES.map((candidate) => join(projectRoot, candidate)).find((path) => existsSync(path));
|
|
131
|
+
const projectPackageName = readProjectPackageName(projectRoot);
|
|
132
|
+
if (!projectManifestPath) diagnostics.push({
|
|
133
|
+
severity: "warning",
|
|
134
|
+
code: "project_manifest_missing",
|
|
135
|
+
message: "No project manifest found (.smrt/manifest.json or dist/manifest.json); run the project build so the runtime manifest exists."
|
|
208
136
|
});
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
137
|
+
else {
|
|
138
|
+
const manifest = readManifest(projectManifestPath);
|
|
139
|
+
if (!manifest) diagnostics.push({
|
|
140
|
+
severity: "error",
|
|
141
|
+
code: "project_manifest_invalid",
|
|
142
|
+
message: `Project manifest at ${relativePath(projectRoot, projectManifestPath)} is not valid JSON.`
|
|
143
|
+
});
|
|
144
|
+
else manifests.push({
|
|
145
|
+
kind: "project",
|
|
146
|
+
packageName: typeof manifest.packageName === "string" ? manifest.packageName : projectPackageName,
|
|
147
|
+
path: relativePath(projectRoot, projectManifestPath),
|
|
148
|
+
objectCount: registerManifest(manifest, projectPackageName)
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
let dependencyNames = [];
|
|
152
|
+
try {
|
|
153
|
+
dependencyNames = discoverSmrtPackages({
|
|
154
|
+
baseDir: projectRoot,
|
|
155
|
+
noCache: true
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
diagnostics.push({
|
|
159
|
+
severity: "warning",
|
|
160
|
+
code: "dependency_discovery_failed",
|
|
161
|
+
message: `Installed SMRT package discovery failed: ${error instanceof Error ? error.message : "unknown error"}`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
for (const dependency of dependencyNames.sort()) {
|
|
165
|
+
const manifestPath = resolveManifestPath(dependency, projectRoot);
|
|
166
|
+
if (!manifestPath) {
|
|
167
|
+
diagnostics.push({
|
|
168
|
+
severity: "info",
|
|
169
|
+
code: "dependency_manifest_missing",
|
|
170
|
+
message: `Installed SMRT package ${dependency} exposes no runtime manifest.`
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const manifest = readManifest(manifestPath);
|
|
175
|
+
if (!manifest) {
|
|
176
|
+
diagnostics.push({
|
|
177
|
+
severity: "warning",
|
|
178
|
+
code: "dependency_manifest_invalid",
|
|
179
|
+
message: `Manifest for ${dependency} is not valid JSON.`
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
manifests.push({
|
|
184
|
+
kind: "dependency",
|
|
185
|
+
packageName: dependency,
|
|
186
|
+
path: relativePath(projectRoot, manifestPath),
|
|
187
|
+
objectCount: registerManifest(manifest, dependency)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
bootedProjectRoot = projectRoot;
|
|
191
|
+
booted = {
|
|
192
|
+
provenance: DECLARED_PROVENANCE,
|
|
193
|
+
bootedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
194
|
+
projectName: projectPackageName ?? basename(projectRoot),
|
|
195
|
+
manifests,
|
|
196
|
+
objectCount: manifests.reduce((sum, m) => sum + m.objectCount, 0),
|
|
197
|
+
diagnostics
|
|
198
|
+
};
|
|
199
|
+
return booted;
|
|
233
200
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
201
|
+
function readProjectPackageName(projectRoot) {
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
|
|
204
|
+
return typeof parsed.name === "string" && parsed.name ? parsed.name : null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
236
208
|
}
|
|
237
209
|
//#endregion
|
|
238
|
-
//#region src/tools/
|
|
239
|
-
var DEFAULT_MANIFEST_PATHS = [
|
|
240
|
-
".smrt/manifest.json",
|
|
241
|
-
"dist/manifest.json",
|
|
242
|
-
"src/manifest/manifest.json"
|
|
243
|
-
];
|
|
244
|
-
var SCAN_EXCLUDE = [
|
|
245
|
-
"**/node_modules/**",
|
|
246
|
-
"**/dist/**",
|
|
247
|
-
"**/build/**",
|
|
248
|
-
"**/.git/**",
|
|
249
|
-
"**/.smrt/**",
|
|
250
|
-
"**/*.d.ts",
|
|
251
|
-
"**/*.test.ts",
|
|
252
|
-
"**/*.spec.ts",
|
|
253
|
-
"**/__tests__/**"
|
|
254
|
-
];
|
|
255
|
-
var RELATIONSHIP_TYPES = /* @__PURE__ */ new Set([
|
|
256
|
-
"foreignKey",
|
|
257
|
-
"crossPackageRef",
|
|
258
|
-
"oneToMany",
|
|
259
|
-
"manyToMany"
|
|
260
|
-
]);
|
|
210
|
+
//#region src/tools/runtime/connection.ts
|
|
261
211
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
212
|
+
* Optional read-only dev-database connection resolution for runtime
|
|
213
|
+
* diagnostics tools (#1824).
|
|
214
|
+
*
|
|
215
|
+
* Resolution order per call:
|
|
216
|
+
* 1. explicit `dbUrl`/`dbType` tool arguments
|
|
217
|
+
* 2. `SMRT_DEV_DB_URL` environment variable
|
|
218
|
+
* 3. the project's cosmiconfig CLI section (`getPackageConfig('cli', ...)`
|
|
219
|
+
* from `@happyvertical/smrt-config`) → `database.{type,url}`
|
|
220
|
+
*
|
|
221
|
+
* No configured connection → `db: null` with `source: 'none'`; callers return
|
|
222
|
+
* a successful static-only envelope. A connection is always opened lazily per
|
|
223
|
+
* call and closed in the caller's `finally` — nothing is cached across calls
|
|
224
|
+
* and the server never holds a database handle.
|
|
225
|
+
*
|
|
226
|
+
* Sensitive handling: connection strings are never logged or echoed. Every
|
|
227
|
+
* surfaced URL passes through {@link redactConnectionString}; driver errors
|
|
228
|
+
* are surfaced only through {@link safeErrorMessage}, which strips anything
|
|
229
|
+
* that looks like a credential-bearing URL.
|
|
230
|
+
*/
|
|
231
|
+
var RUNTIME_DATABASE_TYPES = [
|
|
232
|
+
"sqlite",
|
|
233
|
+
"postgres",
|
|
234
|
+
"duckdb"
|
|
272
235
|
];
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const projectPath = resolve(directory);
|
|
276
|
-
if (!await pathExists$1(projectPath)) return JSON.stringify({
|
|
277
|
-
projectPath,
|
|
278
|
-
manifestSource: "none",
|
|
279
|
-
objectCount: 0,
|
|
280
|
-
objects: [],
|
|
281
|
-
diagnostics: [{
|
|
282
|
-
severity: "warning",
|
|
283
|
-
message: `Project directory does not exist: ${projectPath}`
|
|
284
|
-
}]
|
|
285
|
-
}, null, 2);
|
|
286
|
-
const packageMetadata = await readPackageMetadata(projectPath);
|
|
287
|
-
const tenantScopes = await scanTenantScopes(projectPath);
|
|
288
|
-
const manifestResult = await loadManifestArtifact(projectPath, args.manifestPath) ?? await scanSourceManifest(projectPath, packageMetadata);
|
|
289
|
-
const entries = Object.entries(manifestResult.manifest.objects ?? {});
|
|
290
|
-
const objects = detail === "summary" ? entries.map(([manifestKey, object]) => summarizeObject({
|
|
291
|
-
manifestKey,
|
|
292
|
-
object,
|
|
293
|
-
projectPath,
|
|
294
|
-
tenantScope: tenantScopes.get(object.className)
|
|
295
|
-
})) : entries.map(([manifestKey, object]) => formatObject({
|
|
296
|
-
manifestKey,
|
|
297
|
-
object,
|
|
298
|
-
projectPath,
|
|
299
|
-
includeFields,
|
|
300
|
-
includeRelationships,
|
|
301
|
-
includeMethods,
|
|
302
|
-
tenantScope: tenantScopes.get(object.className)
|
|
303
|
-
}));
|
|
304
|
-
objects.sort((left, right) => left.className.localeCompare(right.className));
|
|
305
|
-
const { kept, omitted } = applyObjectBudget(objects, maxChars);
|
|
306
|
-
const output = {
|
|
307
|
-
projectPath,
|
|
308
|
-
manifestSource: manifestResult.source,
|
|
309
|
-
manifestPath: "path" in manifestResult ? relative(projectPath, manifestResult.path) : void 0,
|
|
310
|
-
packageName: manifestResult.manifest.packageName ?? packageMetadata.name ?? void 0,
|
|
311
|
-
packageVersion: manifestResult.manifest.packageVersion ?? packageMetadata.version ?? void 0,
|
|
312
|
-
detail,
|
|
313
|
-
objectCount: objects.length,
|
|
314
|
-
scannedFileCount: manifestResult.scannedFileCount,
|
|
315
|
-
parseTimeMs: manifestResult.parseTimeMs,
|
|
316
|
-
...omitted > 0 ? { truncated: {
|
|
317
|
-
returnedObjectCount: kept.length,
|
|
318
|
-
omittedObjectCount: omitted,
|
|
319
|
-
budgetChars: maxChars,
|
|
320
|
-
guidance: "The object list hit its character budget; metadata and diagnostics are still complete. Narrow the scan with `directory` (a single package), keep `detail: \"summary\"`, or raise `maxChars` deliberately. Object names are sorted alphabetically, so omitted objects are the alphabetical tail."
|
|
321
|
-
} } : {},
|
|
322
|
-
objects: kept,
|
|
323
|
-
diagnostics: manifestResult.diagnostics
|
|
324
|
-
};
|
|
325
|
-
return JSON.stringify(output, null, 2);
|
|
236
|
+
function isRuntimeDatabaseType(value) {
|
|
237
|
+
return RUNTIME_DATABASE_TYPES.includes(value);
|
|
326
238
|
}
|
|
327
239
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
240
|
+
* Sensitive query-parameter names. Matching normalizes the key (lowercase,
|
|
241
|
+
* `_`/`-` stripped), so camelCase (`authToken`, `accessToken`) and hyphen
|
|
242
|
+
* variants (`api-key`) are masked exactly like their snake_case forms.
|
|
330
243
|
*/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
function summarizeObject({ manifestKey, object, projectPath, tenantScope }) {
|
|
346
|
-
const fields = Object.entries(object.fields ?? {});
|
|
347
|
-
const decoratorConfig = object.decoratorConfig ?? {};
|
|
348
|
-
return compactObject({
|
|
349
|
-
manifestKey,
|
|
350
|
-
className: object.className,
|
|
351
|
-
qualifiedName: object.qualifiedName,
|
|
352
|
-
filePath: sanitizePath$1(projectPath, object.filePath),
|
|
353
|
-
extends: object.extends,
|
|
354
|
-
collection: object.collection,
|
|
355
|
-
tableName: object.schema?.tableName ?? stringFromConfig(decoratorConfig.tableName) ?? object.collection,
|
|
356
|
-
tenantScope: tenantScope ?? normalizeTenantScopedConfig(decoratorConfig.tenantScoped),
|
|
357
|
-
fieldCount: fields.length,
|
|
358
|
-
relationships: fields.filter(([, field]) => RELATIONSHIP_TYPES.has(field.type)).map(([name, field]) => `${name} -> ${field.related ?? ""} (${field.type})`).join(", ") || void 0,
|
|
359
|
-
mcpOperations: mcpOperationsFromConfig(decoratorConfig.mcp)
|
|
360
|
-
});
|
|
244
|
+
var SENSITIVE_QUERY_PARAMS = [
|
|
245
|
+
"access_token",
|
|
246
|
+
"apikey",
|
|
247
|
+
"api_key",
|
|
248
|
+
"auth",
|
|
249
|
+
"auth_token",
|
|
250
|
+
"connectionstring",
|
|
251
|
+
"connection_string",
|
|
252
|
+
"password",
|
|
253
|
+
"token"
|
|
254
|
+
];
|
|
255
|
+
function normalizeQueryParamName(key) {
|
|
256
|
+
return key.toLowerCase().replace(/[_-]/g, "");
|
|
361
257
|
}
|
|
258
|
+
var SENSITIVE_QUERY_PARAM_NAMES = new Set(SENSITIVE_QUERY_PARAMS.map(normalizeQueryParamName));
|
|
259
|
+
var DEFAULT_CLI_DATABASE = { database: {
|
|
260
|
+
type: "sqlite",
|
|
261
|
+
url: ":memory:"
|
|
262
|
+
} };
|
|
362
263
|
/**
|
|
363
|
-
*
|
|
364
|
-
* the
|
|
264
|
+
* Redact a connection string so it can be shown to an agent without leaking
|
|
265
|
+
* credentials. Mirrors the CLI's `redactConnectionString` (which is CLI
|
|
266
|
+
* private); patterned identically so dev-mcp never depends on the CLI.
|
|
267
|
+
*
|
|
268
|
+
* Query-parameter masking normalizes each key (lowercase, `_`/`-` stripped),
|
|
269
|
+
* so camelCase forms such as Turso/libsql's `?authToken=` mask exactly like
|
|
270
|
+
* their snake_case forms. A final regex pass also masks `key=value` pairs
|
|
271
|
+
* embedded in free text (driver error messages often quote the URL); it treats
|
|
272
|
+
* the start of the string, `?`, `&`, `,`, `(`, and whitespace as the
|
|
273
|
+
* preceding boundary.
|
|
365
274
|
*/
|
|
366
|
-
function
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const candidates = manifestPath ? [resolve(projectPath, manifestPath)] : DEFAULT_MANIFEST_PATHS.map((candidate) => join(projectPath, candidate));
|
|
376
|
-
const diagnostics = [];
|
|
377
|
-
for (const candidate of candidates) {
|
|
378
|
-
if (!await pathExists$1(candidate)) continue;
|
|
379
|
-
try {
|
|
380
|
-
const parsed = JSON.parse(await readFile(candidate, "utf-8"));
|
|
381
|
-
if (isManifestLike(parsed)) return {
|
|
382
|
-
source: "manifest",
|
|
383
|
-
path: candidate,
|
|
384
|
-
manifest: parsed,
|
|
385
|
-
diagnostics
|
|
386
|
-
};
|
|
387
|
-
diagnostics.push({
|
|
388
|
-
severity: "warning",
|
|
389
|
-
filePath: candidate,
|
|
390
|
-
message: "Manifest artifact is present but does not contain objects."
|
|
391
|
-
});
|
|
392
|
-
} catch (error) {
|
|
393
|
-
diagnostics.push({
|
|
394
|
-
severity: "error",
|
|
395
|
-
filePath: candidate,
|
|
396
|
-
message: `Unable to parse manifest artifact: ${messageFromError(error)}`
|
|
397
|
-
});
|
|
398
|
-
}
|
|
275
|
+
function redactConnectionString(value) {
|
|
276
|
+
let redacted = value;
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(value);
|
|
279
|
+
if (url.password) url.password = "***";
|
|
280
|
+
for (const key of [...url.searchParams.keys()]) if (SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key))) url.searchParams.set(key, "***");
|
|
281
|
+
redacted = url.toString();
|
|
282
|
+
} catch {
|
|
283
|
+
redacted = value.replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)(?:[^@\s]|@(?=[^@\s]*@))+(@)/gi, "$1***$2");
|
|
399
284
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
path: resolve(projectPath, manifestPath),
|
|
403
|
-
manifest: { objects: {} },
|
|
404
|
-
diagnostics: [...diagnostics, {
|
|
405
|
-
severity: "warning",
|
|
406
|
-
filePath: resolve(projectPath, manifestPath),
|
|
407
|
-
message: "Requested manifest artifact was not found."
|
|
408
|
-
}]
|
|
409
|
-
} : void 0;
|
|
285
|
+
redacted = redacted.replace(/(?:[A-Za-z]:)?(?:[\\/][^\s\\/'"`]+)+[\\/]([^\s\\/'"`]+\.(?:db|sqlite3?|duckdb))/g, "…/$1");
|
|
286
|
+
return redacted.replace(/((?:^|[?&,(\s])([a-z][a-z0-9_-]{0,30})=)([^&,\s)]+)/gi, (match, prefix, key) => SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key)) ? `${prefix}***` : match);
|
|
410
287
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
"**/*.js",
|
|
418
|
-
"**/*.jsx"
|
|
419
|
-
],
|
|
420
|
-
exclude: SCAN_EXCLUDE
|
|
421
|
-
}).scanAndResolve();
|
|
422
|
-
const manifest = new ManifestAdapter().toManifest(resolved.filter((classDef) => classDef.hasSmartDecorator), {
|
|
423
|
-
packageName: packageMetadata.name,
|
|
424
|
-
packageVersion: packageMetadata.version,
|
|
425
|
-
typeAliases: results.typeAliases
|
|
426
|
-
});
|
|
427
|
-
finalizeScannerManifest(manifest, packageMetadata);
|
|
428
|
-
return {
|
|
429
|
-
source: "scanner",
|
|
430
|
-
manifest,
|
|
431
|
-
diagnostics: results.errors.map(scanErrorToDiagnostic),
|
|
432
|
-
scannedFileCount: results.fileCount,
|
|
433
|
-
parseTimeMs: Math.round(results.totalParseTimeMs)
|
|
434
|
-
};
|
|
288
|
+
/**
|
|
289
|
+
* Build a safe, redacted error message for a database failure. Connection
|
|
290
|
+
* strings and raw driver error objects are never surfaced verbatim.
|
|
291
|
+
*/
|
|
292
|
+
function safeErrorMessage(error) {
|
|
293
|
+
return redactConnectionString(error instanceof Error ? error.message : String(error ?? "unknown error"));
|
|
435
294
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
manifestGen.generateAgentManifests(fullManifest, packageMetadata.name, packageMetadata.json);
|
|
446
|
-
});
|
|
295
|
+
/**
|
|
296
|
+
* Normalize a type hint into an engine `getDatabase` accepts. Unknown values
|
|
297
|
+
* throw a safe error (no URL is included) so the caller can surface a
|
|
298
|
+
* diagnostic instead of silently opening the wrong adapter.
|
|
299
|
+
*/
|
|
300
|
+
function toRuntimeDatabaseType(value) {
|
|
301
|
+
const normalized = value.trim().toLowerCase();
|
|
302
|
+
if (isRuntimeDatabaseType(normalized)) return normalized;
|
|
303
|
+
throw new Error(`Unsupported runtime database type "${normalized}"; expected sqlite, postgres, or duckdb`);
|
|
447
304
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
console.log = originalLog;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
function formatObject({ manifestKey, object, projectPath, includeFields, includeRelationships, includeMethods, tenantScope }) {
|
|
458
|
-
const fieldDetails = Object.entries(object.fields ?? {}).map(([name, field]) => ({
|
|
459
|
-
name,
|
|
460
|
-
type: field.type,
|
|
461
|
-
...field.required !== void 0 ? { required: field.required } : {},
|
|
462
|
-
...field.default !== void 0 ? { default: field.default } : {},
|
|
463
|
-
...field.related ? { related: field.related } : {},
|
|
464
|
-
...field.description ? { description: field.description } : {},
|
|
465
|
-
...field._meta ? { meta: field._meta } : {},
|
|
466
|
-
...field.transient !== void 0 ? { transient: field.transient } : {}
|
|
467
|
-
}));
|
|
468
|
-
const relationshipDetails = fieldDetails.filter((field) => RELATIONSHIP_TYPES.has(field.type)).map((field) => ({
|
|
469
|
-
field: field.name,
|
|
470
|
-
relatedClass: field.related ?? "",
|
|
471
|
-
type: field.type,
|
|
472
|
-
...field.meta ? { meta: field.meta } : {}
|
|
473
|
-
}));
|
|
474
|
-
const methodDetails = Object.entries(object.methods ?? {}).map(([name, method]) => ({
|
|
475
|
-
name: method.name ?? name,
|
|
476
|
-
isAsync: method.async === true,
|
|
477
|
-
isStatic: method.isStatic === true,
|
|
478
|
-
isPublic: method.isPublic !== false,
|
|
479
|
-
parameters: method.parameters ?? [],
|
|
480
|
-
returnType: method.returnType ?? "unknown",
|
|
481
|
-
...method.description ? { description: method.description } : {}
|
|
482
|
-
}));
|
|
483
|
-
const decoratorConfig = object.decoratorConfig ?? {};
|
|
484
|
-
const effectiveTenantScope = tenantScope ?? normalizeTenantScopedConfig(decoratorConfig.tenantScoped);
|
|
485
|
-
const schema = object.schema;
|
|
486
|
-
return compactObject({
|
|
487
|
-
manifestKey,
|
|
488
|
-
name: object.name,
|
|
489
|
-
className: object.className,
|
|
490
|
-
qualifiedName: object.qualifiedName,
|
|
491
|
-
filePath: sanitizePath$1(projectPath, object.filePath),
|
|
492
|
-
packageName: object.packageName,
|
|
493
|
-
packageVersion: object.packageVersion,
|
|
494
|
-
importPath: object.importPath,
|
|
495
|
-
modulePath: object.modulePath,
|
|
496
|
-
exportName: object.exportName,
|
|
497
|
-
collectionExportName: object.collectionExportName,
|
|
498
|
-
collection: object.collection,
|
|
499
|
-
extends: object.extends,
|
|
500
|
-
extendsTypeArg: object.extendsTypeArg,
|
|
501
|
-
tableName: schema?.tableName ?? stringFromConfig(decoratorConfig.tableName) ?? object.collection,
|
|
502
|
-
tableStrategy: stringFromConfig(decoratorConfig.tableStrategy) ?? "cti",
|
|
503
|
-
conflictColumns: arrayFromConfig(decoratorConfig.conflictColumns),
|
|
504
|
-
tenantScope: effectiveTenantScope,
|
|
505
|
-
decoratorConfig,
|
|
506
|
-
schema: schema ? {
|
|
507
|
-
tableName: schema.tableName,
|
|
508
|
-
columns: schema.columns,
|
|
509
|
-
indexes: schema.indexes ?? [],
|
|
510
|
-
version: schema.version
|
|
511
|
-
} : void 0,
|
|
512
|
-
indexes: schema?.indexes ?? [],
|
|
513
|
-
staticProperties: object.staticProperties,
|
|
514
|
-
validationRules: object.validationRules,
|
|
515
|
-
...includeFields && {
|
|
516
|
-
fields: fieldDetails.map((field) => `${field.name}: ${field.type}`).join(", "),
|
|
517
|
-
fieldDetails
|
|
518
|
-
},
|
|
519
|
-
...includeRelationships && relationshipDetails.length > 0 && {
|
|
520
|
-
relationships: relationshipDetails.map((relationship) => `${relationship.field} -> ${relationship.relatedClass} (${relationship.type})`).join(", "),
|
|
521
|
-
relationshipDetails
|
|
522
|
-
},
|
|
523
|
-
...includeMethods && methodDetails.length > 0 && {
|
|
524
|
-
methods: methodDetails.map((method) => `${method.isAsync ? "async " : ""}${method.name}()`).join(", "),
|
|
525
|
-
methodDetails
|
|
526
|
-
}
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
async function scanTenantScopes(projectPath) {
|
|
530
|
-
const files = await listSourceFiles$1(projectPath);
|
|
531
|
-
const scopes = /* @__PURE__ */ new Map();
|
|
532
|
-
await Promise.all(files.map(async (filePath) => {
|
|
533
|
-
const content = await readFile(filePath, "utf-8");
|
|
534
|
-
const classMatches = content.matchAll(/class\s+([A-Za-z_]\w*)\b/g);
|
|
535
|
-
for (const match of classMatches) {
|
|
536
|
-
const className = match[1];
|
|
537
|
-
if (!className || match.index === void 0) continue;
|
|
538
|
-
const prefix = content.slice(Math.max(0, match.index - 800), match.index);
|
|
539
|
-
const tenantMatch = Array.from(prefix.matchAll(/@TenantScoped\s*\(([\s\S]*?)\)/g)).at(-1);
|
|
540
|
-
if (!tenantMatch) continue;
|
|
541
|
-
scopes.set(className, {
|
|
542
|
-
source: "TenantScoped",
|
|
543
|
-
...parseTenantScopedOptions(tenantMatch[1])
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
}));
|
|
547
|
-
return scopes;
|
|
305
|
+
/** Infer an engine hint from a URL scheme when no explicit type is given. */
|
|
306
|
+
function inferDatabaseType(url, hint) {
|
|
307
|
+
if (hint && hint.trim().length > 0) return hint;
|
|
308
|
+
if (/^postgres(ql)?:/i.test(url)) return "postgres";
|
|
309
|
+
if (/^duckdb:/i.test(url)) return "duckdb";
|
|
310
|
+
return "sqlite";
|
|
548
311
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".spec.ts")) files.push(fullPath);
|
|
573
|
-
}
|
|
312
|
+
/**
|
|
313
|
+
* Resolve the dev-database connection for one tool call.
|
|
314
|
+
*
|
|
315
|
+
* Returns `db: null` (never throws) when no connection is configured; callers
|
|
316
|
+
* must treat that as "no runtime database" and return a static-only envelope.
|
|
317
|
+
* A thrown connect error is propagated to the caller, which converts it into
|
|
318
|
+
* a diagnostic envelope — it must never reach the MCP transport.
|
|
319
|
+
*/
|
|
320
|
+
async function resolveRuntimeConnection(args = {}) {
|
|
321
|
+
const argUrl = args.dbUrl?.trim();
|
|
322
|
+
if (argUrl && argUrl !== ":memory:") {
|
|
323
|
+
const databaseType = toRuntimeDatabaseType(inferDatabaseType(argUrl, args.dbType));
|
|
324
|
+
return {
|
|
325
|
+
db: await getDatabaseInstance({
|
|
326
|
+
type: databaseType,
|
|
327
|
+
url: argUrl
|
|
328
|
+
}),
|
|
329
|
+
source: "argument",
|
|
330
|
+
displayUrl: redactConnectionString(argUrl),
|
|
331
|
+
databaseType
|
|
332
|
+
};
|
|
574
333
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
function parseTenantScopedOptions(raw) {
|
|
579
|
-
const mode = raw.match(/mode\s*:\s*['"`](required|optional)['"`]/)?.[1];
|
|
580
|
-
const field = raw.match(/field\s*:\s*['"`]([A-Za-z_]\w*)['"`]/)?.[1];
|
|
581
|
-
const allowSuperAdminBypass = raw.match(/allowSuperAdminBypass\s*:\s*(true|false)/)?.[1];
|
|
582
|
-
const autoFilter = raw.match(/autoFilter\s*:\s*(true|false)/)?.[1];
|
|
583
|
-
const autoPopulate = raw.match(/autoPopulate\s*:\s*(true|false)/)?.[1];
|
|
584
|
-
return compactObject({
|
|
585
|
-
mode: mode ?? "required",
|
|
586
|
-
field: field ?? "tenantId",
|
|
587
|
-
autoFilter: autoFilter === void 0 ? void 0 : autoFilter === "true",
|
|
588
|
-
autoPopulate: autoPopulate === void 0 ? void 0 : autoPopulate === "true",
|
|
589
|
-
allowSuperAdminBypass: allowSuperAdminBypass === void 0 ? void 0 : allowSuperAdminBypass === "true"
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
function normalizeTenantScopedConfig(tenantScoped) {
|
|
593
|
-
if (!tenantScoped) return void 0;
|
|
594
|
-
const options = typeof tenantScoped === "object" && !Array.isArray(tenantScoped) ? tenantScoped : {};
|
|
595
|
-
return {
|
|
596
|
-
source: "smrt",
|
|
597
|
-
mode: options.mode ?? "required",
|
|
598
|
-
field: options.field ?? "tenantId",
|
|
599
|
-
autoFilter: options.autoFilter ?? true,
|
|
600
|
-
autoPopulate: options.autoPopulate ?? true,
|
|
601
|
-
allowSuperAdminBypass: options.allowSuperAdminBypass ?? false
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
async function readPackageMetadata(projectPath) {
|
|
605
|
-
const packageJsonPath = join(projectPath, "package.json");
|
|
606
|
-
if (!await pathExists$1(packageJsonPath)) return {};
|
|
607
|
-
try {
|
|
608
|
-
const json = JSON.parse(await readFile(packageJsonPath, "utf-8"));
|
|
334
|
+
const envUrl = process.env.SMRT_DEV_DB_URL?.trim();
|
|
335
|
+
if (envUrl && envUrl !== ":memory:") {
|
|
336
|
+
const databaseType = toRuntimeDatabaseType(inferDatabaseType(envUrl, args.dbType));
|
|
609
337
|
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
338
|
+
db: await getDatabaseInstance({
|
|
339
|
+
type: databaseType,
|
|
340
|
+
url: envUrl
|
|
341
|
+
}),
|
|
342
|
+
source: "environment",
|
|
343
|
+
displayUrl: redactConnectionString(envUrl),
|
|
344
|
+
databaseType
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const config = await loadCliDatabaseConfig();
|
|
348
|
+
const configUrl = config?.database?.url?.trim();
|
|
349
|
+
if (configUrl && configUrl !== ":memory:") {
|
|
350
|
+
const databaseType = toRuntimeDatabaseType(config.database?.type || inferDatabaseType(configUrl, args.dbType));
|
|
351
|
+
return {
|
|
352
|
+
db: await getDatabaseInstance({
|
|
353
|
+
type: databaseType,
|
|
354
|
+
url: configUrl
|
|
355
|
+
}),
|
|
356
|
+
source: "config",
|
|
357
|
+
displayUrl: redactConnectionString(configUrl),
|
|
358
|
+
databaseType
|
|
613
359
|
};
|
|
614
|
-
} catch {
|
|
615
|
-
return {};
|
|
616
360
|
}
|
|
361
|
+
return {
|
|
362
|
+
db: null,
|
|
363
|
+
source: "none",
|
|
364
|
+
displayUrl: "",
|
|
365
|
+
databaseType: null
|
|
366
|
+
};
|
|
617
367
|
}
|
|
618
|
-
async function
|
|
368
|
+
async function loadCliDatabaseConfig() {
|
|
619
369
|
try {
|
|
620
|
-
await
|
|
621
|
-
|
|
370
|
+
await loadConfig();
|
|
371
|
+
const database = getPackageConfig("cli", DEFAULT_CLI_DATABASE).database;
|
|
372
|
+
if (database && typeof database.url === "string") return { database };
|
|
373
|
+
return {};
|
|
622
374
|
} catch {
|
|
623
|
-
return
|
|
375
|
+
return {};
|
|
624
376
|
}
|
|
625
377
|
}
|
|
626
|
-
function
|
|
627
|
-
return
|
|
628
|
-
}
|
|
629
|
-
function scanErrorToDiagnostic(error) {
|
|
630
|
-
return {
|
|
631
|
-
severity: error.severity,
|
|
632
|
-
message: error.message,
|
|
633
|
-
filePath: error.filePath,
|
|
634
|
-
line: error.line,
|
|
635
|
-
column: error.column
|
|
636
|
-
};
|
|
378
|
+
async function getDatabaseInstance(options) {
|
|
379
|
+
return getDatabase(options);
|
|
637
380
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Best-effort close of a resolved connection. Never throws; diagnostics must
|
|
383
|
+
* not fail because cleanup hiccuped.
|
|
384
|
+
*/
|
|
385
|
+
async function closeRuntimeConnection(db) {
|
|
386
|
+
if (!db || typeof db !== "object") return;
|
|
387
|
+
const closeable = db;
|
|
388
|
+
const close = closeable.close ?? closeable.client?.end ?? closeable.client?.close;
|
|
389
|
+
if (typeof close !== "function") return;
|
|
390
|
+
try {
|
|
391
|
+
await close.call(closeable.close ? closeable : closeable.client);
|
|
392
|
+
} catch {}
|
|
642
393
|
}
|
|
643
|
-
|
|
644
|
-
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/tools/runtime/tools.ts
|
|
396
|
+
/**
|
|
397
|
+
* Runtime diagnostics tools (#1824): read-only views over a project's dev
|
|
398
|
+
* database `_smrt_*` system tables, powered by the shared SELECT-only
|
|
399
|
+
* system-diagnostics reader in `@happyvertical/smrt-core`.
|
|
400
|
+
*
|
|
401
|
+
* Contract:
|
|
402
|
+
* - **Optional connection.** No configured connection returns a successful
|
|
403
|
+
* static-only envelope — the server always starts and static tools are
|
|
404
|
+
* unaffected. A live connection is opened lazily per call and closed in
|
|
405
|
+
* `finally`; nothing is cached across calls.
|
|
406
|
+
* - **Read-only.** Every underlying statement is a bounded SELECT; the reader
|
|
407
|
+
* never selects sensitive columns (job payloads/results, schedule
|
|
408
|
+
* `agentConfig`/`methodArgs`, dispatch `payload`/`metadata`).
|
|
409
|
+
* - **Provenance-labeled.** Live results carry `provenance: 'runtime (live DB)'`;
|
|
410
|
+
* static-only results carry `provenance: 'static'` — agents must never
|
|
411
|
+
* conflate runtime facts with declared/manifest facts.
|
|
412
|
+
* - **Fail-safe.** A connect/read error becomes a diagnostic envelope; it must
|
|
413
|
+
* never reach the MCP transport and never includes raw driver text or URLs.
|
|
414
|
+
*/
|
|
415
|
+
/** Provenance labels separating runtime facts from static/declared facts. */
|
|
416
|
+
var RUNTIME_PROVENANCE = "runtime (live DB)";
|
|
417
|
+
var STATIC_PROVENANCE = "static";
|
|
418
|
+
/**
|
|
419
|
+
* Serialize resolve → read → close per connection target so overlapping tool
|
|
420
|
+
* calls never close a shared cached handle out from under each other.
|
|
421
|
+
*
|
|
422
|
+
* `@happyvertical/sql`'s `getDatabase` returns a cached handle per URL (no
|
|
423
|
+
* opt-out in its public API). `closeRuntimeConnection` only calls the handle's
|
|
424
|
+
* own `close`/`end`, but the SDK wraps those so a close also evicts the handle
|
|
425
|
+
* from its connection cache. Two concurrent diagnostics calls resolving the
|
|
426
|
+
* same URL would therefore share one handle, with the first finisher closing
|
|
427
|
+
* it mid-read for the second. A per-key promise chain keeps each call's
|
|
428
|
+
* lifecycle private: every call resolves its own view of the connection,
|
|
429
|
+
* performs its read, and only then closes — the next queued call re-resolves
|
|
430
|
+
* a fresh handle.
|
|
431
|
+
*
|
|
432
|
+
* The key is a digest of the resolved target, never the raw URL, so a
|
|
433
|
+
* credential-bearing connection string is not retained in this map.
|
|
434
|
+
*/
|
|
435
|
+
var runtimeReadQueues = /* @__PURE__ */ new Map();
|
|
436
|
+
function connectionQueueKey(args) {
|
|
437
|
+
const target = args.dbUrl?.trim() || process.env.SMRT_DEV_DB_URL?.trim() || "cli.config";
|
|
438
|
+
return createHash("sha256").update(target).digest("hex");
|
|
439
|
+
}
|
|
440
|
+
async function enqueueRuntimeRead(key, operation) {
|
|
441
|
+
const run = (runtimeReadQueues.get(key) ?? Promise.resolve()).then(operation, operation);
|
|
442
|
+
const tail = run.catch(() => void 0);
|
|
443
|
+
runtimeReadQueues.set(key, tail);
|
|
444
|
+
tail.then(() => {
|
|
445
|
+
if (runtimeReadQueues.get(key) === tail) runtimeReadQueues.delete(key);
|
|
446
|
+
});
|
|
447
|
+
return run;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Run one read against the optional runtime connection, mapping every outcome
|
|
451
|
+
* to a successful MCP envelope:
|
|
452
|
+
*
|
|
453
|
+
* - no connection configured → static-only envelope (`connected: false`)
|
|
454
|
+
* - connect failure → static envelope with a safe diagnostic
|
|
455
|
+
* - read failure → connected envelope with a safe diagnostic
|
|
456
|
+
* - success → live result under `provenance: 'runtime (live DB)'`; a
|
|
457
|
+
* category-unavailable reader result keeps its `available: false` data and
|
|
458
|
+
* surfaces its message as a diagnostic
|
|
459
|
+
*/
|
|
460
|
+
async function withRuntimeConnection(args, read, staticHint) {
|
|
461
|
+
return enqueueRuntimeRead(connectionQueueKey(args), () => runWithRuntimeConnection(args, read, staticHint));
|
|
462
|
+
}
|
|
463
|
+
async function runWithRuntimeConnection(args, read, staticHint) {
|
|
464
|
+
let resolved;
|
|
465
|
+
try {
|
|
466
|
+
resolved = await resolveRuntimeConnection(args);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
coverage: null,
|
|
471
|
+
diagnostics: [{
|
|
472
|
+
severity: "warning",
|
|
473
|
+
code: "runtime_connection_error",
|
|
474
|
+
message: safeErrorMessage(error)
|
|
475
|
+
}],
|
|
476
|
+
data: {
|
|
477
|
+
provenance: STATIC_PROVENANCE,
|
|
478
|
+
connected: false
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (!resolved.db) return {
|
|
483
|
+
ok: true,
|
|
484
|
+
coverage: null,
|
|
485
|
+
diagnostics: [{
|
|
486
|
+
severity: "info",
|
|
487
|
+
code: "runtime_connection_unavailable",
|
|
488
|
+
message: `No runtime dev database configured (set SMRT_DEV_DB_URL or cli.database); returning static-only result: ${staticHint}. Static tools are unaffected.`
|
|
489
|
+
}],
|
|
490
|
+
data: {
|
|
491
|
+
provenance: STATIC_PROVENANCE,
|
|
492
|
+
connected: false
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const { db, source, displayUrl, databaseType } = resolved;
|
|
496
|
+
try {
|
|
497
|
+
const { data, diagnostics } = await read(db);
|
|
498
|
+
return {
|
|
499
|
+
ok: true,
|
|
500
|
+
coverage: null,
|
|
501
|
+
diagnostics,
|
|
502
|
+
data: {
|
|
503
|
+
provenance: RUNTIME_PROVENANCE,
|
|
504
|
+
connected: true,
|
|
505
|
+
connectionSource: source,
|
|
506
|
+
databaseType,
|
|
507
|
+
displayUrl,
|
|
508
|
+
...data
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
} catch (error) {
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
coverage: null,
|
|
515
|
+
diagnostics: [{
|
|
516
|
+
severity: "warning",
|
|
517
|
+
code: "runtime_read_error",
|
|
518
|
+
message: safeErrorMessage(error)
|
|
519
|
+
}],
|
|
520
|
+
data: {
|
|
521
|
+
provenance: RUNTIME_PROVENANCE,
|
|
522
|
+
connected: true,
|
|
523
|
+
connectionSource: source,
|
|
524
|
+
databaseType,
|
|
525
|
+
displayUrl
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
} finally {
|
|
529
|
+
await closeRuntimeConnection(db);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Stored error columns (`error_message`, `last_error`) are free text written
|
|
534
|
+
* at failure time and routinely quote connection URLs or credentials. Every
|
|
535
|
+
* string in a live result passes through {@link redactConnectionString}
|
|
536
|
+
* before it reaches an MCP client; structure and non-string values are kept.
|
|
537
|
+
*/
|
|
538
|
+
function redactStrings(value) {
|
|
539
|
+
if (typeof value === "string") return redactConnectionString(value);
|
|
540
|
+
if (Array.isArray(value)) return value.map((item) => redactStrings(item));
|
|
541
|
+
if (value !== null && typeof value === "object") {
|
|
542
|
+
const out = {};
|
|
543
|
+
for (const [key, item] of Object.entries(value)) out[key] = redactStrings(item);
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
return value;
|
|
547
|
+
}
|
|
548
|
+
/** Convert a reader result into envelope data + diagnostics. */
|
|
549
|
+
function toEnvelopeParts(rawResult) {
|
|
550
|
+
const result = redactStrings(rawResult);
|
|
551
|
+
if (result !== null && typeof result === "object" && "available" in result && result.available === false) {
|
|
552
|
+
const unavailable = result;
|
|
553
|
+
const { message, ...rest } = unavailable;
|
|
554
|
+
return {
|
|
555
|
+
data: rest,
|
|
556
|
+
diagnostics: [{
|
|
557
|
+
severity: unavailable.reason === "retired" ? "info" : "warning",
|
|
558
|
+
code: `category_unavailable_${String(unavailable.reason).replace(/-/g, "_")}`,
|
|
559
|
+
message: String(message)
|
|
560
|
+
}]
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
data: result,
|
|
565
|
+
diagnostics: []
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function readToParts(read) {
|
|
569
|
+
return read.then((result) => toEnvelopeParts(result));
|
|
570
|
+
}
|
|
571
|
+
async function runtimeMigrationStatus(args = {}) {
|
|
572
|
+
const { limit, ...connectionArgs } = args;
|
|
573
|
+
return withRuntimeConnection(connectionArgs, (db) => readToParts(readMigrationStatus(db, { limit })), "no migration status — the manifest still reports the declared schema");
|
|
574
|
+
}
|
|
575
|
+
async function runtimeJobHealth(args = {}) {
|
|
576
|
+
const { limit, ...connectionArgs } = args;
|
|
577
|
+
return withRuntimeConnection(connectionArgs, (db) => readToParts(readJobHealth(db, { limit })), "no job health snapshot — the manifest still reports declared job queues");
|
|
578
|
+
}
|
|
579
|
+
async function runtimeScheduleHealth(args = {}) {
|
|
580
|
+
const { limit, ...connectionArgs } = args;
|
|
581
|
+
return withRuntimeConnection(connectionArgs, (db) => readToParts(readScheduleHealth(db, { limit })), "no schedule health snapshot — the manifest still reports declared schedules");
|
|
582
|
+
}
|
|
583
|
+
async function runtimeDispatchHealth(args = {}) {
|
|
584
|
+
const { limit, ...connectionArgs } = args;
|
|
585
|
+
return withRuntimeConnection(connectionArgs, (db) => readToParts(readDispatchHealth(db, { limit })), "no dispatch health snapshot — the manifest still reports declared dispatch topology");
|
|
586
|
+
}
|
|
587
|
+
async function runtimeRecentChanges(args = {}) {
|
|
588
|
+
const { since, tables, tenantId, limit, ...connectionArgs } = args;
|
|
589
|
+
return withRuntimeConnection(connectionArgs, (db) => readToParts(readRecentChanges(db, {
|
|
590
|
+
since,
|
|
591
|
+
tables,
|
|
592
|
+
tenantId,
|
|
593
|
+
limit
|
|
594
|
+
})), "no recent changes — static knowledge artifacts are unchanged");
|
|
595
|
+
}
|
|
596
|
+
async function runtimeRegistryDrift(args = {}) {
|
|
597
|
+
return withRuntimeConnection(args, (db) => readToParts(readRegistryDrift(db)), "no registry drift report — _smrt_registry is retired; declared objects come from the manifest");
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/tools/runtime/observation.ts
|
|
601
|
+
/**
|
|
602
|
+
* Level 2 read-only observation tools over the booted runtime (#1831).
|
|
603
|
+
*
|
|
604
|
+
* Three facts planes, labelled separately in every envelope:
|
|
605
|
+
* - `declared (manifest)`: what the confined boot registered ({@link bootRuntime});
|
|
606
|
+
* - `booted (registry)`: the in-process `ObjectRegistry` projected through the
|
|
607
|
+
* sanitized {@link snapshotRegistry} DTO;
|
|
608
|
+
* - `runtime (live DB)`: the optional read-only connection, reused from Level 1.
|
|
609
|
+
*
|
|
610
|
+
* Nothing here mutates: no writes, no `do()`, no generated CRUD, no project
|
|
611
|
+
* code execution. `runtime-schema-diff` only *introspects* the live schema.
|
|
612
|
+
*/
|
|
613
|
+
/** Row budget for `runtime-schema-diff` change lists. */
|
|
614
|
+
var SCHEMA_DIFF_CHANGE_LIMIT = 200;
|
|
615
|
+
function bootDiagnostics(boot) {
|
|
616
|
+
return boot.diagnostics.filter((d) => d.severity !== "info").map((d) => ({
|
|
617
|
+
severity: d.severity === "error" ? "warning" : d.severity,
|
|
618
|
+
code: `boot_${d.code}`,
|
|
619
|
+
message: d.message
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
function bootSummary(boot) {
|
|
623
|
+
return {
|
|
624
|
+
provenance: boot.provenance,
|
|
625
|
+
bootedAt: boot.bootedAt,
|
|
626
|
+
projectName: boot.projectName,
|
|
627
|
+
manifests: boot.manifests,
|
|
628
|
+
objectCount: boot.objectCount
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
/** `runtime-registry`: sanitized snapshot of the booted registry. */
|
|
632
|
+
async function runtimeRegistry(args = {}) {
|
|
633
|
+
const boot = await bootRuntime({ projectRoot: args.projectPath });
|
|
634
|
+
const snapshot = snapshotRegistry({
|
|
635
|
+
projectRoot: getBootedProjectRoot() ?? void 0,
|
|
636
|
+
objects: args.objects,
|
|
637
|
+
detail: args.detail ?? Boolean(args.objects?.length)
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
ok: true,
|
|
641
|
+
coverage: null,
|
|
642
|
+
diagnostics: bootDiagnostics(boot),
|
|
643
|
+
data: {
|
|
644
|
+
provenance: snapshot.provenance,
|
|
645
|
+
boot: bootSummary(boot),
|
|
646
|
+
snapshot
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/** `runtime-object`: one object's sanitized definition plus its generated DDL. */
|
|
651
|
+
async function runtimeObject(args) {
|
|
652
|
+
const boot = await bootRuntime({ projectRoot: args.projectPath });
|
|
653
|
+
const name = typeof args.name === "string" ? args.name.trim() : "";
|
|
654
|
+
const snapshot = snapshotRegistry({
|
|
655
|
+
projectRoot: getBootedProjectRoot() ?? void 0,
|
|
656
|
+
objects: name ? [name] : [],
|
|
657
|
+
detail: true
|
|
658
|
+
});
|
|
659
|
+
const diagnostics = bootDiagnostics(boot);
|
|
660
|
+
let object = snapshot.objects[0] ?? null;
|
|
661
|
+
if (snapshot.objects.length > 1) {
|
|
662
|
+
object = null;
|
|
663
|
+
diagnostics.push({
|
|
664
|
+
severity: "warning",
|
|
665
|
+
code: "object_ambiguous",
|
|
666
|
+
message: `${name} is registered by several packages; pass a qualified name: ${snapshot.objects.map((candidate) => candidate.qualifiedName ?? candidate.name).join(", ")}`
|
|
667
|
+
});
|
|
668
|
+
} else if (!object) diagnostics.push({
|
|
669
|
+
severity: "warning",
|
|
670
|
+
code: "object_not_found",
|
|
671
|
+
message: name ? `No booted object named ${name}; use runtime-registry to list names.` : "name is required."
|
|
672
|
+
});
|
|
673
|
+
let ddl = null;
|
|
674
|
+
if (object) try {
|
|
675
|
+
ddl = ObjectRegistry.getSchemaDDL(object.qualifiedName ?? object.name, args.engine) ?? null;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
diagnostics.push({
|
|
678
|
+
severity: "warning",
|
|
679
|
+
code: "ddl_unavailable",
|
|
680
|
+
message: `Generated DDL unavailable: ${error instanceof Error ? error.message : "unknown error"}`
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
coverage: null,
|
|
686
|
+
diagnostics,
|
|
687
|
+
data: {
|
|
688
|
+
provenance: snapshot.provenance,
|
|
689
|
+
boot: bootSummary(boot),
|
|
690
|
+
object,
|
|
691
|
+
ddl
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* `runtime-schema-diff`: booted registry schemas versus the live database,
|
|
697
|
+
* using the same comparer `db:diff`/`db:migrate` use. Introspection only —
|
|
698
|
+
* drop/relax options are pinned off and nothing is executed.
|
|
699
|
+
*/
|
|
700
|
+
async function runtimeSchemaDiff(args = {}) {
|
|
701
|
+
const boot = await bootRuntime({ projectRoot: args.projectPath });
|
|
702
|
+
const envelope = await withRuntimeConnection(args, async (db) => {
|
|
703
|
+
const diff = await new SchemaComparer(db, {
|
|
704
|
+
includeDroppedTables: false,
|
|
705
|
+
includeDroppedColumns: false,
|
|
706
|
+
includeDroppedIndexes: false,
|
|
707
|
+
relaxColumns: false
|
|
708
|
+
}).compare(ObjectRegistry.getAllSchemasAsDefinitions());
|
|
709
|
+
const byType = {};
|
|
710
|
+
for (const change of diff.changes) {
|
|
711
|
+
const type = String(change.type ?? "unknown");
|
|
712
|
+
byType[type] = (byType[type] ?? 0) + 1;
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
data: {
|
|
716
|
+
boot: bootSummary(boot),
|
|
717
|
+
hasChanges: diff.has_changes,
|
|
718
|
+
addedTables: diff.added_tables.map((t) => t.tableName),
|
|
719
|
+
droppedTables: diff.dropped_tables,
|
|
720
|
+
orphanTables: diff.orphan_tables ?? [],
|
|
721
|
+
changeCount: diff.changes.length,
|
|
722
|
+
changesByType: byType,
|
|
723
|
+
changes: diff.changes.slice(0, SCHEMA_DIFF_CHANGE_LIMIT),
|
|
724
|
+
truncated: diff.changes.length > SCHEMA_DIFF_CHANGE_LIMIT
|
|
725
|
+
},
|
|
726
|
+
diagnostics: []
|
|
727
|
+
};
|
|
728
|
+
}, "booted registry schemas only; connect a dev database to diff against live tables");
|
|
729
|
+
envelope.diagnostics = [...bootDiagnostics(boot), ...envelope.diagnostics];
|
|
730
|
+
return envelope;
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/http.ts
|
|
734
|
+
/**
|
|
735
|
+
* Standalone Level 2 runtime dev-plane host (#1831).
|
|
736
|
+
*
|
|
737
|
+
* Serves a *positive* read-only tool catalog over the stateless 2026-07-28
|
|
738
|
+
* Streamable HTTP transport (#2147): `createMcpHandler` with `legacy: 'reject'`
|
|
739
|
+
* and `maxSubscriptions: 0`, adapted to Node with `toNodeHandler`. No SSE,
|
|
740
|
+
* no session header, no sticky routing.
|
|
741
|
+
*
|
|
742
|
+
* Security boundary (development only):
|
|
743
|
+
* - binds loopback only; SDK localhost Host/Origin validation runs first;
|
|
744
|
+
* - every request needs `Authorization: Bearer <token>` (constant-time
|
|
745
|
+
* compare) — from `SMRT_DEV_MCP_TOKEN` or minted per process;
|
|
746
|
+
* - no authenticated principal exists here, so scope stays fail-closed
|
|
747
|
+
* global-only exactly as in Level 1;
|
|
748
|
+
* - the catalog never includes generated CRUD, custom actions, `do()`, or
|
|
749
|
+
* tool-backed `is()`.
|
|
750
|
+
*/
|
|
751
|
+
/**
|
|
752
|
+
* The complete Level 2 catalog. A tool is exposed over HTTP only if it is
|
|
753
|
+
* named here; the static stdio catalog is deliberately not mounted.
|
|
754
|
+
*/
|
|
755
|
+
var RUNTIME_HTTP_TOOL_NAMES = [
|
|
756
|
+
"runtime-registry",
|
|
757
|
+
"runtime-object",
|
|
758
|
+
"runtime-schema-diff",
|
|
759
|
+
"migration-status",
|
|
760
|
+
"job-health",
|
|
761
|
+
"schedule-health",
|
|
762
|
+
"dispatch-health",
|
|
763
|
+
"recent-changes",
|
|
764
|
+
"registry-drift"
|
|
765
|
+
];
|
|
766
|
+
var RUNTIME_HTTP_HANDLERS = {
|
|
767
|
+
"runtime-registry": (args) => runtimeRegistry(args),
|
|
768
|
+
"runtime-object": (args) => runtimeObject(args),
|
|
769
|
+
"runtime-schema-diff": (args) => runtimeSchemaDiff(args),
|
|
770
|
+
"migration-status": (args) => runtimeMigrationStatus(args),
|
|
771
|
+
"job-health": (args) => runtimeJobHealth(args),
|
|
772
|
+
"schedule-health": (args) => runtimeScheduleHealth(args),
|
|
773
|
+
"dispatch-health": (args) => runtimeDispatchHealth(args),
|
|
774
|
+
"recent-changes": (args) => runtimeRecentChanges(args),
|
|
775
|
+
"registry-drift": (args) => runtimeRegistryDrift(args)
|
|
776
|
+
};
|
|
777
|
+
/** Catalog definitions for the HTTP plane, in catalog order. */
|
|
778
|
+
function runtimeHttpTools() {
|
|
779
|
+
const names = new Set(RUNTIME_HTTP_TOOL_NAMES);
|
|
780
|
+
return TOOLS.filter((tool) => names.has(tool.name));
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Build a fresh protocol server per request (stateless transport contract).
|
|
784
|
+
* `projectRoot` is fixed at host start; per-request `projectPath` arguments
|
|
785
|
+
* are ignored so a client cannot re-point the booted process.
|
|
786
|
+
*/
|
|
787
|
+
function createRuntimeProtocolServer(projectRoot) {
|
|
788
|
+
const server = new Server({
|
|
789
|
+
name: `${SERVER_NAME}-runtime`,
|
|
790
|
+
version: SERVER_VERSION
|
|
791
|
+
}, { capabilities: { tools: {} } });
|
|
792
|
+
server.setRequestHandler("tools/list", async () => ({ tools: runtimeHttpTools() }));
|
|
793
|
+
server.setRequestHandler("tools/call", async (request) => {
|
|
794
|
+
const name = request.params.name;
|
|
795
|
+
const handler = RUNTIME_HTTP_HANDLERS[name];
|
|
796
|
+
if (typeof handler !== "function") throw new Error(`Unknown runtime tool: ${name}`);
|
|
797
|
+
const { projectPath: _ignored, ...args } = request.params.arguments ?? {};
|
|
798
|
+
const result = await handler({
|
|
799
|
+
...args,
|
|
800
|
+
projectPath: projectRoot
|
|
801
|
+
});
|
|
802
|
+
return {
|
|
803
|
+
content: [{
|
|
804
|
+
type: "text",
|
|
805
|
+
text: JSON.stringify(result, null, 2)
|
|
806
|
+
}],
|
|
807
|
+
structuredContent: result
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
return server;
|
|
811
|
+
}
|
|
812
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
813
|
+
"127.0.0.1",
|
|
814
|
+
"localhost",
|
|
815
|
+
"::1"
|
|
816
|
+
]);
|
|
817
|
+
function bearerMatches(header, token) {
|
|
818
|
+
if (!header?.startsWith("Bearer ")) return false;
|
|
819
|
+
const presented = Buffer.from(header.slice(7));
|
|
820
|
+
const expected = Buffer.from(token);
|
|
821
|
+
return presented.length === expected.length && timingSafeEqual(presented, expected);
|
|
822
|
+
}
|
|
823
|
+
function deny(res, status, message) {
|
|
824
|
+
res.statusCode = status;
|
|
825
|
+
res.setHeader("content-type", "application/json");
|
|
826
|
+
if (status === 401) res.setHeader("www-authenticate", "Bearer realm=\"smrt-dev-mcp runtime\"");
|
|
827
|
+
res.end(JSON.stringify({ error: message }));
|
|
828
|
+
}
|
|
829
|
+
/** Start the Level 2 host. Boots the confined runtime before listening. */
|
|
830
|
+
async function startRuntimeHttpHost(options = {}) {
|
|
831
|
+
const host = options.host ?? "127.0.0.1";
|
|
832
|
+
if (!LOOPBACK_HOSTS.has(host)) throw new Error("The runtime dev-plane binds loopback only (127.0.0.1, localhost, ::1).");
|
|
833
|
+
const path = options.path ?? "/mcp";
|
|
834
|
+
const token = options.token ?? process.env.SMRT_DEV_MCP_TOKEN?.trim() ?? randomBytes(24).toString("base64url");
|
|
835
|
+
if (!token) throw new Error("SMRT_DEV_MCP_TOKEN must not be empty.");
|
|
836
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
837
|
+
const boot = await bootRuntime({ projectRoot });
|
|
838
|
+
const mcp = toNodeHandler(createMcpHandler(() => createRuntimeProtocolServer(projectRoot), {
|
|
839
|
+
legacy: "reject",
|
|
840
|
+
maxSubscriptions: 0
|
|
841
|
+
}));
|
|
842
|
+
const hostGuard = localhostHostValidation();
|
|
843
|
+
const originGuard = localhostOriginValidation();
|
|
844
|
+
const httpServer = createServer$1((req, res) => {
|
|
845
|
+
if (!hostGuard(req, res)) return;
|
|
846
|
+
if (!originGuard(req, res)) return;
|
|
847
|
+
if (new URL(req.url ?? "/", "http://localhost").pathname !== path) {
|
|
848
|
+
deny(res, 404, "not found");
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (!bearerMatches(req.headers.authorization, token)) {
|
|
852
|
+
deny(res, 401, "missing or invalid bearer token");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
mcp(req, res);
|
|
856
|
+
});
|
|
857
|
+
await new Promise((resolve, reject) => {
|
|
858
|
+
httpServer.once("error", reject);
|
|
859
|
+
httpServer.listen(options.port ?? 0, host, () => {
|
|
860
|
+
httpServer.off("error", reject);
|
|
861
|
+
resolve();
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
const address = httpServer.address();
|
|
865
|
+
return {
|
|
866
|
+
url: `http://${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}${path}`,
|
|
867
|
+
token,
|
|
868
|
+
boot,
|
|
869
|
+
close: () => new Promise((resolve, reject) => {
|
|
870
|
+
httpServer.closeAllConnections?.();
|
|
871
|
+
httpServer.close((error) => error ? reject(error) : resolve());
|
|
872
|
+
})
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/tools/generate-smrt-class.ts
|
|
877
|
+
var TYPE_MAPPING = {
|
|
878
|
+
text: {
|
|
879
|
+
tsType: "string",
|
|
880
|
+
defaultValue: "''"
|
|
881
|
+
},
|
|
882
|
+
integer: {
|
|
883
|
+
tsType: "number",
|
|
884
|
+
defaultValue: "0"
|
|
885
|
+
},
|
|
886
|
+
decimal: {
|
|
887
|
+
tsType: "number",
|
|
888
|
+
defaultValue: "0.0"
|
|
889
|
+
},
|
|
890
|
+
boolean: {
|
|
891
|
+
tsType: "boolean",
|
|
892
|
+
defaultValue: "false"
|
|
893
|
+
},
|
|
894
|
+
datetime: {
|
|
895
|
+
tsType: "Date",
|
|
896
|
+
defaultValue: "new Date()"
|
|
897
|
+
},
|
|
898
|
+
json: {
|
|
899
|
+
tsType: "any",
|
|
900
|
+
defaultValue: "{}"
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
async function generateSmrtClass(args) {
|
|
904
|
+
const { className, properties, relationships, baseClass, tableName, conflictColumns, tenantScoped, includeTenantIdField, includeApiConfig, includeMcpConfig, includeCliConfig, includeCompanionSnippets } = normalizeArgs(args);
|
|
905
|
+
const coreImports = /* @__PURE__ */ new Set([baseClass, "smrt"]);
|
|
906
|
+
if (needsFieldDecorator(properties)) coreImports.add("field");
|
|
907
|
+
for (const relationship of relationships) coreImports.add(relationship.type);
|
|
908
|
+
const imports = [`import { ${Array.from(coreImports).join(", ")} } from '@happyvertical/smrt-core';`];
|
|
909
|
+
if (tenantScoped) {
|
|
910
|
+
const tenancyImports = ["TenantScoped"];
|
|
911
|
+
if (includeTenantIdField) tenancyImports.push("tenantId");
|
|
912
|
+
imports.push(`import { ${tenancyImports.join(", ")} } from '@happyvertical/smrt-tenancy';`);
|
|
913
|
+
}
|
|
914
|
+
const decoratorLines = [...tenantScoped ? [`@TenantScoped(${renderObjectLiteral({ ...tenantScoped })})`] : [], renderSmrtDecorator({
|
|
915
|
+
includeApiConfig,
|
|
916
|
+
includeMcpConfig,
|
|
917
|
+
includeCliConfig,
|
|
918
|
+
tableName,
|
|
919
|
+
conflictColumns
|
|
920
|
+
})];
|
|
921
|
+
const classMembers = [
|
|
922
|
+
...includeTenantIdField && tenantScoped ? [renderTenantIdField(tenantScoped)] : [],
|
|
923
|
+
...properties.map(renderProperty),
|
|
924
|
+
...relationships.map(renderRelationship)
|
|
925
|
+
].filter(Boolean);
|
|
926
|
+
const companionSnippets = includeCompanionSnippets ? `\n${renderCompanionSnippets(className, Boolean(tenantScoped))}` : "";
|
|
927
|
+
return `${imports.join("\n")}
|
|
928
|
+
|
|
929
|
+
${decoratorLines.join("\n")}
|
|
930
|
+
export class ${className} extends ${baseClass} {
|
|
931
|
+
${classMembers.join("\n\n")}
|
|
932
|
+
|
|
933
|
+
constructor(options: any = {}) {
|
|
934
|
+
super(options);
|
|
935
|
+
Object.assign(this, options);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
${companionSnippets}`;
|
|
939
|
+
}
|
|
940
|
+
function normalizeArgs(args) {
|
|
941
|
+
const template = args.template ?? "basic";
|
|
942
|
+
const templateDefaults = defaultsForTemplate(template);
|
|
943
|
+
const tenantScoped = args.tenantScoped === true ? templateDefaults.tenantScoped ?? { mode: "required" } : args.tenantScoped === false ? void 0 : args.tenantScoped ?? templateDefaults.tenantScoped;
|
|
944
|
+
return {
|
|
945
|
+
className: args.className,
|
|
946
|
+
properties: args.properties,
|
|
947
|
+
baseClass: args.baseClass ?? "SmrtObject",
|
|
948
|
+
template,
|
|
949
|
+
tableName: args.tableName ?? templateDefaults.tableName,
|
|
950
|
+
conflictColumns: args.conflictColumns ?? templateDefaults.conflictColumns ?? [],
|
|
951
|
+
tenantScoped: normalizeTenantScoped(tenantScoped),
|
|
952
|
+
includeTenantIdField: args.includeTenantIdField ?? templateDefaults.includeTenantIdField ?? Boolean(tenantScoped),
|
|
953
|
+
relationships: args.relationships ?? [],
|
|
954
|
+
includeApiConfig: args.includeApiConfig ?? true,
|
|
955
|
+
includeMcpConfig: args.includeMcpConfig ?? true,
|
|
956
|
+
includeCliConfig: args.includeCliConfig ?? true,
|
|
957
|
+
includeCompanionSnippets: args.includeCompanionSnippets ?? false
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
function defaultsForTemplate(template) {
|
|
961
|
+
switch (template) {
|
|
962
|
+
case "optional-catalog": return {
|
|
963
|
+
tenantScoped: { mode: "optional" },
|
|
964
|
+
includeTenantIdField: true,
|
|
965
|
+
conflictColumns: ["tenant_id", "slug"]
|
|
966
|
+
};
|
|
967
|
+
case "tenant-project-object": return {
|
|
968
|
+
tenantScoped: { mode: "required" },
|
|
969
|
+
includeTenantIdField: true
|
|
970
|
+
};
|
|
971
|
+
case "tenant-event-log-object": return {
|
|
972
|
+
tenantScoped: { mode: "optional" },
|
|
973
|
+
includeTenantIdField: true
|
|
974
|
+
};
|
|
975
|
+
case "global-catalog": return { conflictColumns: ["slug"] };
|
|
976
|
+
case "cross-package-reference":
|
|
977
|
+
case "basic": return {};
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function normalizeTenantScoped(value) {
|
|
981
|
+
if (!value) return void 0;
|
|
982
|
+
return {
|
|
983
|
+
mode: typeof value === "object" ? value.mode ?? "required" : "required",
|
|
984
|
+
field: typeof value === "object" ? value.field ?? "tenantId" : "tenantId",
|
|
985
|
+
autoFilter: typeof value === "object" ? value.autoFilter : void 0,
|
|
986
|
+
autoPopulate: typeof value === "object" ? value.autoPopulate : void 0,
|
|
987
|
+
allowSuperAdminBypass: typeof value === "object" ? value.allowSuperAdminBypass : void 0
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
function renderSmrtDecorator(options) {
|
|
991
|
+
const decoratorConfig = {};
|
|
992
|
+
if (options.tableName) decoratorConfig.tableName = options.tableName;
|
|
993
|
+
if (options.conflictColumns.length > 0) decoratorConfig.conflictColumns = options.conflictColumns;
|
|
994
|
+
if (options.includeApiConfig) decoratorConfig.api = {
|
|
995
|
+
include: [
|
|
996
|
+
"list",
|
|
997
|
+
"get",
|
|
998
|
+
"create",
|
|
999
|
+
"update"
|
|
1000
|
+
],
|
|
1001
|
+
exclude: ["delete"]
|
|
1002
|
+
};
|
|
1003
|
+
if (options.includeMcpConfig) decoratorConfig.mcp = { include: ["list", "get"] };
|
|
1004
|
+
if (options.includeCliConfig) decoratorConfig.cli = true;
|
|
1005
|
+
return Object.keys(decoratorConfig).length > 0 ? `@smrt(${JSON.stringify(decoratorConfig, null, 2)})` : "@smrt()";
|
|
645
1006
|
}
|
|
646
|
-
function
|
|
647
|
-
|
|
1007
|
+
function renderTenantIdField(tenantScoped) {
|
|
1008
|
+
const nullable = tenantScoped.mode === "optional";
|
|
1009
|
+
const field = tenantScoped.field ?? "tenantId";
|
|
1010
|
+
return nullable ? ` @tenantId({ nullable: true })\n ${field}: string | null = null;` : ` @tenantId()\n ${field}: string = '';`;
|
|
648
1011
|
}
|
|
649
|
-
function
|
|
650
|
-
|
|
1012
|
+
function renderProperty(prop) {
|
|
1013
|
+
const mapping = TYPE_MAPPING[prop.type];
|
|
1014
|
+
const nullable = prop.nullable === true;
|
|
1015
|
+
const tsType = nullable ? `${mapping.tsType} | null` : mapping.tsType;
|
|
1016
|
+
const defaultValue = prop.defaultValue !== void 0 ? renderLiteral(prop.defaultValue) : nullable ? "null" : mapping.defaultValue;
|
|
1017
|
+
const fieldOptions = compactObject$1({
|
|
1018
|
+
required: prop.required,
|
|
1019
|
+
nullable: prop.nullable,
|
|
1020
|
+
description: prop.description
|
|
1021
|
+
});
|
|
1022
|
+
return `${prop.description ? ` /** ${prop.description} */\n` : ""}${Object.keys(fieldOptions).length > 0 ? ` @field(${JSON.stringify(fieldOptions)})\n` : ""} ${prop.name}: ${tsType} = ${defaultValue};`;
|
|
651
1023
|
}
|
|
652
|
-
function
|
|
653
|
-
|
|
1024
|
+
function renderRelationship(relationship) {
|
|
1025
|
+
const options = compactObject$1({
|
|
1026
|
+
required: relationship.required,
|
|
1027
|
+
nullable: relationship.nullable,
|
|
1028
|
+
description: relationship.description,
|
|
1029
|
+
validate: relationship.validate,
|
|
1030
|
+
foreignKey: relationship.foreignKey,
|
|
1031
|
+
through: relationship.through,
|
|
1032
|
+
sourceKey: relationship.sourceKey,
|
|
1033
|
+
targetKey: relationship.targetKey
|
|
1034
|
+
});
|
|
1035
|
+
const args = [renderLiteral(relationship.related), ...Object.keys(options).length > 0 ? [JSON.stringify(options)] : []];
|
|
1036
|
+
const decorator = `@${relationship.type}(${args.join(", ")})`;
|
|
1037
|
+
const fieldType = relationship.type === "oneToMany" || relationship.type === "manyToMany" ? "unknown[]" : relationship.nullable ? "string | null" : "string";
|
|
1038
|
+
const defaultValue = relationship.type === "oneToMany" || relationship.type === "manyToMany" ? "[]" : relationship.nullable ? "null" : "''";
|
|
1039
|
+
return ` ${decorator}\n ${relationship.name}: ${fieldType} = ${defaultValue};`;
|
|
1040
|
+
}
|
|
1041
|
+
function needsFieldDecorator(properties) {
|
|
1042
|
+
return properties.some((property) => property.required !== void 0 || property.nullable !== void 0 || property.description !== void 0);
|
|
1043
|
+
}
|
|
1044
|
+
function renderCompanionSnippets(className, usesTenantScoped) {
|
|
1045
|
+
return `/*
|
|
1046
|
+
* Package wiring:
|
|
1047
|
+
* - Export ${className} from the package entrypoint used by consumers.
|
|
1048
|
+
* - Import this module from any package registration file that eagerly loads objects.${usesTenantScoped ? `\n * - Ensure package.json declares "@happyvertical/smrt-tenancy".` : ""}
|
|
1049
|
+
*/`;
|
|
1050
|
+
}
|
|
1051
|
+
function renderObjectLiteral(value) {
|
|
1052
|
+
return JSON.stringify(compactObject$1(value), null, 2);
|
|
1053
|
+
}
|
|
1054
|
+
function renderLiteral(value) {
|
|
1055
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
1056
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1057
|
+
if (value === null) return "null";
|
|
1058
|
+
return JSON.stringify(value, null, 2);
|
|
1059
|
+
}
|
|
1060
|
+
function compactObject$1(value) {
|
|
1061
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
654
1062
|
}
|
|
655
1063
|
//#endregion
|
|
656
|
-
//#region src/tools/
|
|
1064
|
+
//#region src/tools/introspect-project.ts
|
|
1065
|
+
var DEFAULT_MANIFEST_PATHS = [
|
|
1066
|
+
".smrt/manifest.json",
|
|
1067
|
+
"dist/manifest.json",
|
|
1068
|
+
"src/manifest/manifest.json"
|
|
1069
|
+
];
|
|
1070
|
+
var SCAN_EXCLUDE = [
|
|
1071
|
+
"**/node_modules/**",
|
|
1072
|
+
"**/dist/**",
|
|
1073
|
+
"**/build/**",
|
|
1074
|
+
"**/.git/**",
|
|
1075
|
+
"**/.smrt/**",
|
|
1076
|
+
"**/*.d.ts",
|
|
1077
|
+
"**/*.test.ts",
|
|
1078
|
+
"**/*.spec.ts",
|
|
1079
|
+
"**/__tests__/**"
|
|
1080
|
+
];
|
|
1081
|
+
var RELATIONSHIP_TYPES = /* @__PURE__ */ new Set([
|
|
1082
|
+
"foreignKey",
|
|
1083
|
+
"crossPackageRef",
|
|
1084
|
+
"oneToMany",
|
|
1085
|
+
"manyToMany"
|
|
1086
|
+
]);
|
|
657
1087
|
/**
|
|
658
|
-
*
|
|
659
|
-
*
|
|
1088
|
+
* Response budget in characters. Generous enough that a 42-object summary never
|
|
1089
|
+
* truncates, while still capping a runaway payload instead of emitting one.
|
|
660
1090
|
*/
|
|
661
|
-
var
|
|
662
|
-
var
|
|
663
|
-
"
|
|
664
|
-
"
|
|
665
|
-
"
|
|
666
|
-
"
|
|
667
|
-
"
|
|
668
|
-
".svelte-kit",
|
|
669
|
-
"coverage"
|
|
670
|
-
]);
|
|
671
|
-
var DEPENDENCY_SECTIONS = [
|
|
672
|
-
"dependencies",
|
|
673
|
-
"devDependencies",
|
|
674
|
-
"peerDependencies",
|
|
675
|
-
"optionalDependencies"
|
|
1091
|
+
var DEFAULT_MAX_CHARS = 5e4;
|
|
1092
|
+
var DEFAULT_MCP_OPERATIONS = [
|
|
1093
|
+
"list",
|
|
1094
|
+
"get",
|
|
1095
|
+
"create",
|
|
1096
|
+
"update",
|
|
1097
|
+
"delete"
|
|
676
1098
|
];
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (!await pathExists(projectPath)) return JSON.stringify({
|
|
1099
|
+
async function introspectProject(args) {
|
|
1100
|
+
const { directory = process.cwd(), includeFields = true, includeRelationships = true, includeMethods = true, detail = "summary", maxChars = DEFAULT_MAX_CHARS } = args;
|
|
1101
|
+
const projectPath = resolve(directory);
|
|
1102
|
+
if (!await pathExists$1(projectPath)) return JSON.stringify({
|
|
682
1103
|
projectPath,
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
summary: {
|
|
687
|
-
high: 0,
|
|
688
|
-
medium: 0,
|
|
689
|
-
low: 0
|
|
690
|
-
},
|
|
1104
|
+
manifestSource: "none",
|
|
1105
|
+
objectCount: 0,
|
|
1106
|
+
objects: [],
|
|
691
1107
|
diagnostics: [{
|
|
692
1108
|
severity: "warning",
|
|
693
1109
|
message: `Project directory does not exist: ${projectPath}`
|
|
694
1110
|
}]
|
|
695
1111
|
}, null, 2);
|
|
696
|
-
const
|
|
697
|
-
const
|
|
698
|
-
await
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
...findCustomHttpShells(sourceFiles, packageContexts, projectPath),
|
|
704
|
-
...findLocalAuthTenancy(sourceFiles, packageContexts, projectPath),
|
|
705
|
-
...findUiShellDrift(packageContexts, projectPath),
|
|
706
|
-
...findMissingManifestArtifacts(sourceFiles, packageContexts, projectPath)
|
|
707
|
-
], args.maxFindings);
|
|
708
|
-
const packages = packageContexts.map(packageInventory).sort((left, right) => left.path.localeCompare(right.path));
|
|
709
|
-
const summary = summarizeFindings(findings);
|
|
710
|
-
return JSON.stringify({
|
|
1112
|
+
const packageMetadata = await readPackageMetadata(projectPath);
|
|
1113
|
+
const tenantScopes = await scanTenantScopes(projectPath);
|
|
1114
|
+
const manifestResult = await loadManifestArtifact(projectPath, args.manifestPath) ?? await scanSourceManifest(projectPath, packageMetadata);
|
|
1115
|
+
const entries = Object.entries(manifestResult.manifest.objects ?? {});
|
|
1116
|
+
const objects = detail === "summary" ? entries.map(([manifestKey, object]) => summarizeObject({
|
|
1117
|
+
manifestKey,
|
|
1118
|
+
object,
|
|
711
1119
|
projectPath,
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
],
|
|
722
|
-
suggestedFollowUpIssues: findings.map((finding) => ({
|
|
723
|
-
title: finding.suggestedIssueTitle,
|
|
724
|
-
severity: finding.severity,
|
|
725
|
-
area: finding.area
|
|
726
|
-
}))
|
|
727
|
-
}, null, 2);
|
|
728
|
-
}
|
|
729
|
-
async function buildPackageContexts(projectPath) {
|
|
730
|
-
const packageJsonPaths = await listPackageJsonFiles(projectPath);
|
|
731
|
-
const contexts = await Promise.all(packageJsonPaths.map(async (packageJsonPath) => {
|
|
732
|
-
const json = JSON.parse(await readFile(packageJsonPath, "utf-8"));
|
|
733
|
-
const directory = dirname(packageJsonPath);
|
|
734
|
-
return {
|
|
735
|
-
directory,
|
|
736
|
-
relativePath: relative(projectPath, directory) || ".",
|
|
737
|
-
packageJsonPath,
|
|
738
|
-
json,
|
|
739
|
-
dependencies: collectDependencies(json),
|
|
740
|
-
scripts: isRecord$1(json.scripts) ? json.scripts : {},
|
|
741
|
-
imports: /* @__PURE__ */ new Map(),
|
|
742
|
-
sourceFiles: []
|
|
743
|
-
};
|
|
1120
|
+
tenantScope: tenantScopes.get(object.className)
|
|
1121
|
+
})) : entries.map(([manifestKey, object]) => formatObject({
|
|
1122
|
+
manifestKey,
|
|
1123
|
+
object,
|
|
1124
|
+
projectPath,
|
|
1125
|
+
includeFields,
|
|
1126
|
+
includeRelationships,
|
|
1127
|
+
includeMethods,
|
|
1128
|
+
tenantScope: tenantScopes.get(object.className)
|
|
744
1129
|
}));
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1130
|
+
objects.sort((left, right) => left.className.localeCompare(right.className));
|
|
1131
|
+
const { kept, omitted } = applyObjectBudget(objects, maxChars);
|
|
1132
|
+
const output = {
|
|
1133
|
+
projectPath,
|
|
1134
|
+
manifestSource: manifestResult.source,
|
|
1135
|
+
manifestPath: "path" in manifestResult ? relative(projectPath, manifestResult.path) : void 0,
|
|
1136
|
+
packageName: manifestResult.manifest.packageName ?? packageMetadata.name ?? void 0,
|
|
1137
|
+
packageVersion: manifestResult.manifest.packageVersion ?? packageMetadata.version ?? void 0,
|
|
1138
|
+
detail,
|
|
1139
|
+
objectCount: objects.length,
|
|
1140
|
+
scannedFileCount: manifestResult.scannedFileCount,
|
|
1141
|
+
parseTimeMs: manifestResult.parseTimeMs,
|
|
1142
|
+
...omitted > 0 ? { truncated: {
|
|
1143
|
+
returnedObjectCount: kept.length,
|
|
1144
|
+
omittedObjectCount: omitted,
|
|
1145
|
+
budgetChars: maxChars,
|
|
1146
|
+
guidance: "The object list hit its character budget; metadata and diagnostics are still complete. Narrow the scan with `directory` (a single package), keep `detail: \"summary\"`, or raise `maxChars` deliberately. Object names are sorted alphabetically, so omitted objects are the alphabetical tail."
|
|
1147
|
+
} } : {},
|
|
1148
|
+
objects: kept,
|
|
1149
|
+
diagnostics: manifestResult.diagnostics
|
|
1150
|
+
};
|
|
1151
|
+
return JSON.stringify(output, null, 2);
|
|
756
1152
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1153
|
+
/**
|
|
1154
|
+
* Trims the object list to the character budget, always keeping at least one so
|
|
1155
|
+
* a caller sees the shape rather than an empty list.
|
|
1156
|
+
*/
|
|
1157
|
+
function applyObjectBudget(objects, maxChars) {
|
|
1158
|
+
const kept = [];
|
|
1159
|
+
let used = 0;
|
|
1160
|
+
for (const object of objects) {
|
|
1161
|
+
const size = JSON.stringify(object, null, 2).length + 2;
|
|
1162
|
+
if (used + size > maxChars && kept.length > 0) break;
|
|
1163
|
+
used += size;
|
|
1164
|
+
kept.push(object);
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
kept,
|
|
1168
|
+
omitted: objects.length - kept.length
|
|
1169
|
+
};
|
|
763
1170
|
}
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1171
|
+
function summarizeObject({ manifestKey, object, projectPath, tenantScope }) {
|
|
1172
|
+
const fields = Object.entries(object.fields ?? {});
|
|
1173
|
+
const decoratorConfig = object.decoratorConfig ?? {};
|
|
1174
|
+
return compactObject({
|
|
1175
|
+
manifestKey,
|
|
1176
|
+
className: object.className,
|
|
1177
|
+
qualifiedName: object.qualifiedName,
|
|
1178
|
+
filePath: sanitizePath$1(projectPath, object.filePath),
|
|
1179
|
+
extends: object.extends,
|
|
1180
|
+
collection: object.collection,
|
|
1181
|
+
tableName: object.schema?.tableName ?? stringFromConfig(decoratorConfig.tableName) ?? object.collection,
|
|
1182
|
+
tenantScope: tenantScope ?? normalizeTenantScopedConfig(decoratorConfig.tenantScoped),
|
|
1183
|
+
fieldCount: fields.length,
|
|
1184
|
+
relationships: fields.filter(([, field]) => RELATIONSHIP_TYPES.has(field.type)).map(([name, field]) => `${name} -> ${field.related ?? ""} (${field.type})`).join(", ") || void 0,
|
|
1185
|
+
mcpOperations: mcpOperationsFromConfig(decoratorConfig.mcp)
|
|
776
1186
|
});
|
|
777
|
-
return files;
|
|
778
1187
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1188
|
+
/**
|
|
1189
|
+
* An omitted `mcp` config means full CRUD, not a closed surface — the same rule
|
|
1190
|
+
* the knowledge index applies.
|
|
1191
|
+
*/
|
|
1192
|
+
function mcpOperationsFromConfig(config) {
|
|
1193
|
+
if (config === false) return [];
|
|
1194
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) return [...DEFAULT_MCP_OPERATIONS];
|
|
1195
|
+
const record = config;
|
|
1196
|
+
const include = Array.isArray(record.include) ? record.include.filter((item) => typeof item === "string") : DEFAULT_MCP_OPERATIONS;
|
|
1197
|
+
const exclude = new Set(Array.isArray(record.exclude) ? record.exclude.filter((item) => typeof item === "string") : []);
|
|
1198
|
+
return include.filter((operation) => !exclude.has(operation));
|
|
1199
|
+
}
|
|
1200
|
+
async function loadManifestArtifact(projectPath, manifestPath) {
|
|
1201
|
+
const candidates = manifestPath ? [resolve(projectPath, manifestPath)] : DEFAULT_MANIFEST_PATHS.map((candidate) => join(projectPath, candidate));
|
|
1202
|
+
const diagnostics = [];
|
|
1203
|
+
for (const candidate of candidates) {
|
|
1204
|
+
if (!await pathExists$1(candidate)) continue;
|
|
1205
|
+
try {
|
|
1206
|
+
const parsed = JSON.parse(await readFile(candidate, "utf-8"));
|
|
1207
|
+
if (isManifestLike(parsed)) return {
|
|
1208
|
+
source: "manifest",
|
|
1209
|
+
path: candidate,
|
|
1210
|
+
manifest: parsed,
|
|
1211
|
+
diagnostics
|
|
790
1212
|
};
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1213
|
+
diagnostics.push({
|
|
1214
|
+
severity: "warning",
|
|
1215
|
+
filePath: candidate,
|
|
1216
|
+
message: "Manifest artifact is present but does not contain objects."
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
diagnostics.push({
|
|
1220
|
+
severity: "error",
|
|
1221
|
+
filePath: candidate,
|
|
1222
|
+
message: `Unable to parse manifest artifact: ${messageFromError(error)}`
|
|
1223
|
+
});
|
|
794
1224
|
}
|
|
795
1225
|
}
|
|
1226
|
+
return manifestPath ? {
|
|
1227
|
+
source: "manifest",
|
|
1228
|
+
path: resolve(projectPath, manifestPath),
|
|
1229
|
+
manifest: { objects: {} },
|
|
1230
|
+
diagnostics: [...diagnostics, {
|
|
1231
|
+
severity: "warning",
|
|
1232
|
+
filePath: resolve(projectPath, manifestPath),
|
|
1233
|
+
message: "Requested manifest artifact was not found."
|
|
1234
|
+
}]
|
|
1235
|
+
} : void 0;
|
|
796
1236
|
}
|
|
797
|
-
function
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1237
|
+
async function scanSourceManifest(projectPath, packageMetadata) {
|
|
1238
|
+
const { results, resolved } = await new OxcScanner({
|
|
1239
|
+
cwd: projectPath,
|
|
1240
|
+
include: [
|
|
1241
|
+
"**/*.ts",
|
|
1242
|
+
"**/*.tsx",
|
|
1243
|
+
"**/*.js",
|
|
1244
|
+
"**/*.jsx"
|
|
1245
|
+
],
|
|
1246
|
+
exclude: SCAN_EXCLUDE
|
|
1247
|
+
}).scanAndResolve();
|
|
1248
|
+
const manifest = new ManifestAdapter().toManifest(resolved.filter((classDef) => classDef.hasSmartDecorator), {
|
|
1249
|
+
packageName: packageMetadata.name,
|
|
1250
|
+
packageVersion: packageMetadata.version,
|
|
1251
|
+
typeAliases: results.typeAliases
|
|
811
1252
|
});
|
|
1253
|
+
finalizeScannerManifest(manifest, packageMetadata);
|
|
1254
|
+
return {
|
|
1255
|
+
source: "scanner",
|
|
1256
|
+
manifest,
|
|
1257
|
+
diagnostics: results.errors.map(scanErrorToDiagnostic),
|
|
1258
|
+
scannedFileCount: results.fileCount,
|
|
1259
|
+
parseTimeMs: Math.round(results.totalParseTimeMs)
|
|
1260
|
+
};
|
|
812
1261
|
}
|
|
813
|
-
function
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}],
|
|
824
|
-
recommendation: "Use the SMRT scanner/runtime manifest path so defaults, relationships, schemas, tenant fields, and cross-package references match framework behavior.",
|
|
825
|
-
suggestedIssueTitle: "Replace custom object manifest generation with SMRT scanner/runtime manifest generation"
|
|
826
|
-
}));
|
|
827
|
-
}
|
|
828
|
-
function findDirectStorageBypasses(sourceFiles, packages, projectPath) {
|
|
829
|
-
return sourceFiles.flatMap((file) => {
|
|
830
|
-
const owner = findOwningPackage(file.path, packages);
|
|
831
|
-
const usesFs = /from\s+['"](?:node:fs|node:fs\/promises|fs|fs\/promises)['"]|require\(['"](?:node:fs|node:fs\/promises|fs|fs\/promises)['"]\)/.test(file.content);
|
|
832
|
-
const writesDurableData = /\b(writeFile|appendFile|mkdir|rm|rename)\b/.test(file.content) && /(\.json|data|storage|persist|cache|db)/i.test(file.content);
|
|
833
|
-
const usesDirectSql = /from\s+['"](?:better-sqlite3|sqlite3|pg|mysql2?|knex|drizzle-orm)['"]/.test(file.content);
|
|
834
|
-
if ((!usesFs || !writesDurableData) && !usesDirectSql) return [];
|
|
835
|
-
if (owner.dependencies.has("@happyvertical/sql") || owner.dependencies.has("@happyvertical/files") || owner.dependencies.has("@happyvertical/smrt-assets") || owner.dependencies.has("@happyvertical/smrt-content")) return [];
|
|
836
|
-
return [{
|
|
837
|
-
severity: "medium",
|
|
838
|
-
area: "storage",
|
|
839
|
-
code: "direct-storage-bypass",
|
|
840
|
-
title: `${packageLabel(owner)} appears to bypass HappyVertical storage packages`,
|
|
841
|
-
evidence: [{
|
|
842
|
-
filePath: relative(projectPath, file.path),
|
|
843
|
-
line: lineNumber(file.content, usesDirectSql ? "sqlite" : "writeFile"),
|
|
844
|
-
detail: usesDirectSql ? "Imports a direct SQL/storage library without @happyvertical/sql." : "Uses node:fs-style durable writes without @happyvertical/files/assets/content."
|
|
845
|
-
}],
|
|
846
|
-
recommendation: "Route durable data through @happyvertical/sql, @happyvertical/files, SMRT assets, or SMRT content unless this is explicitly build-only tooling.",
|
|
847
|
-
suggestedIssueTitle: `Review direct storage usage in ${packageLabel(owner)}`
|
|
848
|
-
}];
|
|
1262
|
+
function finalizeScannerManifest(manifest, packageMetadata) {
|
|
1263
|
+
const manifestGen = new ManifestGenerator();
|
|
1264
|
+
const fullManifest = manifest;
|
|
1265
|
+
withSuppressedConsoleLog(() => {
|
|
1266
|
+
manifestGen.injectTenantScopedFields(fullManifest);
|
|
1267
|
+
manifestGen.mergeInheritedFields(fullManifest);
|
|
1268
|
+
manifestGen.generateValidationRules(fullManifest);
|
|
1269
|
+
manifestGen.generateSchemas(fullManifest);
|
|
1270
|
+
manifestGen.assertTenantScopedSchemaContract(fullManifest);
|
|
1271
|
+
manifestGen.generateAgentManifests(fullManifest, packageMetadata.name, packageMetadata.json);
|
|
849
1272
|
});
|
|
850
1273
|
}
|
|
851
|
-
function
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
"hono",
|
|
859
|
-
"koa"
|
|
860
|
-
].filter((dep) => pkg.dependencies.has(dep));
|
|
861
|
-
if (routerDependencies.length === 0) continue;
|
|
862
|
-
appendEvidence(evidenceByPackage, pkg, {
|
|
863
|
-
filePath: relative(projectPath, pkg.packageJsonPath),
|
|
864
|
-
detail: `Declares custom router package(s): ${routerDependencies.join(", ")}.`
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
for (const file of sourceFiles) {
|
|
868
|
-
const owner = findOwningPackage(file.path, packages);
|
|
869
|
-
if (owner.dependencies.has("@sveltejs/kit")) continue;
|
|
870
|
-
if (!/from\s+['"](?:node:http|http|node:https|https)['"]|createServer\s*\(/.test(file.content)) continue;
|
|
871
|
-
appendEvidence(evidenceByPackage, owner, {
|
|
872
|
-
filePath: relative(projectPath, file.path),
|
|
873
|
-
line: lineNumber(file.content, "createServer"),
|
|
874
|
-
detail: "Custom HTTP routing found without the SvelteKit/SMRT app-shell dependency pattern."
|
|
875
|
-
});
|
|
1274
|
+
function withSuppressedConsoleLog(callback) {
|
|
1275
|
+
const originalLog = console.log;
|
|
1276
|
+
console.log = () => void 0;
|
|
1277
|
+
try {
|
|
1278
|
+
return callback();
|
|
1279
|
+
} finally {
|
|
1280
|
+
console.log = originalLog;
|
|
876
1281
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1282
|
+
}
|
|
1283
|
+
function formatObject({ manifestKey, object, projectPath, includeFields, includeRelationships, includeMethods, tenantScope }) {
|
|
1284
|
+
const fieldDetails = Object.entries(object.fields ?? {}).map(([name, field]) => ({
|
|
1285
|
+
name,
|
|
1286
|
+
type: field.type,
|
|
1287
|
+
...field.required !== void 0 ? { required: field.required } : {},
|
|
1288
|
+
...field.default !== void 0 ? { default: field.default } : {},
|
|
1289
|
+
...field.related ? { related: field.related } : {},
|
|
1290
|
+
...field.description ? { description: field.description } : {},
|
|
1291
|
+
...field._meta ? { meta: field._meta } : {},
|
|
1292
|
+
...field.transient !== void 0 ? { transient: field.transient } : {}
|
|
1293
|
+
}));
|
|
1294
|
+
const relationshipDetails = fieldDetails.filter((field) => RELATIONSHIP_TYPES.has(field.type)).map((field) => ({
|
|
1295
|
+
field: field.name,
|
|
1296
|
+
relatedClass: field.related ?? "",
|
|
1297
|
+
type: field.type,
|
|
1298
|
+
...field.meta ? { meta: field.meta } : {}
|
|
1299
|
+
}));
|
|
1300
|
+
const methodDetails = Object.entries(object.methods ?? {}).map(([name, method]) => ({
|
|
1301
|
+
name: method.name ?? name,
|
|
1302
|
+
isAsync: method.async === true,
|
|
1303
|
+
isStatic: method.isStatic === true,
|
|
1304
|
+
isPublic: method.isPublic !== false,
|
|
1305
|
+
parameters: method.parameters ?? [],
|
|
1306
|
+
returnType: method.returnType ?? "unknown",
|
|
1307
|
+
...method.description ? { description: method.description } : {}
|
|
1308
|
+
}));
|
|
1309
|
+
const decoratorConfig = object.decoratorConfig ?? {};
|
|
1310
|
+
const effectiveTenantScope = tenantScope ?? normalizeTenantScopedConfig(decoratorConfig.tenantScoped);
|
|
1311
|
+
const schema = object.schema;
|
|
1312
|
+
return compactObject({
|
|
1313
|
+
manifestKey,
|
|
1314
|
+
name: object.name,
|
|
1315
|
+
className: object.className,
|
|
1316
|
+
qualifiedName: object.qualifiedName,
|
|
1317
|
+
filePath: sanitizePath$1(projectPath, object.filePath),
|
|
1318
|
+
packageName: object.packageName,
|
|
1319
|
+
packageVersion: object.packageVersion,
|
|
1320
|
+
importPath: object.importPath,
|
|
1321
|
+
modulePath: object.modulePath,
|
|
1322
|
+
exportName: object.exportName,
|
|
1323
|
+
collectionExportName: object.collectionExportName,
|
|
1324
|
+
collection: object.collection,
|
|
1325
|
+
extends: object.extends,
|
|
1326
|
+
extendsTypeArg: object.extendsTypeArg,
|
|
1327
|
+
tableName: schema?.tableName ?? stringFromConfig(decoratorConfig.tableName) ?? object.collection,
|
|
1328
|
+
tableStrategy: stringFromConfig(decoratorConfig.tableStrategy) ?? "cti",
|
|
1329
|
+
conflictColumns: arrayFromConfig(decoratorConfig.conflictColumns),
|
|
1330
|
+
tenantScope: effectiveTenantScope,
|
|
1331
|
+
decoratorConfig,
|
|
1332
|
+
schema: schema ? {
|
|
1333
|
+
tableName: schema.tableName,
|
|
1334
|
+
columns: schema.columns,
|
|
1335
|
+
indexes: schema.indexes ?? [],
|
|
1336
|
+
version: schema.version
|
|
1337
|
+
} : void 0,
|
|
1338
|
+
indexes: schema?.indexes ?? [],
|
|
1339
|
+
staticProperties: object.staticProperties,
|
|
1340
|
+
validationRules: object.validationRules,
|
|
1341
|
+
...includeFields && {
|
|
1342
|
+
fields: fieldDetails.map((field) => `${field.name}: ${field.type}`).join(", "),
|
|
1343
|
+
fieldDetails
|
|
1344
|
+
},
|
|
1345
|
+
...includeRelationships && relationshipDetails.length > 0 && {
|
|
1346
|
+
relationships: relationshipDetails.map((relationship) => `${relationship.field} -> ${relationship.relatedClass} (${relationship.type})`).join(", "),
|
|
1347
|
+
relationshipDetails
|
|
1348
|
+
},
|
|
1349
|
+
...includeMethods && methodDetails.length > 0 && {
|
|
1350
|
+
methods: methodDetails.map((method) => `${method.isAsync ? "async " : ""}${method.name}()`).join(", "),
|
|
1351
|
+
methodDetails
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
async function scanTenantScopes(projectPath) {
|
|
1356
|
+
const files = await listSourceFiles$1(projectPath);
|
|
1357
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
1358
|
+
await Promise.all(files.map(async (filePath) => {
|
|
1359
|
+
const content = await readFile(filePath, "utf-8");
|
|
1360
|
+
const classMatches = content.matchAll(/class\s+([A-Za-z_]\w*)\b/g);
|
|
1361
|
+
for (const match of classMatches) {
|
|
1362
|
+
const className = match[1];
|
|
1363
|
+
if (!className || match.index === void 0) continue;
|
|
1364
|
+
const prefix = content.slice(Math.max(0, match.index - 800), match.index);
|
|
1365
|
+
const tenantMatch = Array.from(prefix.matchAll(/@TenantScoped\s*\(([\s\S]*?)\)/g)).at(-1);
|
|
1366
|
+
if (!tenantMatch) continue;
|
|
1367
|
+
scopes.set(className, {
|
|
1368
|
+
source: "TenantScoped",
|
|
1369
|
+
...parseTenantScopedOptions(tenantMatch[1])
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
885
1372
|
}));
|
|
1373
|
+
return scopes;
|
|
886
1374
|
}
|
|
887
|
-
function
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1375
|
+
async function listSourceFiles$1(projectPath) {
|
|
1376
|
+
const files = [];
|
|
1377
|
+
async function visit(dir) {
|
|
1378
|
+
let entries;
|
|
1379
|
+
try {
|
|
1380
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1381
|
+
} catch {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
for (const entry of entries) {
|
|
1385
|
+
const fullPath = join(dir, entry.name);
|
|
1386
|
+
if (entry.isDirectory()) {
|
|
1387
|
+
if ([
|
|
1388
|
+
"node_modules",
|
|
1389
|
+
"dist",
|
|
1390
|
+
"build",
|
|
1391
|
+
".git",
|
|
1392
|
+
".smrt",
|
|
1393
|
+
"__tests__"
|
|
1394
|
+
].includes(entry.name) || entry.name.startsWith(".")) continue;
|
|
1395
|
+
await visit(fullPath);
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".spec.ts")) files.push(fullPath);
|
|
1399
|
+
}
|
|
892
1400
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
function findLocalAuthTenancy(sourceFiles, packages, projectPath) {
|
|
896
|
-
return sourceFiles.flatMap((file) => {
|
|
897
|
-
const owner = findOwningPackage(file.path, packages);
|
|
898
|
-
const pathSignal = /(auth|tenant|audit|session|rbac|user)/i.test(file.path);
|
|
899
|
-
const codeSignal = /\b(tenantId|tenant|role|permission|session|auditLog|apiKey)\b/.test(file.content);
|
|
900
|
-
if (!pathSignal || !codeSignal) return [];
|
|
901
|
-
if (owner.dependencies.has("@happyvertical/smrt-users") || owner.dependencies.has("@happyvertical/smrt-tenancy") || owner.dependencies.has("@happyvertical/smrt-profiles")) return [];
|
|
902
|
-
return [{
|
|
903
|
-
severity: "medium",
|
|
904
|
-
area: "auth-tenancy",
|
|
905
|
-
code: "local-auth-tenancy",
|
|
906
|
-
title: `${packageLabel(owner)} contains local auth/tenancy/audit logic`,
|
|
907
|
-
evidence: [{
|
|
908
|
-
filePath: relative(projectPath, file.path),
|
|
909
|
-
line: lineNumber(file.content, "tenant"),
|
|
910
|
-
detail: "Auth, tenancy, session, role, or audit terminology appears without smrt-users/smrt-tenancy/smrt-profiles dependencies."
|
|
911
|
-
}],
|
|
912
|
-
recommendation: "Add explicit adapters to smrt-users, smrt-tenancy, and profile/audit models or document why this local implementation is intentionally isolated.",
|
|
913
|
-
suggestedIssueTitle: `Review auth and tenancy adapters in ${packageLabel(owner)}`
|
|
914
|
-
}];
|
|
915
|
-
});
|
|
1401
|
+
await visit(projectPath);
|
|
1402
|
+
return files;
|
|
916
1403
|
}
|
|
917
|
-
function
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}],
|
|
930
|
-
recommendation: "Use @happyvertical/smrt-svelte and the SvelteKit shell pattern for downstream app UI unless this package is intentionally framework-agnostic.",
|
|
931
|
-
suggestedIssueTitle: `Check SMRT Svelte shell alignment for ${packageLabel(pkg)}`
|
|
932
|
-
}];
|
|
1404
|
+
function parseTenantScopedOptions(raw) {
|
|
1405
|
+
const mode = raw.match(/mode\s*:\s*['"`](required|optional)['"`]/)?.[1];
|
|
1406
|
+
const field = raw.match(/field\s*:\s*['"`]([A-Za-z_]\w*)['"`]/)?.[1];
|
|
1407
|
+
const allowSuperAdminBypass = raw.match(/allowSuperAdminBypass\s*:\s*(true|false)/)?.[1];
|
|
1408
|
+
const autoFilter = raw.match(/autoFilter\s*:\s*(true|false)/)?.[1];
|
|
1409
|
+
const autoPopulate = raw.match(/autoPopulate\s*:\s*(true|false)/)?.[1];
|
|
1410
|
+
return compactObject({
|
|
1411
|
+
mode: mode ?? "required",
|
|
1412
|
+
field: field ?? "tenantId",
|
|
1413
|
+
autoFilter: autoFilter === void 0 ? void 0 : autoFilter === "true",
|
|
1414
|
+
autoPopulate: autoPopulate === void 0 ? void 0 : autoPopulate === "true",
|
|
1415
|
+
allowSuperAdminBypass: allowSuperAdminBypass === void 0 ? void 0 : allowSuperAdminBypass === "true"
|
|
933
1416
|
});
|
|
934
1417
|
}
|
|
935
|
-
function
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
detail: "No .smrt/manifest.json or dist/manifest.json was visible during review."
|
|
947
|
-
}],
|
|
948
|
-
recommendation: "Run the package build/test manifest generation path and verify it uses the SMRT scanner/runtime manifest pipeline.",
|
|
949
|
-
suggestedIssueTitle: `Verify SMRT manifest generation for ${packageLabel(pkg)}`
|
|
950
|
-
}];
|
|
951
|
-
});
|
|
1418
|
+
function normalizeTenantScopedConfig(tenantScoped) {
|
|
1419
|
+
if (!tenantScoped) return void 0;
|
|
1420
|
+
const options = typeof tenantScoped === "object" && !Array.isArray(tenantScoped) ? tenantScoped : {};
|
|
1421
|
+
return {
|
|
1422
|
+
source: "smrt",
|
|
1423
|
+
mode: options.mode ?? "required",
|
|
1424
|
+
field: options.field ?? "tenantId",
|
|
1425
|
+
autoFilter: options.autoFilter ?? true,
|
|
1426
|
+
autoPopulate: options.autoPopulate ?? true,
|
|
1427
|
+
allowSuperAdminBypass: options.allowSuperAdminBypass ?? false
|
|
1428
|
+
};
|
|
952
1429
|
}
|
|
953
|
-
async function
|
|
954
|
-
|
|
1430
|
+
async function readPackageMetadata(projectPath) {
|
|
1431
|
+
const packageJsonPath = join(projectPath, "package.json");
|
|
1432
|
+
if (!await pathExists$1(packageJsonPath)) return {};
|
|
955
1433
|
try {
|
|
956
|
-
|
|
1434
|
+
const json = JSON.parse(await readFile(packageJsonPath, "utf-8"));
|
|
1435
|
+
return {
|
|
1436
|
+
name: typeof json.name === "string" ? json.name : void 0,
|
|
1437
|
+
version: typeof json.version === "string" ? json.version : void 0,
|
|
1438
|
+
json
|
|
1439
|
+
};
|
|
957
1440
|
} catch {
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
for (const entry of entries) {
|
|
961
|
-
const fullPath = join(dir, entry.name);
|
|
962
|
-
if (entry.isDirectory()) {
|
|
963
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
964
|
-
await visit(fullPath, onFile);
|
|
965
|
-
continue;
|
|
966
|
-
}
|
|
967
|
-
if (entry.isFile()) await onFile(fullPath, entry.name);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
function collectDependencies(packageJson) {
|
|
971
|
-
const dependencies = /* @__PURE__ */ new Set();
|
|
972
|
-
for (const sectionName of DEPENDENCY_SECTIONS) {
|
|
973
|
-
const section = packageJson[sectionName];
|
|
974
|
-
if (!isRecord$1(section)) continue;
|
|
975
|
-
for (const dependencyName of Object.keys(section)) dependencies.add(dependencyName);
|
|
976
|
-
}
|
|
977
|
-
return dependencies;
|
|
978
|
-
}
|
|
979
|
-
function extractImports(content) {
|
|
980
|
-
const imports = /* @__PURE__ */ new Set();
|
|
981
|
-
for (const pattern of [
|
|
982
|
-
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g,
|
|
983
|
-
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
984
|
-
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
985
|
-
]) for (const match of content.matchAll(pattern)) {
|
|
986
|
-
const imported = normalizePackageImport(match[1]);
|
|
987
|
-
if (imported) imports.add(imported);
|
|
1441
|
+
return {};
|
|
988
1442
|
}
|
|
989
|
-
return Array.from(imports);
|
|
990
1443
|
}
|
|
991
|
-
function
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1444
|
+
async function pathExists$1(path) {
|
|
1445
|
+
try {
|
|
1446
|
+
await access(path);
|
|
1447
|
+
return true;
|
|
1448
|
+
} catch {
|
|
1449
|
+
return false;
|
|
996
1450
|
}
|
|
997
|
-
return value.split("/")[0];
|
|
998
1451
|
}
|
|
999
|
-
function
|
|
1000
|
-
|
|
1001
|
-
return packages.filter((pkg) => normalized === pkg.directory || normalized.startsWith(`${pkg.directory}${sep}`)).sort((left, right) => right.directory.length - left.directory.length)[0] ?? packages[0];
|
|
1452
|
+
function isManifestLike(value) {
|
|
1453
|
+
return !!value && typeof value === "object" && !!value.objects && typeof value.objects === "object";
|
|
1002
1454
|
}
|
|
1003
|
-
function
|
|
1004
|
-
const declaredHappyVerticalDependencies = Array.from(pkg.dependencies).filter((dependency) => dependency.startsWith(HAPPYVERTICAL_PACKAGE_PREFIX)).sort();
|
|
1005
|
-
const importedHappyVerticalPackages = Array.from(pkg.imports.keys()).sort();
|
|
1006
|
-
const ownName = typeof pkg.json.name === "string" ? pkg.json.name : void 0;
|
|
1455
|
+
function scanErrorToDiagnostic(error) {
|
|
1007
1456
|
return {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
importedHappyVerticalPackages,
|
|
1014
|
-
missingHappyVerticalDependencies: importedHappyVerticalPackages.filter((name) => name !== ownName && !pkg.dependencies.has(name)),
|
|
1015
|
-
hasSvelteKit: pkg.dependencies.has("@sveltejs/kit"),
|
|
1016
|
-
hasSmrtSvelte: pkg.dependencies.has("@happyvertical/smrt-svelte")
|
|
1457
|
+
severity: error.severity,
|
|
1458
|
+
message: error.message,
|
|
1459
|
+
filePath: error.filePath,
|
|
1460
|
+
line: error.line,
|
|
1461
|
+
column: error.column
|
|
1017
1462
|
};
|
|
1018
1463
|
}
|
|
1019
|
-
function
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}, {
|
|
1024
|
-
high: 0,
|
|
1025
|
-
medium: 0,
|
|
1026
|
-
low: 0
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
function limitFindings(findings, maxFindings) {
|
|
1030
|
-
const sorted = findings.sort((left, right) => severityRank(left.severity) - severityRank(right.severity) || left.area.localeCompare(right.area) || left.title.localeCompare(right.title));
|
|
1031
|
-
return maxFindings && maxFindings > 0 ? sorted.slice(0, maxFindings) : sorted;
|
|
1032
|
-
}
|
|
1033
|
-
function severityRank(severity) {
|
|
1034
|
-
return severity === "high" ? 0 : severity === "medium" ? 1 : 2;
|
|
1035
|
-
}
|
|
1036
|
-
function packageLabel(pkg) {
|
|
1037
|
-
return typeof pkg.json.name === "string" ? pkg.json.name : pkg.relativePath;
|
|
1464
|
+
function sanitizePath$1(projectPath, filePath) {
|
|
1465
|
+
const relativePath = relative(projectPath, isAbsolute(filePath) ? filePath : resolve(projectPath, filePath));
|
|
1466
|
+
if (!relativePath.startsWith("..")) return relativePath || filePath;
|
|
1467
|
+
return filePath;
|
|
1038
1468
|
}
|
|
1039
|
-
function
|
|
1040
|
-
|
|
1041
|
-
if (index < 0) return void 0;
|
|
1042
|
-
return content.slice(0, index).split("\n").length;
|
|
1469
|
+
function stringFromConfig(value) {
|
|
1470
|
+
return typeof value === "string" ? value : void 0;
|
|
1043
1471
|
}
|
|
1044
|
-
function
|
|
1045
|
-
return
|
|
1472
|
+
function arrayFromConfig(value) {
|
|
1473
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
1046
1474
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
await access(path);
|
|
1050
|
-
return true;
|
|
1051
|
-
} catch {
|
|
1052
|
-
return false;
|
|
1053
|
-
}
|
|
1475
|
+
function compactObject(value) {
|
|
1476
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
1054
1477
|
}
|
|
1055
|
-
function
|
|
1056
|
-
return
|
|
1478
|
+
function messageFromError(error) {
|
|
1479
|
+
return error instanceof Error ? error.message : String(error);
|
|
1057
1480
|
}
|
|
1058
1481
|
//#endregion
|
|
1059
|
-
//#region src/tools/
|
|
1482
|
+
//#region src/tools/review-smrt-project.ts
|
|
1060
1483
|
/**
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* Resolution order per call:
|
|
1065
|
-
* 1. explicit `dbUrl`/`dbType` tool arguments
|
|
1066
|
-
* 2. `SMRT_DEV_DB_URL` environment variable
|
|
1067
|
-
* 3. the project's cosmiconfig CLI section (`getPackageConfig('cli', ...)`
|
|
1068
|
-
* from `@happyvertical/smrt-config`) → `database.{type,url}`
|
|
1069
|
-
*
|
|
1070
|
-
* No configured connection → `db: null` with `source: 'none'`; callers return
|
|
1071
|
-
* a successful static-only envelope. A connection is always opened lazily per
|
|
1072
|
-
* call and closed in the caller's `finally` — nothing is cached across calls
|
|
1073
|
-
* and the server never holds a database handle.
|
|
1074
|
-
*
|
|
1075
|
-
* Sensitive handling: connection strings are never logged or echoed. Every
|
|
1076
|
-
* surfaced URL passes through {@link redactConnectionString}; driver errors
|
|
1077
|
-
* are surfaced only through {@link safeErrorMessage}, which strips anything
|
|
1078
|
-
* that looks like a credential-bearing URL.
|
|
1484
|
+
* Ecosystem-aware downstream project review.
|
|
1485
|
+
* Advisory only: scans manifests and source files, then reports alignment risks.
|
|
1079
1486
|
*/
|
|
1080
|
-
var
|
|
1081
|
-
|
|
1082
|
-
"
|
|
1083
|
-
"
|
|
1487
|
+
var SOURCE_EXTENSIONS = /\.(tsx?|jsx?|svelte)$/;
|
|
1488
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
1489
|
+
"node_modules",
|
|
1490
|
+
"dist",
|
|
1491
|
+
"build",
|
|
1492
|
+
".git",
|
|
1493
|
+
".smrt",
|
|
1494
|
+
".svelte-kit",
|
|
1495
|
+
"coverage"
|
|
1496
|
+
]);
|
|
1497
|
+
var DEPENDENCY_SECTIONS = [
|
|
1498
|
+
"dependencies",
|
|
1499
|
+
"devDependencies",
|
|
1500
|
+
"peerDependencies",
|
|
1501
|
+
"optionalDependencies"
|
|
1084
1502
|
];
|
|
1085
|
-
|
|
1086
|
-
|
|
1503
|
+
var SMRT_PACKAGE_PREFIX = "@happyvertical/smrt-";
|
|
1504
|
+
var HAPPYVERTICAL_PACKAGE_PREFIX = "@happyvertical/";
|
|
1505
|
+
async function reviewSmrtProject(args) {
|
|
1506
|
+
const projectPath = resolve(args.directory ?? args.rootDir ?? process.cwd());
|
|
1507
|
+
if (!await pathExists(projectPath)) return JSON.stringify({
|
|
1508
|
+
projectPath,
|
|
1509
|
+
packageCount: 0,
|
|
1510
|
+
packages: [],
|
|
1511
|
+
findings: [],
|
|
1512
|
+
summary: {
|
|
1513
|
+
high: 0,
|
|
1514
|
+
medium: 0,
|
|
1515
|
+
low: 0
|
|
1516
|
+
},
|
|
1517
|
+
diagnostics: [{
|
|
1518
|
+
severity: "warning",
|
|
1519
|
+
message: `Project directory does not exist: ${projectPath}`
|
|
1520
|
+
}]
|
|
1521
|
+
}, null, 2);
|
|
1522
|
+
const packageContexts = await buildPackageContexts(projectPath);
|
|
1523
|
+
const sourceFiles = await listSourceFiles(projectPath, packageContexts);
|
|
1524
|
+
await attachSourceFiles(sourceFiles, packageContexts, projectPath);
|
|
1525
|
+
const findings = limitFindings([
|
|
1526
|
+
...findMissingHappyVerticalDependencies(packageContexts),
|
|
1527
|
+
...findCustomManifestGeneration(sourceFiles, projectPath),
|
|
1528
|
+
...findDirectStorageBypasses(sourceFiles, packageContexts, projectPath),
|
|
1529
|
+
...findCustomHttpShells(sourceFiles, packageContexts, projectPath),
|
|
1530
|
+
...findLocalAuthTenancy(sourceFiles, packageContexts, projectPath),
|
|
1531
|
+
...findUiShellDrift(packageContexts, projectPath),
|
|
1532
|
+
...findMissingManifestArtifacts(sourceFiles, packageContexts, projectPath)
|
|
1533
|
+
], args.maxFindings);
|
|
1534
|
+
const packages = packageContexts.map(packageInventory).sort((left, right) => left.path.localeCompare(right.path));
|
|
1535
|
+
const summary = summarizeFindings(findings);
|
|
1536
|
+
return JSON.stringify({
|
|
1537
|
+
projectPath,
|
|
1538
|
+
packageCount: packages.length,
|
|
1539
|
+
packages,
|
|
1540
|
+
findings: args.includeSourceEvidence === false ? findings.map(({ evidence: _evidence, ...finding }) => finding) : findings,
|
|
1541
|
+
summary,
|
|
1542
|
+
referenceChecks: [
|
|
1543
|
+
"Prefer SMRT scanner/runtime manifests over custom manifest builders.",
|
|
1544
|
+
"Prefer SvelteKit plus @happyvertical/smrt-svelte for app shells.",
|
|
1545
|
+
"Prefer @happyvertical/sql and @happyvertical/files/assets/content over direct durable node:fs storage.",
|
|
1546
|
+
"Prefer smrt-users, smrt-tenancy, and profile/audit packages over local static auth seams."
|
|
1547
|
+
],
|
|
1548
|
+
suggestedFollowUpIssues: findings.map((finding) => ({
|
|
1549
|
+
title: finding.suggestedIssueTitle,
|
|
1550
|
+
severity: finding.severity,
|
|
1551
|
+
area: finding.area
|
|
1552
|
+
}))
|
|
1553
|
+
}, null, 2);
|
|
1087
1554
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1555
|
+
async function buildPackageContexts(projectPath) {
|
|
1556
|
+
const packageJsonPaths = await listPackageJsonFiles(projectPath);
|
|
1557
|
+
const contexts = await Promise.all(packageJsonPaths.map(async (packageJsonPath) => {
|
|
1558
|
+
const json = JSON.parse(await readFile(packageJsonPath, "utf-8"));
|
|
1559
|
+
const directory = dirname(packageJsonPath);
|
|
1560
|
+
return {
|
|
1561
|
+
directory,
|
|
1562
|
+
relativePath: relative(projectPath, directory) || ".",
|
|
1563
|
+
packageJsonPath,
|
|
1564
|
+
json,
|
|
1565
|
+
dependencies: collectDependencies(json),
|
|
1566
|
+
scripts: isRecord$1(json.scripts) ? json.scripts : {},
|
|
1567
|
+
imports: /* @__PURE__ */ new Map(),
|
|
1568
|
+
sourceFiles: []
|
|
1569
|
+
};
|
|
1570
|
+
}));
|
|
1571
|
+
if (contexts.length === 0) contexts.push({
|
|
1572
|
+
directory: projectPath,
|
|
1573
|
+
relativePath: ".",
|
|
1574
|
+
packageJsonPath: join(projectPath, "package.json"),
|
|
1575
|
+
json: {},
|
|
1576
|
+
dependencies: /* @__PURE__ */ new Set(),
|
|
1577
|
+
scripts: {},
|
|
1578
|
+
imports: /* @__PURE__ */ new Map(),
|
|
1579
|
+
sourceFiles: []
|
|
1580
|
+
});
|
|
1581
|
+
return contexts.sort((left, right) => left.directory.length - right.directory.length);
|
|
1106
1582
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
* Redact a connection string so it can be shown to an agent without leaking
|
|
1114
|
-
* credentials. Mirrors the CLI's `redactConnectionString` (which is CLI
|
|
1115
|
-
* private); patterned identically so dev-mcp never depends on the CLI.
|
|
1116
|
-
*
|
|
1117
|
-
* Query-parameter masking normalizes each key (lowercase, `_`/`-` stripped),
|
|
1118
|
-
* so camelCase forms such as Turso/libsql's `?authToken=` mask exactly like
|
|
1119
|
-
* their snake_case forms. A final regex pass also masks `key=value` pairs
|
|
1120
|
-
* embedded in free text (driver error messages often quote the URL); it treats
|
|
1121
|
-
* the start of the string, `?`, `&`, `,`, `(`, and whitespace as the
|
|
1122
|
-
* preceding boundary.
|
|
1123
|
-
*/
|
|
1124
|
-
function redactConnectionString(value) {
|
|
1125
|
-
let redacted = value;
|
|
1126
|
-
try {
|
|
1127
|
-
const url = new URL(value);
|
|
1128
|
-
if (url.password) url.password = "***";
|
|
1129
|
-
for (const key of [...url.searchParams.keys()]) if (SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key))) url.searchParams.set(key, "***");
|
|
1130
|
-
redacted = url.toString();
|
|
1131
|
-
} catch {
|
|
1132
|
-
redacted = value.replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)(?:[^@\s]|@(?=[^@\s]*@))+(@)/gi, "$1***$2");
|
|
1133
|
-
}
|
|
1134
|
-
return redacted.replace(/((?:^|[?&,(\s])([a-z][a-z0-9_-]{0,30})=)([^&,\s)]+)/gi, (match, prefix, key) => SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key)) ? `${prefix}***` : match);
|
|
1583
|
+
async function listPackageJsonFiles(projectPath) {
|
|
1584
|
+
const files = [];
|
|
1585
|
+
await visit(projectPath, async (filePath, entryName) => {
|
|
1586
|
+
if (entryName === "package.json") files.push(filePath);
|
|
1587
|
+
});
|
|
1588
|
+
return files;
|
|
1135
1589
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1590
|
+
async function listSourceFiles(projectPath, packages) {
|
|
1591
|
+
const files = [];
|
|
1592
|
+
await visit(projectPath, async (filePath, entryName) => {
|
|
1593
|
+
if (!SOURCE_EXTENSIONS.test(entryName)) return;
|
|
1594
|
+
if (entryName.endsWith(".d.ts") || entryName.endsWith(".test.ts") || entryName.endsWith(".spec.ts")) return;
|
|
1595
|
+
const packageDir = findOwningPackage(filePath, packages).directory;
|
|
1596
|
+
const content = await readFile(filePath, "utf-8");
|
|
1597
|
+
files.push({
|
|
1598
|
+
path: filePath,
|
|
1599
|
+
packageDir,
|
|
1600
|
+
content
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
return files;
|
|
1142
1604
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1605
|
+
async function attachSourceFiles(sourceFiles, packages, projectPath) {
|
|
1606
|
+
for (const sourceFile of sourceFiles) {
|
|
1607
|
+
const owner = packages.find((pkg) => pkg.directory === sourceFile.packageDir);
|
|
1608
|
+
if (!owner) continue;
|
|
1609
|
+
owner.sourceFiles.push(sourceFile);
|
|
1610
|
+
for (const importedPackage of extractImports(sourceFile.content)) {
|
|
1611
|
+
if (!importedPackage.startsWith(HAPPYVERTICAL_PACKAGE_PREFIX)) continue;
|
|
1612
|
+
const evidence = {
|
|
1613
|
+
filePath: relative(projectPath, sourceFile.path),
|
|
1614
|
+
line: lineNumber(sourceFile.content, importedPackage),
|
|
1615
|
+
detail: `Imports ${importedPackage}`
|
|
1616
|
+
};
|
|
1617
|
+
const existing = owner.imports.get(importedPackage) ?? [];
|
|
1618
|
+
existing.push(evidence);
|
|
1619
|
+
owner.imports.set(importedPackage, existing);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
function findMissingHappyVerticalDependencies(packages) {
|
|
1624
|
+
return packages.flatMap((pkg) => {
|
|
1625
|
+
const ownName = typeof pkg.json.name === "string" ? pkg.json.name : void 0;
|
|
1626
|
+
const missing = Array.from(pkg.imports.keys()).filter((importedPackage) => importedPackage !== ownName && !pkg.dependencies.has(importedPackage));
|
|
1627
|
+
if (missing.length === 0) return [];
|
|
1628
|
+
return [{
|
|
1629
|
+
severity: missing.some((name) => name.startsWith(SMRT_PACKAGE_PREFIX)) ? "high" : "medium",
|
|
1630
|
+
area: "dependencies",
|
|
1631
|
+
code: "missing-happyvertical-dependencies",
|
|
1632
|
+
title: `${packageLabel(pkg)} imports HappyVertical packages that package.json does not declare`,
|
|
1633
|
+
evidence: missing.flatMap((name) => pkg.imports.get(name) ?? []),
|
|
1634
|
+
recommendation: "Declare every imported @happyvertical package in the owning package manifest so downstream installs and generated knowledge stay reproducible.",
|
|
1635
|
+
suggestedIssueTitle: `Declare missing HappyVertical dependencies in ${packageLabel(pkg)}`
|
|
1636
|
+
}];
|
|
1637
|
+
});
|
|
1152
1638
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1639
|
+
function findCustomManifestGeneration(sourceFiles, projectPath) {
|
|
1640
|
+
return sourceFiles.filter((file) => /manifest\.json/.test(file.content) && /objects\s*:/.test(file.content) && /\b(writeFile|writeFileSync)\b/.test(file.content) && !/@happyvertical\/smrt-scanner/.test(file.content)).map((file) => ({
|
|
1641
|
+
severity: "high",
|
|
1642
|
+
area: "manifest",
|
|
1643
|
+
code: "custom-object-manifest-generation",
|
|
1644
|
+
title: "Custom SMRT object manifest generation detected",
|
|
1645
|
+
evidence: [{
|
|
1646
|
+
filePath: relative(projectPath, file.path),
|
|
1647
|
+
line: lineNumber(file.content, "manifest.json"),
|
|
1648
|
+
detail: "Writes a manifest.json object inventory outside the SMRT scanner/runtime path."
|
|
1649
|
+
}],
|
|
1650
|
+
recommendation: "Use the SMRT scanner/runtime manifest path so defaults, relationships, schemas, tenant fields, and cross-package references match framework behavior.",
|
|
1651
|
+
suggestedIssueTitle: "Replace custom object manifest generation with SMRT scanner/runtime manifest generation"
|
|
1652
|
+
}));
|
|
1159
1653
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1654
|
+
function findDirectStorageBypasses(sourceFiles, packages, projectPath) {
|
|
1655
|
+
return sourceFiles.flatMap((file) => {
|
|
1656
|
+
const owner = findOwningPackage(file.path, packages);
|
|
1657
|
+
const usesFs = /from\s+['"](?:node:fs|node:fs\/promises|fs|fs\/promises)['"]|require\(['"](?:node:fs|node:fs\/promises|fs|fs\/promises)['"]\)/.test(file.content);
|
|
1658
|
+
const writesDurableData = /\b(writeFile|appendFile|mkdir|rm|rename)\b/.test(file.content) && /(\.json|data|storage|persist|cache|db)/i.test(file.content);
|
|
1659
|
+
const usesDirectSql = /from\s+['"](?:better-sqlite3|sqlite3|pg|mysql2?|knex|drizzle-orm)['"]/.test(file.content);
|
|
1660
|
+
if ((!usesFs || !writesDurableData) && !usesDirectSql) return [];
|
|
1661
|
+
if (owner.dependencies.has("@happyvertical/sql") || owner.dependencies.has("@happyvertical/files") || owner.dependencies.has("@happyvertical/smrt-assets") || owner.dependencies.has("@happyvertical/smrt-content")) return [];
|
|
1662
|
+
return [{
|
|
1663
|
+
severity: "medium",
|
|
1664
|
+
area: "storage",
|
|
1665
|
+
code: "direct-storage-bypass",
|
|
1666
|
+
title: `${packageLabel(owner)} appears to bypass HappyVertical storage packages`,
|
|
1667
|
+
evidence: [{
|
|
1668
|
+
filePath: relative(projectPath, file.path),
|
|
1669
|
+
line: lineNumber(file.content, usesDirectSql ? "sqlite" : "writeFile"),
|
|
1670
|
+
detail: usesDirectSql ? "Imports a direct SQL/storage library without @happyvertical/sql." : "Uses node:fs-style durable writes without @happyvertical/files/assets/content."
|
|
1671
|
+
}],
|
|
1672
|
+
recommendation: "Route durable data through @happyvertical/sql, @happyvertical/files, SMRT assets, or SMRT content unless this is explicitly build-only tooling.",
|
|
1673
|
+
suggestedIssueTitle: `Review direct storage usage in ${packageLabel(owner)}`
|
|
1674
|
+
}];
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
function findCustomHttpShells(sourceFiles, packages, projectPath) {
|
|
1678
|
+
const evidenceByPackage = /* @__PURE__ */ new Map();
|
|
1679
|
+
for (const pkg of packages) {
|
|
1680
|
+
if (pkg.dependencies.has("@sveltejs/kit")) continue;
|
|
1681
|
+
const routerDependencies = [
|
|
1682
|
+
"express",
|
|
1683
|
+
"fastify",
|
|
1684
|
+
"hono",
|
|
1685
|
+
"koa"
|
|
1686
|
+
].filter((dep) => pkg.dependencies.has(dep));
|
|
1687
|
+
if (routerDependencies.length === 0) continue;
|
|
1688
|
+
appendEvidence(evidenceByPackage, pkg, {
|
|
1689
|
+
filePath: relative(projectPath, pkg.packageJsonPath),
|
|
1690
|
+
detail: `Declares custom router package(s): ${routerDependencies.join(", ")}.`
|
|
1691
|
+
});
|
|
1194
1692
|
}
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
source: "config",
|
|
1205
|
-
displayUrl: redactConnectionString(configUrl),
|
|
1206
|
-
databaseType
|
|
1207
|
-
};
|
|
1693
|
+
for (const file of sourceFiles) {
|
|
1694
|
+
const owner = findOwningPackage(file.path, packages);
|
|
1695
|
+
if (owner.dependencies.has("@sveltejs/kit")) continue;
|
|
1696
|
+
if (!/from\s+['"](?:node:http|http|node:https|https)['"]|createServer\s*\(/.test(file.content)) continue;
|
|
1697
|
+
appendEvidence(evidenceByPackage, owner, {
|
|
1698
|
+
filePath: relative(projectPath, file.path),
|
|
1699
|
+
line: lineNumber(file.content, "createServer"),
|
|
1700
|
+
detail: "Custom HTTP routing found without the SvelteKit/SMRT app-shell dependency pattern."
|
|
1701
|
+
});
|
|
1208
1702
|
}
|
|
1209
|
-
return {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1703
|
+
return Array.from(evidenceByPackage.entries()).map(([owner, evidence]) => ({
|
|
1704
|
+
severity: "medium",
|
|
1705
|
+
area: "api-shell",
|
|
1706
|
+
code: "custom-http-shell",
|
|
1707
|
+
title: `${packageLabel(owner)} uses a custom HTTP shell`,
|
|
1708
|
+
evidence,
|
|
1709
|
+
recommendation: "Compare the app shell against the Anytown/Ergot SvelteKit + SMRT shell pattern before adding custom HTTP infrastructure.",
|
|
1710
|
+
suggestedIssueTitle: `Align ${packageLabel(owner)} app shell with SMRT/SvelteKit conventions`
|
|
1711
|
+
}));
|
|
1215
1712
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
return {};
|
|
1222
|
-
} catch {
|
|
1223
|
-
return {};
|
|
1713
|
+
function appendEvidence(evidenceByPackage, pkg, evidence) {
|
|
1714
|
+
const existing = evidenceByPackage.get(pkg);
|
|
1715
|
+
if (existing) {
|
|
1716
|
+
existing.push(evidence);
|
|
1717
|
+
return;
|
|
1224
1718
|
}
|
|
1719
|
+
evidenceByPackage.set(pkg, [evidence]);
|
|
1225
1720
|
}
|
|
1226
|
-
|
|
1227
|
-
return
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1721
|
+
function findLocalAuthTenancy(sourceFiles, packages, projectPath) {
|
|
1722
|
+
return sourceFiles.flatMap((file) => {
|
|
1723
|
+
const owner = findOwningPackage(file.path, packages);
|
|
1724
|
+
const pathSignal = /(auth|tenant|audit|session|rbac|user)/i.test(file.path);
|
|
1725
|
+
const codeSignal = /\b(tenantId|tenant|role|permission|session|auditLog|apiKey)\b/.test(file.content);
|
|
1726
|
+
if (!pathSignal || !codeSignal) return [];
|
|
1727
|
+
if (owner.dependencies.has("@happyvertical/smrt-users") || owner.dependencies.has("@happyvertical/smrt-tenancy") || owner.dependencies.has("@happyvertical/smrt-profiles")) return [];
|
|
1728
|
+
return [{
|
|
1729
|
+
severity: "medium",
|
|
1730
|
+
area: "auth-tenancy",
|
|
1731
|
+
code: "local-auth-tenancy",
|
|
1732
|
+
title: `${packageLabel(owner)} contains local auth/tenancy/audit logic`,
|
|
1733
|
+
evidence: [{
|
|
1734
|
+
filePath: relative(projectPath, file.path),
|
|
1735
|
+
line: lineNumber(file.content, "tenant"),
|
|
1736
|
+
detail: "Auth, tenancy, session, role, or audit terminology appears without smrt-users/smrt-tenancy/smrt-profiles dependencies."
|
|
1737
|
+
}],
|
|
1738
|
+
recommendation: "Add explicit adapters to smrt-users, smrt-tenancy, and profile/audit models or document why this local implementation is intentionally isolated.",
|
|
1739
|
+
suggestedIssueTitle: `Review auth and tenancy adapters in ${packageLabel(owner)}`
|
|
1740
|
+
}];
|
|
1741
|
+
});
|
|
1241
1742
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
* conflate runtime facts with declared/manifest facts.
|
|
1260
|
-
* - **Fail-safe.** A connect/read error becomes a diagnostic envelope; it must
|
|
1261
|
-
* never reach the MCP transport and never includes raw driver text or URLs.
|
|
1262
|
-
*/
|
|
1263
|
-
/** Provenance labels separating runtime facts from static/declared facts. */
|
|
1264
|
-
var RUNTIME_PROVENANCE = "runtime (live DB)";
|
|
1265
|
-
var STATIC_PROVENANCE = "static";
|
|
1266
|
-
/**
|
|
1267
|
-
* Serialize resolve → read → close per connection target so overlapping tool
|
|
1268
|
-
* calls never close a shared cached handle out from under each other.
|
|
1269
|
-
*
|
|
1270
|
-
* `@happyvertical/sql`'s `getDatabase` returns a cached handle per URL (no
|
|
1271
|
-
* opt-out in its public API). `closeRuntimeConnection` only calls the handle's
|
|
1272
|
-
* own `close`/`end`, but the SDK wraps those so a close also evicts the handle
|
|
1273
|
-
* from its connection cache. Two concurrent diagnostics calls resolving the
|
|
1274
|
-
* same URL would therefore share one handle, with the first finisher closing
|
|
1275
|
-
* it mid-read for the second. A per-key promise chain keeps each call's
|
|
1276
|
-
* lifecycle private: every call resolves its own view of the connection,
|
|
1277
|
-
* performs its read, and only then closes — the next queued call re-resolves
|
|
1278
|
-
* a fresh handle.
|
|
1279
|
-
*
|
|
1280
|
-
* The key is a digest of the resolved target, never the raw URL, so a
|
|
1281
|
-
* credential-bearing connection string is not retained in this map.
|
|
1282
|
-
*/
|
|
1283
|
-
var runtimeReadQueues = /* @__PURE__ */ new Map();
|
|
1284
|
-
function connectionQueueKey(args) {
|
|
1285
|
-
const target = args.dbUrl?.trim() || process.env.SMRT_DEV_DB_URL?.trim() || "cli.config";
|
|
1286
|
-
return createHash("sha256").update(target).digest("hex");
|
|
1743
|
+
function findUiShellDrift(packages, projectPath) {
|
|
1744
|
+
return packages.flatMap((pkg) => {
|
|
1745
|
+
if (!(pkg.sourceFiles.some((file) => file.path.endsWith(".svelte")) || /(?:web|ui|app|site|frontend)/i.test(packageLabel(pkg)) || pkg.dependencies.has("svelte") || pkg.dependencies.has("vite"))) return [];
|
|
1746
|
+
if (pkg.dependencies.has("@happyvertical/smrt-svelte")) return [];
|
|
1747
|
+
return [{
|
|
1748
|
+
severity: "low",
|
|
1749
|
+
area: "ui-shell",
|
|
1750
|
+
code: "missing-smrt-svelte-shell",
|
|
1751
|
+
title: `${packageLabel(pkg)} looks like UI work without @happyvertical/smrt-svelte`,
|
|
1752
|
+
evidence: [{
|
|
1753
|
+
filePath: relative(projectPath, pkg.packageJsonPath),
|
|
1754
|
+
detail: "UI-facing package does not declare @happyvertical/smrt-svelte."
|
|
1755
|
+
}],
|
|
1756
|
+
recommendation: "Use @happyvertical/smrt-svelte and the SvelteKit shell pattern for downstream app UI unless this package is intentionally framework-agnostic.",
|
|
1757
|
+
suggestedIssueTitle: `Check SMRT Svelte shell alignment for ${packageLabel(pkg)}`
|
|
1758
|
+
}];
|
|
1759
|
+
});
|
|
1287
1760
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1761
|
+
function findMissingManifestArtifacts(sourceFiles, packages, projectPath) {
|
|
1762
|
+
return packages.flatMap((pkg) => {
|
|
1763
|
+
if (!pkg.sourceFiles.some((file) => /@smrt\s*\(/.test(file.content))) return [];
|
|
1764
|
+
if (sourceFiles.some((file) => file.packageDir === pkg.directory && /@happyvertical\/smrt-scanner/.test(file.content)) || pathExistsSyncHint(join(pkg.directory, ".smrt", "manifest.json")) || pathExistsSyncHint(join(pkg.directory, "dist", "manifest.json"))) return [];
|
|
1765
|
+
return [{
|
|
1766
|
+
severity: "low",
|
|
1767
|
+
area: "manifest",
|
|
1768
|
+
code: "missing-generated-manifest-artifact",
|
|
1769
|
+
title: `${packageLabel(pkg)} has @smrt objects but no generated manifest artifact was found`,
|
|
1770
|
+
evidence: [{
|
|
1771
|
+
filePath: relative(projectPath, pkg.packageJsonPath),
|
|
1772
|
+
detail: "No .smrt/manifest.json or dist/manifest.json was visible during review."
|
|
1773
|
+
}],
|
|
1774
|
+
recommendation: "Run the package build/test manifest generation path and verify it uses the SMRT scanner/runtime manifest pipeline.",
|
|
1775
|
+
suggestedIssueTitle: `Verify SMRT manifest generation for ${packageLabel(pkg)}`
|
|
1776
|
+
}];
|
|
1294
1777
|
});
|
|
1295
|
-
return run;
|
|
1296
|
-
}
|
|
1297
|
-
/**
|
|
1298
|
-
* Run one read against the optional runtime connection, mapping every outcome
|
|
1299
|
-
* to a successful MCP envelope:
|
|
1300
|
-
*
|
|
1301
|
-
* - no connection configured → static-only envelope (`connected: false`)
|
|
1302
|
-
* - connect failure → static envelope with a safe diagnostic
|
|
1303
|
-
* - read failure → connected envelope with a safe diagnostic
|
|
1304
|
-
* - success → live result under `provenance: 'runtime (live DB)'`; a
|
|
1305
|
-
* category-unavailable reader result keeps its `available: false` data and
|
|
1306
|
-
* surfaces its message as a diagnostic
|
|
1307
|
-
*/
|
|
1308
|
-
async function withRuntimeConnection(args, read, staticHint) {
|
|
1309
|
-
return enqueueRuntimeRead(connectionQueueKey(args), () => runWithRuntimeConnection(args, read, staticHint));
|
|
1310
1778
|
}
|
|
1311
|
-
async function
|
|
1312
|
-
let
|
|
1779
|
+
async function visit(dir, onFile) {
|
|
1780
|
+
let entries;
|
|
1313
1781
|
try {
|
|
1314
|
-
|
|
1315
|
-
} catch
|
|
1316
|
-
return
|
|
1317
|
-
ok: true,
|
|
1318
|
-
coverage: null,
|
|
1319
|
-
diagnostics: [{
|
|
1320
|
-
severity: "warning",
|
|
1321
|
-
code: "runtime_connection_error",
|
|
1322
|
-
message: safeErrorMessage(error)
|
|
1323
|
-
}],
|
|
1324
|
-
data: {
|
|
1325
|
-
provenance: STATIC_PROVENANCE,
|
|
1326
|
-
connected: false
|
|
1327
|
-
}
|
|
1328
|
-
};
|
|
1782
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1783
|
+
} catch {
|
|
1784
|
+
return;
|
|
1329
1785
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
message: `No runtime dev database configured (set SMRT_DEV_DB_URL or cli.database); returning static-only result: ${staticHint}. Static tools are unaffected.`
|
|
1337
|
-
}],
|
|
1338
|
-
data: {
|
|
1339
|
-
provenance: STATIC_PROVENANCE,
|
|
1340
|
-
connected: false
|
|
1786
|
+
for (const entry of entries) {
|
|
1787
|
+
const fullPath = join(dir, entry.name);
|
|
1788
|
+
if (entry.isDirectory()) {
|
|
1789
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
1790
|
+
await visit(fullPath, onFile);
|
|
1791
|
+
continue;
|
|
1341
1792
|
}
|
|
1342
|
-
|
|
1343
|
-
const { db, source, displayUrl, databaseType } = resolved;
|
|
1344
|
-
try {
|
|
1345
|
-
const { data, diagnostics } = await read(db);
|
|
1346
|
-
return {
|
|
1347
|
-
ok: true,
|
|
1348
|
-
coverage: null,
|
|
1349
|
-
diagnostics,
|
|
1350
|
-
data: {
|
|
1351
|
-
provenance: RUNTIME_PROVENANCE,
|
|
1352
|
-
connected: true,
|
|
1353
|
-
connectionSource: source,
|
|
1354
|
-
databaseType,
|
|
1355
|
-
displayUrl,
|
|
1356
|
-
...data
|
|
1357
|
-
}
|
|
1358
|
-
};
|
|
1359
|
-
} catch (error) {
|
|
1360
|
-
return {
|
|
1361
|
-
ok: true,
|
|
1362
|
-
coverage: null,
|
|
1363
|
-
diagnostics: [{
|
|
1364
|
-
severity: "warning",
|
|
1365
|
-
code: "runtime_read_error",
|
|
1366
|
-
message: safeErrorMessage(error)
|
|
1367
|
-
}],
|
|
1368
|
-
data: {
|
|
1369
|
-
provenance: RUNTIME_PROVENANCE,
|
|
1370
|
-
connected: true,
|
|
1371
|
-
connectionSource: source,
|
|
1372
|
-
databaseType,
|
|
1373
|
-
displayUrl
|
|
1374
|
-
}
|
|
1375
|
-
};
|
|
1376
|
-
} finally {
|
|
1377
|
-
await closeRuntimeConnection(db);
|
|
1793
|
+
if (entry.isFile()) await onFile(fullPath, entry.name);
|
|
1378
1794
|
}
|
|
1379
1795
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
function redactStrings(value) {
|
|
1387
|
-
if (typeof value === "string") return redactConnectionString(value);
|
|
1388
|
-
if (Array.isArray(value)) return value.map((item) => redactStrings(item));
|
|
1389
|
-
if (value !== null && typeof value === "object") {
|
|
1390
|
-
const out = {};
|
|
1391
|
-
for (const [key, item] of Object.entries(value)) out[key] = redactStrings(item);
|
|
1392
|
-
return out;
|
|
1796
|
+
function collectDependencies(packageJson) {
|
|
1797
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
1798
|
+
for (const sectionName of DEPENDENCY_SECTIONS) {
|
|
1799
|
+
const section = packageJson[sectionName];
|
|
1800
|
+
if (!isRecord$1(section)) continue;
|
|
1801
|
+
for (const dependencyName of Object.keys(section)) dependencies.add(dependencyName);
|
|
1393
1802
|
}
|
|
1394
|
-
return
|
|
1803
|
+
return dependencies;
|
|
1395
1804
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
const
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1805
|
+
function extractImports(content) {
|
|
1806
|
+
const imports = /* @__PURE__ */ new Set();
|
|
1807
|
+
for (const pattern of [
|
|
1808
|
+
/(?:import|export)\s+(?:type\s+)?(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g,
|
|
1809
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
1810
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
1811
|
+
]) for (const match of content.matchAll(pattern)) {
|
|
1812
|
+
const imported = normalizePackageImport(match[1]);
|
|
1813
|
+
if (imported) imports.add(imported);
|
|
1814
|
+
}
|
|
1815
|
+
return Array.from(imports);
|
|
1816
|
+
}
|
|
1817
|
+
function normalizePackageImport(value) {
|
|
1818
|
+
if (!value || value.startsWith(".") || value.startsWith("/")) return void 0;
|
|
1819
|
+
if (value.startsWith("@")) {
|
|
1820
|
+
const [scope, name] = value.split("/");
|
|
1821
|
+
return scope && name ? `${scope}/${name}` : value;
|
|
1410
1822
|
}
|
|
1823
|
+
return value.split("/")[0];
|
|
1824
|
+
}
|
|
1825
|
+
function findOwningPackage(filePath, packages) {
|
|
1826
|
+
const normalized = resolve(filePath);
|
|
1827
|
+
return packages.filter((pkg) => normalized === pkg.directory || normalized.startsWith(`${pkg.directory}${sep}`)).sort((left, right) => right.directory.length - left.directory.length)[0] ?? packages[0];
|
|
1828
|
+
}
|
|
1829
|
+
function packageInventory(pkg) {
|
|
1830
|
+
const declaredHappyVerticalDependencies = Array.from(pkg.dependencies).filter((dependency) => dependency.startsWith(HAPPYVERTICAL_PACKAGE_PREFIX)).sort();
|
|
1831
|
+
const importedHappyVerticalPackages = Array.from(pkg.imports.keys()).sort();
|
|
1832
|
+
const ownName = typeof pkg.json.name === "string" ? pkg.json.name : void 0;
|
|
1411
1833
|
return {
|
|
1412
|
-
|
|
1413
|
-
|
|
1834
|
+
name: packageLabel(pkg),
|
|
1835
|
+
path: pkg.relativePath,
|
|
1836
|
+
private: typeof pkg.json.private === "boolean" ? pkg.json.private : void 0,
|
|
1837
|
+
scripts: Object.keys(pkg.scripts).sort(),
|
|
1838
|
+
declaredHappyVerticalDependencies,
|
|
1839
|
+
importedHappyVerticalPackages,
|
|
1840
|
+
missingHappyVerticalDependencies: importedHappyVerticalPackages.filter((name) => name !== ownName && !pkg.dependencies.has(name)),
|
|
1841
|
+
hasSvelteKit: pkg.dependencies.has("@sveltejs/kit"),
|
|
1842
|
+
hasSmrtSvelte: pkg.dependencies.has("@happyvertical/smrt-svelte")
|
|
1414
1843
|
};
|
|
1415
1844
|
}
|
|
1416
|
-
function
|
|
1417
|
-
return
|
|
1845
|
+
function summarizeFindings(findings) {
|
|
1846
|
+
return findings.reduce((summary, finding) => {
|
|
1847
|
+
summary[finding.severity]++;
|
|
1848
|
+
return summary;
|
|
1849
|
+
}, {
|
|
1850
|
+
high: 0,
|
|
1851
|
+
medium: 0,
|
|
1852
|
+
low: 0
|
|
1853
|
+
});
|
|
1418
1854
|
}
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1421
|
-
return
|
|
1855
|
+
function limitFindings(findings, maxFindings) {
|
|
1856
|
+
const sorted = findings.sort((left, right) => severityRank(left.severity) - severityRank(right.severity) || left.area.localeCompare(right.area) || left.title.localeCompare(right.title));
|
|
1857
|
+
return maxFindings && maxFindings > 0 ? sorted.slice(0, maxFindings) : sorted;
|
|
1422
1858
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
return withRuntimeConnection(connectionArgs, (db) => readToParts(readJobHealth(db, { limit })), "no job health snapshot — the manifest still reports declared job queues");
|
|
1859
|
+
function severityRank(severity) {
|
|
1860
|
+
return severity === "high" ? 0 : severity === "medium" ? 1 : 2;
|
|
1426
1861
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
return withRuntimeConnection(connectionArgs, (db) => readToParts(readScheduleHealth(db, { limit })), "no schedule health snapshot — the manifest still reports declared schedules");
|
|
1862
|
+
function packageLabel(pkg) {
|
|
1863
|
+
return typeof pkg.json.name === "string" ? pkg.json.name : pkg.relativePath;
|
|
1430
1864
|
}
|
|
1431
|
-
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1865
|
+
function lineNumber(content, needle) {
|
|
1866
|
+
const index = content.indexOf(needle);
|
|
1867
|
+
if (index < 0) return void 0;
|
|
1868
|
+
return content.slice(0, index).split("\n").length;
|
|
1434
1869
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
return withRuntimeConnection(connectionArgs, (db) => readToParts(readRecentChanges(db, {
|
|
1438
|
-
since,
|
|
1439
|
-
tables,
|
|
1440
|
-
tenantId,
|
|
1441
|
-
limit
|
|
1442
|
-
})), "no recent changes — static knowledge artifacts are unchanged");
|
|
1870
|
+
function isRecord$1(value) {
|
|
1871
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1443
1872
|
}
|
|
1444
|
-
async function
|
|
1445
|
-
|
|
1873
|
+
async function pathExists(path) {
|
|
1874
|
+
try {
|
|
1875
|
+
await access(path);
|
|
1876
|
+
return true;
|
|
1877
|
+
} catch {
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
function pathExistsSyncHint(path) {
|
|
1882
|
+
return existsSync(path);
|
|
1446
1883
|
}
|
|
1447
1884
|
//#endregion
|
|
1448
1885
|
//#region src/index.ts
|
|
@@ -1451,8 +1888,6 @@ async function runtimeRegistryDrift(args = {}) {
|
|
|
1451
1888
|
* Provides code generation, project introspection, knowledge context,
|
|
1452
1889
|
* review/architecture prompt bundles, and portable agent skills.
|
|
1453
1890
|
*/
|
|
1454
|
-
var SERVER_NAME = "smrt-dev-mcp";
|
|
1455
|
-
var SERVER_VERSION = readPackageVersion();
|
|
1456
1891
|
var DEBUG = process.env.DEBUG === "true";
|
|
1457
1892
|
var REVIEW_SKILL_URI = `smrt-dev-mcp://agent-skills/${REVIEW_SKILL_NAME}`;
|
|
1458
1893
|
var DOMAIN_CODE_REVIEW_PROMPT = "domain-code-review";
|
|
@@ -1815,6 +2250,15 @@ function createServer() {
|
|
|
1815
2250
|
case "registry-drift":
|
|
1816
2251
|
result = JSON.stringify(await runtimeRegistryDrift(args), null, 2);
|
|
1817
2252
|
break;
|
|
2253
|
+
case "runtime-registry":
|
|
2254
|
+
result = JSON.stringify(await runtimeRegistry(args), null, 2);
|
|
2255
|
+
break;
|
|
2256
|
+
case "runtime-object":
|
|
2257
|
+
result = JSON.stringify(await runtimeObject(args), null, 2);
|
|
2258
|
+
break;
|
|
2259
|
+
case "runtime-schema-diff":
|
|
2260
|
+
result = JSON.stringify(await runtimeSchemaDiff(args), null, 2);
|
|
2261
|
+
break;
|
|
1818
2262
|
default: throw new Error(`Unknown tool: ${name}`);
|
|
1819
2263
|
}
|
|
1820
2264
|
return {
|
|
@@ -1870,15 +2314,6 @@ function isEntrypoint() {
|
|
|
1870
2314
|
return import.meta.url === pathToFileURL(entry).href;
|
|
1871
2315
|
}
|
|
1872
2316
|
}
|
|
1873
|
-
function readPackageVersion() {
|
|
1874
|
-
try {
|
|
1875
|
-
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1876
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
1877
|
-
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
|
|
1878
|
-
} catch {
|
|
1879
|
-
return "0.0.0";
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
2317
|
function renderAgentSkillMarkdown(name) {
|
|
1883
2318
|
const skill = getAgentSkill({
|
|
1884
2319
|
name,
|
|
@@ -1995,11 +2430,53 @@ function sanitizePath(path) {
|
|
|
1995
2430
|
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return "<absolute-path>";
|
|
1996
2431
|
return path;
|
|
1997
2432
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2433
|
+
/**
|
|
2434
|
+
* `smrt-dev-mcp --http [--port N] [--project DIR]` starts the Level 2 runtime
|
|
2435
|
+
* dev-plane host (#1831) instead of the stdio server. Loopback-only, bearer
|
|
2436
|
+
* protected, positive read-only catalog; see `http.ts`.
|
|
2437
|
+
*/
|
|
2438
|
+
function parseHttpCliArgs(argv) {
|
|
2439
|
+
const result = { http: false };
|
|
2440
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2441
|
+
const arg = argv[index];
|
|
2442
|
+
if (arg === "--http") result.http = true;
|
|
2443
|
+
else if (arg === "--port") {
|
|
2444
|
+
const value = Number.parseInt(argv[index + 1] ?? "", 10);
|
|
2445
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) throw new Error("--port requires an integer between 0 and 65535");
|
|
2446
|
+
result.port = value;
|
|
2447
|
+
index += 1;
|
|
2448
|
+
} else if (arg === "--project") {
|
|
2449
|
+
const value = argv[index + 1];
|
|
2450
|
+
if (!value) throw new Error("--project requires a directory");
|
|
2451
|
+
result.projectRoot = value;
|
|
2452
|
+
index += 1;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return result;
|
|
2456
|
+
}
|
|
2457
|
+
async function mainHttp(cli) {
|
|
2458
|
+
const host = await startRuntimeHttpHost({
|
|
2459
|
+
port: cli.port,
|
|
2460
|
+
projectRoot: cli.projectRoot
|
|
2461
|
+
});
|
|
2462
|
+
const minted = !process.env.SMRT_DEV_MCP_TOKEN?.trim();
|
|
2463
|
+
console.error(`[${SERVER_NAME}] runtime dev-plane listening at ${host.url} (${host.boot.objectCount} booted objects, ${host.boot.manifests.length} manifests)`);
|
|
2464
|
+
if (minted) console.error(`[${SERVER_NAME}] bearer token: ${host.token}`);
|
|
2465
|
+
const shutdown = async () => {
|
|
2466
|
+
await host.close();
|
|
2467
|
+
process.exit(0);
|
|
2468
|
+
};
|
|
2469
|
+
process.on("SIGINT", shutdown);
|
|
2470
|
+
process.on("SIGTERM", shutdown);
|
|
2471
|
+
}
|
|
2472
|
+
if (isEntrypoint()) {
|
|
2473
|
+
const cli = parseHttpCliArgs(process.argv.slice(2));
|
|
2474
|
+
(cli.http ? mainHttp(cli) : main()).catch((error) => {
|
|
2475
|
+
console.error(`[${SERVER_NAME}] Fatal error:`, error);
|
|
2476
|
+
process.exit(1);
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2002
2479
|
//#endregion
|
|
2003
|
-
export { SERVER_VERSION, TOOLS, createServer };
|
|
2480
|
+
export { SERVER_VERSION, TOOLS, createServer, parseHttpCliArgs };
|
|
2004
2481
|
|
|
2005
2482
|
//# sourceMappingURL=index.js.map
|