@isomorph.ai/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +55 -0
  2. package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
  3. package/dist/packages/harbour-cli/src/app-schema.js +142 -0
  4. package/dist/packages/harbour-cli/src/auth.js +197 -0
  5. package/dist/packages/harbour-cli/src/check.js +317 -0
  6. package/dist/packages/harbour-cli/src/cli.js +321 -0
  7. package/dist/packages/harbour-cli/src/config.js +97 -0
  8. package/dist/packages/harbour-cli/src/dev.js +125 -0
  9. package/dist/packages/harbour-cli/src/forwarder.js +168 -0
  10. package/dist/packages/harbour-cli/src/integrations.js +385 -0
  11. package/dist/packages/harbour-cli/src/jobs.js +53 -0
  12. package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
  13. package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
  14. package/dist/packages/harbour-cli/src/kit.js +149 -0
  15. package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
  16. package/dist/packages/harbour-cli/src/operations.js +382 -0
  17. package/dist/packages/harbour-cli/src/output.js +273 -0
  18. package/dist/packages/harbour-cli/src/productionise.js +508 -0
  19. package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
  20. package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
  21. package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
  22. package/dist/packages/harbour-cli/src/starter.js +557 -0
  23. package/dist/packages/harbour-cli/src/upload.js +163 -0
  24. package/dist/packages/harbour-cli/src/version.js +1 -0
  25. package/dist/src/analyzer.js +685 -0
  26. package/dist/src/contracts.js +82 -0
  27. package/dist/src/digest.js +26 -0
  28. package/dist/src/secret-paths.js +38 -0
  29. package/dist/src/source-intake.js +125 -0
  30. package/package.json +43 -0
@@ -0,0 +1,685 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { lstat, readFile, readdir, stat } from "node:fs/promises";
5
+ import { basename, dirname, join, relative } from "node:path";
6
+ import { sha256 } from "./digest.js";
7
+ import { isEnvExamplePath, isKitCommittedPath, isSourceIntakeExcludedPath } from "./secret-paths.js";
8
+ const ANALYZER_VERSION = "0.1.0";
9
+ const MAX_FILES = 600;
10
+ const MAX_FILE_BYTES = 256_000;
11
+ // The pipeline's own receipts and derived artifacts (`.harbour/`, written into
12
+ // the workspace and the published lineage) are control-plane artifacts, not
13
+ // application files. Keeping them out also makes reruns idempotent after a
14
+ // previous save has written a receipt into the selected workspace.
15
+ const IGNORED_DIRS = new Set([".git", ".harbour", ".isomorph", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
16
+ /** The development kit's directory. It commits its declaration, lock, governed-AI inventory and journey checks there (see `isKitCommittedPath`); everything else in it (local state, checks evidence, generated control files) stays out of the package. The inventory is what the pipeline plans a kit app's AI over (data plane 0.78.2.0): the gate writes it under `isomorph check`, the lane refuses an app whose source calls governed AI without it. */
17
+ const KIT_DIR = ".isomorph";
18
+ const KIT_CONTROL_FILES = [`${KIT_DIR}/integrations.json`, `${KIT_DIR}/kit.lock.json`, `${KIT_DIR}/ai-inventory.json`];
19
+ const KIT_CHECKS_DIR = `${KIT_DIR}/checks`;
20
+ const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
21
+ const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh"]);
22
+ function normalizeIncludePath(value) {
23
+ if (!value || value === ".")
24
+ return "";
25
+ if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value))
26
+ throw new Error("ANALYZER_INCLUDE_INVALID: include paths must be relative to the selected app root.");
27
+ const parts = value.split(/[\\/]+/).filter(Boolean);
28
+ if (parts.some(part => part === "." || part === ".."))
29
+ throw new Error("ANALYZER_INCLUDE_INVALID: include paths may not contain traversal segments.");
30
+ return parts.join("/");
31
+ }
32
+ async function collectIncludedFiles(root, includePaths, output, unknowns) {
33
+ const normalized = [...new Set(includePaths.map(normalizeIncludePath))];
34
+ for (const include of normalized) {
35
+ if (include.split("/").some(part => IGNORED_DIRS.has(part)) && !isKitCommittedPath(include))
36
+ continue;
37
+ const absolute = include ? join(root, ...include.split("/")) : root;
38
+ let info;
39
+ try {
40
+ info = await lstat(absolute);
41
+ }
42
+ catch {
43
+ throw new Error(`ANALYZER_INCLUDE_MISSING: include path does not exist: ${include || "."}`);
44
+ }
45
+ if (info.isSymbolicLink())
46
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: symlink include path is not allowed: ${include || "."}`);
47
+ if (!info.isFile() && !info.isDirectory())
48
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: include path is not a file or directory: ${include}`);
49
+ let current = root;
50
+ for (const part of include.split("/").filter(Boolean)) {
51
+ current = join(current, part);
52
+ const component = await lstat(current);
53
+ if (component.isSymbolicLink())
54
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: symlink path component is not allowed: ${include}`);
55
+ }
56
+ if (info.isDirectory())
57
+ await collectFiles(root, absolute, output, unknowns);
58
+ else
59
+ await collectFile(root, absolute, output);
60
+ }
61
+ }
62
+ function extension(path) {
63
+ const index = path.lastIndexOf(".");
64
+ return index >= 0 ? path.slice(index) : "";
65
+ }
66
+ async function collectFiles(root, current, output, unknowns) {
67
+ if (output.length >= MAX_FILES)
68
+ return;
69
+ const entries = await readdir(current, { withFileTypes: true });
70
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
71
+ if (output.length >= MAX_FILES) {
72
+ unknowns.push(`file limit reached at ${MAX_FILES} files`);
73
+ return;
74
+ }
75
+ // A linked worktree represents .git as a small pointer file rather than a
76
+ // directory. It is Git metadata and must stay outside the app boundary in
77
+ // both checkout layouts.
78
+ if (entry.name === ".git")
79
+ continue;
80
+ if (entry.isDirectory()) {
81
+ if (!IGNORED_DIRS.has(entry.name))
82
+ await collectFiles(root, join(current, entry.name), output, unknowns);
83
+ else if (entry.name === KIT_DIR && current === root)
84
+ await collectKitFiles(root, output);
85
+ continue;
86
+ }
87
+ if (!entry.isFile())
88
+ continue;
89
+ await collectFile(root, join(current, entry.name), output);
90
+ }
91
+ }
92
+ async function collectKitFiles(root, output) {
93
+ const paths = [...KIT_CONTROL_FILES];
94
+ let checks = [];
95
+ try {
96
+ checks = await readdir(join(root, ...KIT_CHECKS_DIR.split("/")), { withFileTypes: true });
97
+ }
98
+ catch {
99
+ checks = [];
100
+ }
101
+ for (const entry of checks) {
102
+ const path = `${KIT_CHECKS_DIR}/${entry.name}`;
103
+ if (isKitCommittedPath(path))
104
+ paths.push(path);
105
+ }
106
+ for (const path of paths.sort()) {
107
+ const absolute = join(root, ...path.split("/"));
108
+ let info;
109
+ try {
110
+ info = await lstat(absolute);
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ if (info.isFile() && !info.isSymbolicLink())
116
+ await collectFile(root, absolute, output);
117
+ }
118
+ }
119
+ async function collectFile(root, absolute, output) {
120
+ const path = relative(root, absolute);
121
+ if (output.some(file => file.path === path))
122
+ return;
123
+ const info = await lstat(absolute);
124
+ if (!info.isFile() || info.isSymbolicLink())
125
+ throw new Error("ANALYZER_FILE_UNSAFE: selected app file changed or is not a regular file.");
126
+ const name = basename(absolute);
127
+ const fact = { path, basename: name, size: info.size };
128
+ // Env templates and extensionless shell-context files (Dockerfile, Procfile)
129
+ // carry env-name evidence; without content they can never produce an ask.
130
+ const safeExtensionless = isEnvTemplateFile(name) || isShellContextFile(name);
131
+ if (!SECRET_FILE_NAMES.has(name) && info.size <= MAX_FILE_BYTES && (TEXT_EXTENSIONS.has(extension(name)) || safeExtensionless)) {
132
+ fact.content = await readFile(absolute, "utf8");
133
+ }
134
+ output.push(fact);
135
+ }
136
+ function unique(values) {
137
+ return [...new Set(values.filter(Boolean))].sort();
138
+ }
139
+ function parsePackageJson(files) {
140
+ const packageFile = files.find(file => file.path === "package.json" && file.content);
141
+ const dependencies = [];
142
+ const scripts = {};
143
+ if (packageFile?.content) {
144
+ const parsed = JSON.parse(packageFile.content);
145
+ dependencies.push(...Object.keys(parsed.dependencies ?? {}), ...Object.keys(parsed.devDependencies ?? {}));
146
+ Object.assign(scripts, parsed.scripts ?? {});
147
+ if (parsed.packageManager)
148
+ dependencies.push(parsed.packageManager.split("@")[0] ?? "");
149
+ }
150
+ const packageManagers = unique([
151
+ files.some(file => file.basename === "package-lock.json") ? "npm" : "",
152
+ files.some(file => file.basename === "pnpm-lock.yaml") ? "pnpm" : "",
153
+ files.some(file => file.basename === "yarn.lock") ? "yarn" : "",
154
+ files.some(file => file.basename === "bun.lockb" || file.basename === "bun.lock") ? "bun" : "",
155
+ packageFile ? "npm" : ""
156
+ ]);
157
+ return { dependencies: unique(dependencies), scripts, packageManagers };
158
+ }
159
+ function detectFramework(dependencies, files) {
160
+ const deps = new Set(dependencies);
161
+ if (deps.has("next"))
162
+ return "nextjs";
163
+ if (deps.has("@remix-run/react"))
164
+ return "remix";
165
+ if (deps.has("@sveltejs/kit"))
166
+ return "sveltekit";
167
+ if (deps.has("astro"))
168
+ return "astro";
169
+ if (deps.has("vite"))
170
+ return "vite";
171
+ if (deps.has("react"))
172
+ return "react";
173
+ if (deps.has("express"))
174
+ return "express";
175
+ if (files.some(file => file.basename === "requirements.txt" || file.basename === "pyproject.toml"))
176
+ return "python";
177
+ return "unknown";
178
+ }
179
+ function detectComponents(framework, dependencies, files) {
180
+ const deps = new Set(dependencies);
181
+ const entries = files.map(file => file.path);
182
+ const components = [];
183
+ const frontendEntries = entries.filter(path => /^(src|app|pages)\//.test(path) && /\.(tsx|jsx|ts|js|html)$/.test(path)).slice(0, 12);
184
+ const backendEntries = entries.filter(path => /^(server|api|src\/server|functions)\//.test(path) && /\.(ts|js|mjs|cjs)$/.test(path)).slice(0, 12);
185
+ if (frontendEntries.length || ["vite", "nextjs", "react", "remix", "sveltekit", "astro"].includes(framework)) {
186
+ components.push({
187
+ componentId: "web-frontend",
188
+ kind: framework === "nextjs" || framework === "remix" ? "SSR_FRONTEND" : "STATIC_FRONTEND",
189
+ runtime: deps.has("vite") || deps.has("react") || deps.has("next") ? "node" : "unknown",
190
+ framework,
191
+ entrypoints: frontendEntries.length ? frontendEntries : ["package.json"]
192
+ });
193
+ }
194
+ if (backendEntries.length || deps.has("express") || deps.has("fastify") || deps.has("hono")) {
195
+ components.push({
196
+ componentId: "http-backend",
197
+ kind: "HTTP_BACKEND",
198
+ runtime: "node",
199
+ framework: deps.has("express") ? "express" : deps.has("fastify") ? "fastify" : deps.has("hono") ? "hono" : "unknown",
200
+ entrypoints: backendEntries.length ? backendEntries : ["package.json"]
201
+ });
202
+ }
203
+ return components.length ? components : [{ componentId: "app", kind: "UNKNOWN", runtime: "unknown", framework, entrypoints: entries.slice(0, 8) }];
204
+ }
205
+ function detectCapabilities(files, dependencies) {
206
+ const corpus = `${dependencies.join("\n")}\n${files.map(file => `${file.path}\n${file.content ?? ""}`).join("\n")}`;
207
+ const capabilities = [];
208
+ if (/auth|oauth|oidc|saml|next-auth|passport|clerk|auth0|supabase/i.test(corpus)) {
209
+ capabilities.push({ kind: "IDENTITY", providerHint: "existing-auth-code", usage: "Authentication or identity dependency/code detected" });
210
+ }
211
+ if (/postgres|mysql|sqlite|prisma|drizzle|sequelize|mongoose|mongodb|redis/i.test(corpus)) {
212
+ capabilities.push({ kind: "RELATIONAL_DATA", usage: "Database dependency or connection pattern detected" });
213
+ }
214
+ if (/s3|blob|bucket|r2|storage/i.test(corpus)) {
215
+ capabilities.push({ kind: "OBJECT_STORAGE", usage: "Object storage dependency or naming pattern detected" });
216
+ }
217
+ if (/openai|anthropic|gemini|model|llm/i.test(corpus)) {
218
+ capabilities.push({ kind: "MODEL_ENDPOINT", usage: "Model endpoint dependency or naming pattern detected" });
219
+ }
220
+ if (/api[_-]?key|fetch\(|axios|external/i.test(corpus)) {
221
+ capabilities.push({ kind: "EXTERNAL_API", usage: "External API call or credential name pattern detected" });
222
+ }
223
+ if (/process\.env|import\.meta\.env|Deno\.env/i.test(corpus)) {
224
+ capabilities.push({ kind: "SECRETS", usage: "Environment-variable based configuration detected" });
225
+ }
226
+ return capabilities;
227
+ }
228
+ /** Env-access idioms recognized in any retained text file. Names only —
229
+ * capture groups never span a value. */
230
+ const ENV_ACCESS_PATTERNS = [
231
+ // JS/TS dot access and Deno (original rule).
232
+ /(?:process\.env\.|import\.meta\.env\.|Deno\.env\.get\(["'])([A-Z0-9_]{3,})/g,
233
+ // JS/TS bracket access.
234
+ /(?:process\.env|import\.meta\.env)\[["']([A-Z0-9_]{3,})["']\]/g,
235
+ // Python: os.environ["X"], os.environ.get("X").
236
+ /\bos\.environ(?:\.get\(\s*|\[)["']([A-Z0-9_]{3,})["']/g,
237
+ // Go: os.Getenv("X"), os.LookupEnv("X").
238
+ /\bos\.(?:Getenv|LookupEnv)\(\s*"([A-Z0-9_]{3,})"/g,
239
+ // Ruby: ENV["X"], ENV.fetch("X").
240
+ /\bENV(?:\.fetch\(\s*|\[)["']([A-Z0-9_]{3,})["']/g,
241
+ // Java System.getenv, PHP/C getenv, Python os.getenv — all end in getenv(.
242
+ /\bgetenv\(\s*["']([A-Z0-9_]{3,})["']/g
243
+ ];
244
+ /** Shell parameter expansion: ${X}, ${X:-default}, bare $X. Only applied to
245
+ * shell-context content (see isShellContextFile) — `${X}` in prose or make
246
+ * syntax must never become a builder ask. */
247
+ const SHELL_EXPANSION_PATTERN = /\$(?:\{([A-Z0-9_]{3,})(?::?[-=+?][^}]*)?\}|([A-Z0-9_]{3,})\b)/g;
248
+ function isEnvTemplateFile(basename) {
249
+ return isEnvExamplePath(basename);
250
+ }
251
+ /** Files whose content is shell-like end to end. Markdown and source files are
252
+ * deliberately absent; Makefiles too (`${X}` there is make-variable syntax). */
253
+ function isShellContextFile(basename) {
254
+ if (basename === "Dockerfile" || basename.startsWith("Dockerfile."))
255
+ return true;
256
+ if (basename === "Procfile")
257
+ return true;
258
+ if (basename.endsWith(".sh"))
259
+ return true;
260
+ return /^(?:docker-)?compose[^/]*\.ya?ml$/.test(basename);
261
+ }
262
+ function pushMatches(content, pattern, names) {
263
+ for (const match of content.matchAll(pattern)) {
264
+ names.push(match[1] ?? match[2] ?? "");
265
+ }
266
+ }
267
+ function detectEnvNames(files) {
268
+ const names = [];
269
+ for (const file of files) {
270
+ const content = file.content ?? "";
271
+ for (const pattern of ENV_ACCESS_PATTERNS)
272
+ pushMatches(content, pattern, names);
273
+ if (isShellContextFile(file.basename))
274
+ pushMatches(content, SHELL_EXPANSION_PATTERN, names);
275
+ // package.json scripts run in a shell; expansion applies to the script
276
+ // strings alone, never to the raw JSON (dependency names, config values).
277
+ if (file.basename === "package.json") {
278
+ try {
279
+ const scripts = JSON.parse(content).scripts ?? {};
280
+ for (const value of Object.values(scripts)) {
281
+ if (typeof value === "string")
282
+ pushMatches(value, SHELL_EXPANSION_PATTERN, names);
283
+ }
284
+ }
285
+ catch { /* Invalid manifests contribute no names. */ }
286
+ }
287
+ if (isEnvTemplateFile(file.basename)) {
288
+ for (const line of content.split(/\r?\n/)) {
289
+ const envMatch = /^([A-Z0-9_]{3,})=/.exec(line.trim());
290
+ if (envMatch)
291
+ names.push(envMatch[1] ?? "");
292
+ }
293
+ }
294
+ }
295
+ return unique(names);
296
+ }
297
+ function detectTestCommands(scripts) {
298
+ return unique([
299
+ scripts.test ? "npm test" : "",
300
+ scripts.check ? "npm run check" : "",
301
+ scripts.lint ? "npm run lint" : ""
302
+ ]);
303
+ }
304
+ function detectDeployCommands(scripts) {
305
+ return unique([
306
+ scripts.build ? "npm run build" : "",
307
+ scripts.start ? "npm start" : "",
308
+ scripts.dev ? "npm run dev" : "",
309
+ scripts.preview ? "npm run preview" : "",
310
+ scripts.deploy ? "npm run deploy" : ""
311
+ ]);
312
+ }
313
+ async function detectGitSource(root, fingerprint, options, includedFiles) {
314
+ const gitRoot = options.sourceBoundary === "root"
315
+ ? (existsSync(join(root, ".git")) ? root : undefined)
316
+ : findGitRoot(root);
317
+ if (!gitRoot || !allDeployableFilesTracked(gitRoot, root, includedFiles)) {
318
+ const source = {
319
+ schema: "isomorph.source-ref/1.0",
320
+ kind: "unversioned_workspace",
321
+ locator: options.sourceLocator ?? `workspace://${basename(root)}`,
322
+ revision: fingerprint,
323
+ recoverable: false
324
+ };
325
+ if (options.sourceProvider)
326
+ source.provider = options.sourceProvider;
327
+ return source;
328
+ }
329
+ const gitDir = await resolveGitDir(gitRoot);
330
+ const headPath = join(gitDir, "HEAD");
331
+ let revision = fingerprint;
332
+ let locator = options.sourceLocator ?? await readFirstRemoteUrl(gitRoot) ?? `git://${basename(gitRoot)}`;
333
+ try {
334
+ const head = (await readFile(headPath, "utf8")).trim();
335
+ if (head.startsWith("ref:")) {
336
+ const ref = head.replace("ref:", "").trim();
337
+ const commit = await readFile(join(gitDir, ref), "utf8").then(value => value.trim()).catch(() => "");
338
+ revision = commit ? `${commit}@${fingerprint}` : `${ref}@${fingerprint}`;
339
+ }
340
+ else {
341
+ revision = `${head}@${fingerprint}`;
342
+ }
343
+ }
344
+ catch {
345
+ revision = fingerprint;
346
+ }
347
+ const source = {
348
+ schema: "isomorph.source-ref/1.0",
349
+ kind: "connected_repository",
350
+ locator,
351
+ revision,
352
+ recoverable: true
353
+ };
354
+ source.provider = options.sourceProvider ?? detectGitProvider(locator);
355
+ return source;
356
+ }
357
+ function allDeployableFilesTracked(gitRoot, appRoot, includedFiles) {
358
+ if (includedFiles.length === 0)
359
+ return false;
360
+ const appPathFromGitRoot = relative(gitRoot, appRoot);
361
+ const tracked = spawnSync("git", ["-C", gitRoot, "ls-files", "--", appPathFromGitRoot || "."], {
362
+ encoding: "utf8"
363
+ });
364
+ if (tracked.status !== 0)
365
+ return false;
366
+ const trackedPaths = new Set(tracked.stdout.split(/\r?\n/).filter(Boolean));
367
+ return includedFiles.every(file => {
368
+ const repoRelativePath = appPathFromGitRoot ? join(appPathFromGitRoot, file) : file;
369
+ return trackedPaths.has(repoRelativePath);
370
+ });
371
+ }
372
+ function findGitRoot(start) {
373
+ let current = start;
374
+ while (true) {
375
+ if (existsSync(join(current, ".git")))
376
+ return current;
377
+ const parent = dirname(current);
378
+ if (parent === current)
379
+ return undefined;
380
+ current = parent;
381
+ }
382
+ }
383
+ async function resolveGitDir(gitRoot) {
384
+ const dotGit = join(gitRoot, ".git");
385
+ const info = await stat(dotGit);
386
+ if (info.isDirectory())
387
+ return dotGit;
388
+ const content = await readFile(dotGit, "utf8");
389
+ const match = /^gitdir:\s*(.+)$/m.exec(content);
390
+ if (!match?.[1])
391
+ return dotGit;
392
+ const gitDir = match[1];
393
+ return gitDir.startsWith("/") ? gitDir : join(gitRoot, gitDir);
394
+ }
395
+ async function readFirstRemoteUrl(gitRoot) {
396
+ try {
397
+ const config = await readFile(join(await resolveGitDir(gitRoot), "config"), "utf8");
398
+ const match = /\[remote "origin"\][\s\S]*?\n\s*url = (.+)/.exec(config) ?? /\[remote "[^"]+"\][\s\S]*?\n\s*url = (.+)/.exec(config);
399
+ return match?.[1]?.trim();
400
+ }
401
+ catch {
402
+ return undefined;
403
+ }
404
+ }
405
+ function detectGitProvider(locator) {
406
+ if (/^(https:\/\/|git@)github\.com[:/]/i.test(locator) || /\/\/github\.com\//i.test(locator))
407
+ return "github";
408
+ if (/^(https:\/\/|git@)gitlab\.com[:/]/i.test(locator) || /\/\/gitlab\.com\//i.test(locator))
409
+ return "gitlab";
410
+ if (/^(https:\/\/|git@)bitbucket\.org[:/]/i.test(locator) || /\/\/bitbucket\.org\//i.test(locator))
411
+ return "bitbucket";
412
+ return "git";
413
+ }
414
+ const MANIFEST_DRIVER_MAP = {
415
+ pg: "postgres",
416
+ postgres: "postgres",
417
+ mssql: "sqlserver",
418
+ tedious: "sqlserver",
419
+ "snowflake-sdk": "snowflake",
420
+ "@google-cloud/bigquery": "bigquery",
421
+ "@databricks/sql": "databricks",
422
+ mysql: "mysql",
423
+ mysql2: "mysql",
424
+ mongodb: "mongodb",
425
+ redis: "redis",
426
+ ioredis: "redis",
427
+ "@supabase/supabase-js": "supabase"
428
+ };
429
+ const CONNECTION_SCHEME_DRIVERS = {
430
+ postgres: "postgres",
431
+ postgresql: "postgres",
432
+ mysql: "mysql",
433
+ mongodb: "mongodb",
434
+ "mongodb+srv": "mongodb",
435
+ redis: "redis",
436
+ rediss: "redis"
437
+ };
438
+ const JDBC_SCHEME_DRIVERS = {
439
+ postgresql: "postgres",
440
+ sqlserver: "sqlserver",
441
+ snowflake: "snowflake",
442
+ mysql: "mysql",
443
+ bigquery: "bigquery",
444
+ databricks: "databricks"
445
+ };
446
+ /**
447
+ * Drivers whose service endpoint is a single fixed global host. Only a driver
448
+ * that can never point anywhere else belongs here: a BigQuery client names no
449
+ * host in code or config, so without this its dependency would carry no host
450
+ * at all, and a hostless dependency is dropped before it ever reaches the
451
+ * registry — the source could never be matched and the app would deploy with
452
+ * no grant and no credential.
453
+ */
454
+ const WELL_KNOWN_DRIVER_HOSTS = {
455
+ bigquery: "bigquery.googleapis.com"
456
+ };
457
+ const DRIVER_BY_WELL_KNOWN_HOST = Object.fromEntries(Object.entries(WELL_KNOWN_DRIVER_HOSTS).map(([driver, host]) => [host, driver]));
458
+ const CONNECTION_STRING_PATTERN = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?):\/\/([^\s"'`<>\\]+)/gi;
459
+ const JDBC_PATTERN = /\bjdbc:([a-z0-9]+):\/\/([^\s"'`<>\\]+)/gi;
460
+ const HTTP_URL_PATTERN = /\bhttps?:\/\/([a-z0-9][a-z0-9.-]*\.[a-z]{2,})(?::\d+)?(?=[/"'`\s?#),;]|$)/gi;
461
+ const BARE_HOSTNAME_PATTERN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i;
462
+ function isEnvExampleFile(path) {
463
+ return isEnvExamplePath(path);
464
+ }
465
+ /** Extract the hostname from a connection-string authority, discarding any
466
+ * credentials, port, path, or query. Never returns secret material. */
467
+ function hostFromAuthority(authority) {
468
+ const withoutCredentials = authority.split("@").pop() ?? "";
469
+ const host = withoutCredentials.split(/[/:?#,]/)[0] ?? "";
470
+ return /^[a-z0-9][a-z0-9.-]*$/i.test(host) ? host.toLowerCase() : undefined;
471
+ }
472
+ function detectManifestDrivers(files) {
473
+ const drivers = new Set();
474
+ for (const file of files) {
475
+ const name = file.path.split("/").pop() ?? file.path;
476
+ if (!file.content)
477
+ continue;
478
+ if (name === "package.json") {
479
+ try {
480
+ const parsed = JSON.parse(file.content);
481
+ for (const dependency of [...Object.keys(parsed.dependencies ?? {}), ...Object.keys(parsed.devDependencies ?? {})]) {
482
+ const driver = MANIFEST_DRIVER_MAP[dependency.toLowerCase()];
483
+ if (driver)
484
+ drivers.add(driver);
485
+ }
486
+ }
487
+ catch {
488
+ // Malformed manifest: skip, extraction stays best-effort.
489
+ }
490
+ }
491
+ else if (name === "requirements.txt") {
492
+ for (const line of file.content.split(/\r?\n/)) {
493
+ const requirement = line.trim().split(/[=<>!~\[;\s]/)[0]?.toLowerCase() ?? "";
494
+ const driver = MANIFEST_DRIVER_MAP[requirement];
495
+ if (driver)
496
+ drivers.add(driver);
497
+ }
498
+ }
499
+ }
500
+ return drivers;
501
+ }
502
+ function recordHost(hosts, host, driver, envName) {
503
+ const existing = hosts.get(host) ?? { host, driver: undefined, envNames: new Set() };
504
+ if (driver && !existing.driver)
505
+ existing.driver = driver;
506
+ if (envName)
507
+ existing.envNames.add(envName);
508
+ hosts.set(host, existing);
509
+ }
510
+ /** Scan one text fragment for connection strings, JDBC URLs, and http(s)
511
+ * base URLs, recording hostnames (never credentials or secret values). */
512
+ function scanFragmentForHosts(hosts, fragment, envName) {
513
+ for (const match of fragment.matchAll(CONNECTION_STRING_PATTERN)) {
514
+ const driver = CONNECTION_SCHEME_DRIVERS[(match[1] ?? "").toLowerCase()];
515
+ const host = hostFromAuthority(match[2] ?? "");
516
+ if (host)
517
+ recordHost(hosts, host, driver, envName);
518
+ }
519
+ for (const match of fragment.matchAll(JDBC_PATTERN)) {
520
+ const driver = JDBC_SCHEME_DRIVERS[(match[1] ?? "").toLowerCase()];
521
+ const host = hostFromAuthority(match[2] ?? "");
522
+ if (host)
523
+ recordHost(hosts, host, driver, envName);
524
+ }
525
+ for (const match of fragment.matchAll(HTTP_URL_PATTERN)) {
526
+ const host = (match[1] ?? "").toLowerCase();
527
+ if (host)
528
+ recordHost(hosts, host, undefined, envName);
529
+ }
530
+ }
531
+ export function extractDataDependencies(files, declared) {
532
+ const drivers = detectManifestDrivers(files);
533
+ const hosts = new Map();
534
+ for (const file of files) {
535
+ const content = file.content;
536
+ if (!content)
537
+ continue;
538
+ if (isEnvExampleFile(file.path)) {
539
+ // Env-example files associate the env var NAME with any hostname found
540
+ // in its value. Values that are not hostnames/URLs are never emitted.
541
+ for (const line of content.split(/\r?\n/)) {
542
+ const envMatch = /^([A-Z0-9_]{3,})=(.*)$/.exec(line.trim());
543
+ if (!envMatch)
544
+ continue;
545
+ const [, envName, value] = envMatch;
546
+ const trimmed = (value ?? "").trim().replace(/^["']|["']$/g, "");
547
+ scanFragmentForHosts(hosts, trimmed, envName);
548
+ if (BARE_HOSTNAME_PATTERN.test(trimmed)) {
549
+ const host = hostFromAuthority(trimmed);
550
+ if (host)
551
+ recordHost(hosts, host, undefined, envName);
552
+ }
553
+ }
554
+ }
555
+ else {
556
+ scanFragmentForHosts(hosts, content, undefined);
557
+ }
558
+ }
559
+ // Classify each host: database when a DB driver or DB connection-string
560
+ // scheme is implicated for that host, otherwise api. Supabase hosts count
561
+ // as database when the supabase client library is a declared dependency.
562
+ const databaseByDriver = new Map();
563
+ const apiHosts = [];
564
+ for (const detected of hosts.values()) {
565
+ let driver = detected.driver;
566
+ if (!driver && drivers.has("supabase") && /\.supabase\.(?:co|in)$/i.test(detected.host))
567
+ driver = "supabase";
568
+ // A well-known service host counts as that driver's host once the client
569
+ // library is a declared dependency, so the env names sitting beside it
570
+ // (the credential, the project) ride along as database identifiers rather
571
+ // than being filed as an unrelated API endpoint.
572
+ if (!driver) {
573
+ const wellKnown = DRIVER_BY_WELL_KNOWN_HOST[detected.host.toLowerCase()];
574
+ if (wellKnown && drivers.has(wellKnown))
575
+ driver = wellKnown;
576
+ }
577
+ if (driver) {
578
+ const group = databaseByDriver.get(driver) ?? { hosts: new Set(), envNames: new Set() };
579
+ group.hosts.add(detected.host);
580
+ for (const envName of detected.envNames)
581
+ group.envNames.add(envName);
582
+ databaseByDriver.set(driver, group);
583
+ }
584
+ else {
585
+ apiHosts.push(detected);
586
+ }
587
+ }
588
+ // Manifest drivers with no host still surface as a database entry, carrying
589
+ // the driver's fixed service host where it has one.
590
+ for (const driver of drivers) {
591
+ if (databaseByDriver.has(driver))
592
+ continue;
593
+ const wellKnownHost = WELL_KNOWN_DRIVER_HOSTS[driver];
594
+ databaseByDriver.set(driver, {
595
+ hosts: new Set(wellKnownHost ? [wellKnownHost] : []),
596
+ envNames: new Set()
597
+ });
598
+ }
599
+ const detectedEntries = [];
600
+ for (const [driver, group] of [...databaseByDriver.entries()].sort(([a], [b]) => a.localeCompare(b))) {
601
+ detectedEntries.push({ kind: "database", identifiers: { hosts: [...group.hosts].sort(), driver, envNames: [...group.envNames].sort() }, origin: "detected" });
602
+ }
603
+ for (const detected of apiHosts.sort((a, b) => a.host.localeCompare(b.host))) {
604
+ detectedEntries.push({ kind: "api", identifiers: { hosts: [detected.host], envNames: [...detected.envNames].sort() }, origin: "detected" });
605
+ }
606
+ // Merge declared entries: declared wins on host collision; origin stays
607
+ // faithful to what the caller supplied (defaulting to "declared").
608
+ const declaredEntries = (declared ?? []).map(entry => ({
609
+ kind: entry.kind,
610
+ identifiers: {
611
+ hosts: [...new Set(entry.identifiers.hosts.map(host => host.toLowerCase()))].sort(),
612
+ ...(entry.identifiers.driver ? { driver: entry.identifiers.driver } : {}),
613
+ envNames: [...new Set(entry.identifiers.envNames)].sort()
614
+ },
615
+ origin: entry.origin ?? "declared"
616
+ }));
617
+ const declaredHosts = new Set(declaredEntries.flatMap(entry => entry.identifiers.hosts));
618
+ const merged = [...declaredEntries];
619
+ for (const entry of detectedEntries) {
620
+ const hadHosts = entry.identifiers.hosts.length > 0;
621
+ const survivingHosts = entry.identifiers.hosts.filter(host => !declaredHosts.has(host));
622
+ if (hadHosts && survivingHosts.length === 0)
623
+ continue;
624
+ merged.push({ ...entry, identifiers: { ...entry.identifiers, hosts: survivingHosts } });
625
+ }
626
+ return merged;
627
+ }
628
+ /** Additive export: run the analyzer's existing env-name detection over an
629
+ * in-memory {path, content} file list (e.g. files unpacked from an upload). */
630
+ export function detectEnvironmentVariableNames(files) {
631
+ return detectEnvNames(files.map(file => ({
632
+ path: file.path,
633
+ basename: file.path.split("/").pop() ?? file.path,
634
+ size: file.content?.length ?? 0,
635
+ ...(file.content !== undefined ? { content: file.content } : {})
636
+ })));
637
+ }
638
+ export async function scanWorkspace(root, options = {}) {
639
+ const rootInfo = await lstat(root);
640
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
641
+ throw new Error("ANALYZER_ROOT_INVALID: selected app root must be a real directory.");
642
+ const unknowns = [];
643
+ const files = [];
644
+ if (options.includePaths?.length)
645
+ await collectIncludedFiles(root, options.includePaths, files, unknowns);
646
+ else
647
+ await collectFiles(root, root, files, unknowns);
648
+ const scopedFiles = files.sort((a, b) => a.path.localeCompare(b.path));
649
+ if (options.includePaths?.length && scopedFiles.length === 0)
650
+ throw new Error("ANALYZER_INCLUDE_EMPTY: the declared include paths contain no eligible files.");
651
+ const fingerprintInput = scopedFiles.map(file => `${file.path}:${file.size}:${file.content ? createHash("sha256").update(file.content).digest("hex") : "unread"}`).join("\n");
652
+ const workspaceFingerprint = await sha256(fingerprintInput);
653
+ const { dependencies, scripts, packageManagers } = parsePackageJson(scopedFiles);
654
+ const framework = detectFramework(dependencies, scopedFiles);
655
+ // Secret-bearing files remain visible only in the exclusion policy. They
656
+ // must never be part of the deployable/source-control file boundary.
657
+ const includedFiles = scopedFiles
658
+ .filter(file => !SECRET_FILE_NAMES.has(file.basename) && !isSourceIntakeExcludedPath(file.path))
659
+ .map(file => file.path)
660
+ .sort();
661
+ const graphWithoutDigest = {
662
+ schema: "isomorph.application-graph/1.0",
663
+ workspaceFingerprint,
664
+ source: await detectGitSource(root, workspaceFingerprint, options, includedFiles),
665
+ deploymentScope: {
666
+ appRoot: basename(root),
667
+ includedFiles,
668
+ excludedDirectories: [...IGNORED_DIRS].sort(),
669
+ excludedSecretFiles: [...SECRET_FILE_NAMES].sort(),
670
+ deployCommands: detectDeployCommands(scripts)
671
+ },
672
+ components: detectComponents(framework, dependencies, scopedFiles),
673
+ capabilities: detectCapabilities(scopedFiles, dependencies),
674
+ packageManagers,
675
+ dependencyNames: dependencies,
676
+ environmentVariableNames: detectEnvNames(scopedFiles),
677
+ testCommands: detectTestCommands(scripts),
678
+ analysis: {
679
+ analyzerVersion: ANALYZER_VERSION,
680
+ coverage: unknowns.length ? "PARTIAL" : "COMPLETE_FOR_SUPPORTED_MANIFESTS",
681
+ unknowns
682
+ }
683
+ };
684
+ return { ...graphWithoutDigest, graphDigest: await sha256(graphWithoutDigest) };
685
+ }