@fourier-labs/harbour 0.1.5 → 0.1.6

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.
@@ -1,32 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  import { RemoteMcpClient } from "./remote-mcp-client.js";
3
3
  import { productionise } from "./productionise.js";
4
+ import { safeError, CliError } from "./output.js";
5
+ import { CLI_VERSION } from "./version.js";
4
6
  const args = process.argv.slice(2);
5
7
  const command = args[0];
6
8
  const rootIndex = args.indexOf("--app-root");
7
- const root = rootIndex >= 0 ? args[rootIndex + 1] : ".";
8
- const json = args.includes("--json");
9
+ const root = rootIndex >= 0 ? args[rootIndex + 1] : undefined;
10
+ const includePaths = [];
11
+ let optionError;
12
+ for (let index = 0; index < args.length; index += 1) {
13
+ if (args[index] === "--include") {
14
+ const value = args[index + 1];
15
+ if (!value || value.startsWith("--")) {
16
+ optionError = "--include requires a relative file or directory path.";
17
+ break;
18
+ }
19
+ includePaths.push(value);
20
+ index += 1;
21
+ }
22
+ }
9
23
  const url = process.env.HARBOUR_MCP_URL;
10
24
  const token = process.env.HARBOUR_TOKEN ?? "";
11
25
  const tenant = process.env.HARBOUR_TENANT ?? "";
12
- const usage = "Usage: harbour productionise --app-root <path>\nSet HARBOUR_MCP_URL, HARBOUR_TENANT, and HARBOUR_TOKEN before running.\n";
13
- if (!command || command === "help" || command === "--help" || args.includes("-h")) {
26
+ const usage = "Usage: harbour productionise --app-root <path> [--include <relative-path>]... [--json]\nSet HARBOUR_MCP_URL, HARBOUR_TENANT, and HARBOUR_TOKEN before running.\n";
27
+ if (command === "--version" || command === "version") {
28
+ process.stdout.write(`${CLI_VERSION}\n`);
29
+ }
30
+ else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
14
31
  process.stdout.write(usage);
15
32
  }
16
- else if (command !== "productionise" || !root || !url || !tenant) {
33
+ else if (optionError || command !== "productionise" || !root || root.startsWith("--") || !url || !tenant || !token) {
34
+ if (optionError)
35
+ process.stderr.write(`${optionError}\n`);
17
36
  process.stderr.write(usage);
18
37
  process.exitCode = 2;
19
38
  }
20
39
  else {
21
40
  const client = new RemoteMcpClient(url, token, tenant);
22
41
  try {
23
- await client.initialize();
24
- const result = await productionise(root, client, message => { if (!json)
25
- process.stderr.write(`${message}\n`); }, tenant);
26
- process.stdout.write(`${JSON.stringify(result)}\n`);
42
+ const result = await productionise(root, client, message => { process.stderr.write(`${message}\n`); }, tenant, includePaths);
43
+ const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
44
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
27
45
  }
28
46
  catch (error) {
29
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
47
+ const safe = safeError(error);
48
+ process.stderr.write(`${safe.message}\n`);
49
+ const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "FAILED", operationStarted: error instanceof CliError ? Boolean(error.operationRef) : false, ...(error instanceof CliError && error.operationRef ? { operationRef: error.operationRef } : {}), error: safe };
50
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
30
51
  process.exitCode = 1;
31
52
  }
32
53
  }
@@ -0,0 +1,14 @@
1
+ export function safeError(error) {
2
+ const code = error instanceof CliError ? error.code : "CLI_FAILED";
3
+ const raw = error instanceof CliError ? error.message : "Harbour could not complete the request.";
4
+ return { code, message: raw.replace(/https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi, "").replace(/\s+/g, " ").trim().slice(0, 240) || "Harbour could not complete the request." };
5
+ }
6
+ export class CliError extends Error {
7
+ code;
8
+ operationRef;
9
+ constructor(code, message, operationRef) {
10
+ super(message);
11
+ this.code = code;
12
+ this.operationRef = operationRef;
13
+ }
14
+ }
@@ -1,67 +1,135 @@
1
- import { basename, resolve } from "node:path";
1
+ import { homedir } from "node:os";
2
+ import { basename, parse, resolve } from "node:path";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { scanWorkspace } from "../../../src/analyzer.js";
4
5
  import { createSourceManifest } from "../../../src/source-intake.js";
5
6
  import { structured } from "./remote-mcp-client.js";
6
7
  import { archiveForManifest, putExact } from "./upload.js";
7
- export async function productionise(rootArg, client, output, tenantId) {
8
+ import { CliError } from "./output.js";
9
+ import { CLI_VERSION } from "./version.js";
10
+ export async function productionise(rootArg, client, output, tenantId, includePaths = []) {
8
11
  const root = resolve(rootArg);
9
12
  output(`Harbour is checking ${basename(root)}.`);
10
- const start = structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex" }));
11
- const operationId = start.operation?.operationId;
12
- if (!operationId)
13
- throw new Error("Harbour did not return an operation.");
14
- const graph = await scanWorkspace(root, { sourceBoundary: "root" });
15
- const submitted = structured(await client.call("harbour_submit_application_graph", { operationId, surface: "codex", graph }));
16
- if (submitted.nextTool && submitted.nextTool !== "harbour_prepare_source_upload")
17
- throw new Error("Harbour returned an unsupported next step.");
18
- output(`Harbour found ${graph.deploymentScope.includedFiles.length} app files.`);
19
- if (graph.deploymentScope.includedFiles.some(path => /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i.test(path))) {
20
- throw new Error("Harbour found a prohibited secret file in the selected app boundary.");
21
- }
13
+ if (root === parse(root).root || root === resolve(homedir()))
14
+ throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
15
+ const graph = await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) });
16
+ if (!graph.deploymentScope.includedFiles.length)
17
+ throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
18
+ if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
19
+ throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
22
20
  const files = await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
23
- const appId = projectName(graph.deploymentScope.appRoot);
24
- const manifest = await createSourceManifest({ tenantId, appId, operationId, graphDigest: graph.graphDigest, files });
25
- const prepared = structured(await client.call("harbour_prepare_source_upload", { operationId, surface: "codex", graph, manifest: { schema: "harbour.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
26
- if (!prepared.acceptedManifest || !prepared.sourceUpload)
27
- throw new Error("Harbour did not return an upload contract.");
28
- if (prepared.nextAction && typeof prepared.nextAction === "object" && "tool" in prepared.nextAction && prepared.nextAction.tool !== "harbour_execute_source_control")
29
- throw new Error("Harbour returned an unsupported next step.");
30
- const archive = await archiveForManifest(root, prepared.acceptedManifest);
31
- await putExact(prepared.sourceUpload, archive.body);
32
- output("Harbour has prepared the secure app package.");
33
- output("Approval required: Harbour can save this app in the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.");
34
- const approval = await readApproval();
35
- if (approval !== "Approved.")
36
- throw new Error("The Harbour save was not approved.");
37
- const approvalObject = { schema: "harbour.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: approval, userVisibleProgress: "Approved.", nextStepSummary: "Harbour can safely save the app.", dataOrActions: ["Save the app in the company code system."] };
38
- const executed = structured(await client.call("harbour_execute_source_control", { operationId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE", approval: approvalObject }));
39
- if (executed.sourceControlExecution?.status === "FAILED")
40
- throw new Error("Harbour could not save the app.");
41
- let status;
42
- for (let attempt = 0; attempt < 40; attempt += 1) {
43
- status = structured(await client.call("harbour_get_operation_status", { operationId, waitSeconds: 15 }));
44
- if (status.sourceSave?.status === "SUCCEEDED" || status.sourceSave?.status === "DUPLICATE")
45
- break;
46
- if (status.sourceSave?.status === "FAILED")
47
- throw new Error("Harbour could not save the app.");
21
+ output(`Harbour found ${files.length} app files.`);
22
+ try {
23
+ await client.initialize();
24
+ }
25
+ catch {
26
+ throw new CliError("NOT_STARTED", "Harbour could not be reached before the operation started.");
27
+ }
28
+ let start;
29
+ try {
30
+ start = structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex" }));
31
+ }
32
+ catch {
33
+ throw new CliError("NOT_STARTED", "Harbour could not start the productionisation operation.");
34
+ }
35
+ const operationRef = start.operation?.operationId;
36
+ if (!operationRef)
37
+ throw new CliError("NOT_STARTED", "Harbour did not start the productionisation operation.");
38
+ try {
39
+ const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph }));
40
+ if (submitted.nextTool && submitted.nextTool !== "harbour_prepare_source_upload")
41
+ throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
42
+ const appId = projectName(graph.deploymentScope.appRoot);
43
+ const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
44
+ const prepared = structured(await client.call("harbour_prepare_source_upload", { operationId: operationRef, surface: "codex", graph, manifest: { schema: "harbour.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
45
+ if (!prepared.acceptedManifest || !prepared.sourceUpload)
46
+ throw new CliError("UPLOAD_CONTRACT_MISSING", "Harbour did not return a safe upload contract.", operationRef);
47
+ if (prepared.nextAction?.tool && prepared.nextAction.tool !== "harbour_execute_source_control")
48
+ throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
49
+ const archive = await archiveForManifest(root, prepared.acceptedManifest);
50
+ await putExact(prepared.sourceUpload, archive.body);
51
+ output("Harbour has prepared the secure app package.");
52
+ let execution = await execute(client, operationRef, graph);
53
+ if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
54
+ output(`Approval required: Harbour can copy ${files.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
55
+ const approval = await readApproval();
56
+ if (approval !== "Approved.")
57
+ throw new CliError("APPROVAL_NOT_GRANTED", "The Harbour save was not approved.", operationRef);
58
+ execution = await execute(client, operationRef, graph, { schema: "harbour.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: approval, userVisibleProgress: "Approved.", nextStepSummary: "Harbour can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
59
+ }
60
+ if (execution.status === "ADMIN_SETUP_REQUIRED")
61
+ throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
62
+ if (execution.status === "FAILED")
63
+ throw new CliError("FAILED", "Harbour could not save the app.", operationRef);
64
+ if (execution.status === "RETRYABLE_FAILURE")
65
+ throw new CliError("RETRYABLE_FAILURE", "Harbour could not complete the save yet. Resume this operation safely.", operationRef);
66
+ const evidence = await waitForSave(client, operationRef, graph, execution, output);
67
+ const report = structured(await client.call("harbour_build_verification_report", { operationId: operationRef, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
68
+ output("Harbour verified the saved app.");
69
+ return { cliVersion: CLI_VERSION, operationRef, result: safeVerificationResult(report) };
70
+ }
71
+ catch (error) {
72
+ if (error instanceof CliError || (error && typeof error === "object" && "code" in error))
73
+ throw error;
74
+ throw new CliError("OPERATION_FAILED", "Harbour could not complete the started operation.", operationRef);
48
75
  }
49
- if (!status?.sourceControlEvidence || !status.runnerEvidence)
50
- throw new Error("Harbour did not return managed save evidence.");
51
- const report = structured(await client.call("harbour_build_verification_report", { operationId, surface: "codex", graph, sourceControlEvidence: status.sourceControlEvidence, runnerEvidence: status.runnerEvidence }));
52
- output("Harbour verified the saved app.");
53
- return report;
54
76
  }
55
- function projectName(value) {
56
- const name = value.split(/[\\/]/).filter(Boolean).at(-1) ?? "harbour-app";
57
- return name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "harbour-app";
77
+ async function execute(client, operationRef, graph, approval) {
78
+ const args = { operationId: operationRef, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
79
+ if (approval)
80
+ args.approval = approval;
81
+ return structured(await client.call("harbour_execute_source_control", args)).sourceControlExecution ?? {};
58
82
  }
59
- async function readApproval() {
60
- if (!process.stdin.isTTY) {
61
- const chunks = [];
62
- for await (const chunk of process.stdin)
63
- chunks.push(Buffer.from(chunk));
64
- return Buffer.concat(chunks).toString("utf8").trim();
83
+ async function waitForSave(client, operationRef, graph, first, output) {
84
+ let waitSeconds = boundedWait(first.nextAction?.waitSeconds);
85
+ for (let attempt = 0; attempt < 40; attempt += 1) {
86
+ const status = structured(await client.call("harbour_get_operation_status", { operationId: operationRef, waitSeconds }));
87
+ const terminal = status.sourceSave?.status;
88
+ if (terminal === "SUCCEEDED" || terminal === "DUPLICATE") {
89
+ const sourceControlEvidence = status.sourceControlEvidence ?? status.sourceSave?.sourceControlEvidence;
90
+ const runnerEvidence = status.runnerEvidence ?? status.sourceSave?.runnerEvidence;
91
+ if (!sourceControlEvidence || !runnerEvidence)
92
+ throw new CliError("EVIDENCE_MISSING", "Harbour did not return managed save evidence.", operationRef);
93
+ return { sourceControlEvidence, runnerEvidence };
94
+ }
95
+ if (terminal === "FAILED")
96
+ throw new CliError("FAILED", "Harbour could not save the app.", operationRef);
97
+ if (terminal === "RETRYABLE_FAILURE")
98
+ throw new CliError("RETRYABLE_FAILURE", "Harbour could not complete the save yet. Resume this operation safely.", operationRef);
99
+ waitSeconds = boundedWait(status.nextAction?.waitSeconds ?? status.waitSeconds);
100
+ output("Harbour is still saving the app.");
65
101
  }
66
- return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
102
+ throw new CliError("RETRYABLE_FAILURE", "Harbour could not finish the save within the safe retry window.", operationRef);
103
+ }
104
+ function boundedWait(value) {
105
+ return Math.min(15, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 15));
106
+ }
107
+ function isProhibitedSecretPath(path) {
108
+ if (/(^|\/)\.env\.example$/i.test(path))
109
+ return false;
110
+ return /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i.test(path);
111
+ }
112
+ function safeVerificationResult(value) {
113
+ if (!value || typeof value !== "object" || Array.isArray(value))
114
+ return { verification: "completed" };
115
+ const record = value;
116
+ const decision = record.decision && typeof record.decision === "object" && !Array.isArray(record.decision)
117
+ ? record.decision
118
+ : undefined;
119
+ return {
120
+ verification: typeof decision?.status === "string"
121
+ ? decision.status
122
+ : typeof record.status === "string"
123
+ ? record.status
124
+ : "completed",
125
+ ...(typeof decision?.assuranceLevel === "string" ? { assuranceLevel: decision.assuranceLevel } : {}),
126
+ ...(typeof record.protectedUrl === "string" ? { protectedUrl: record.protectedUrl } : {})
127
+ };
67
128
  }
129
+ function projectName(value) { const name = value.split(/[\\/]/).filter(Boolean).at(-1) ?? "harbour-app"; return name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "harbour-app"; }
130
+ async function readApproval() { if (!process.stdin.isTTY) {
131
+ const chunks = [];
132
+ for await (const chunk of process.stdin)
133
+ chunks.push(Buffer.from(chunk));
134
+ return Buffer.concat(chunks).toString("utf8").trim();
135
+ } return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); }); }
@@ -1,3 +1,4 @@
1
+ import { CLI_VERSION } from "./version.js";
1
2
  export class RemoteMcpClient {
2
3
  url;
3
4
  token;
@@ -12,7 +13,7 @@ export class RemoteMcpClient {
12
13
  async initialize() {
13
14
  if (this.initialized)
14
15
  return;
15
- await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "harbour-cli", version: "0.1.0" } });
16
+ await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "harbour-cli", version: CLI_VERSION } });
16
17
  await this.request("notifications/initialized", undefined, false);
17
18
  this.initialized = true;
18
19
  }
@@ -2,24 +2,33 @@ import { readFile, lstat } from "node:fs/promises";
2
2
  import { relative, resolve, sep } from "node:path";
3
3
  import { createSourceArchive } from "../../../src/source-intake.js";
4
4
  import { sha256Bytes } from "../../../src/digest.js";
5
- const SECRET = /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i;
5
+ const SECRET = /(^|\/)(?:(?!\.env\.example$)\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i;
6
6
  export async function archiveForManifest(root, manifest) {
7
+ const rootAbsolute = resolve(root);
8
+ if ((await lstat(rootAbsolute)).isSymbolicLink())
9
+ throw new Error("Harbour rejected an unsafe app boundary.");
7
10
  const files = [];
8
11
  const seen = new Set();
9
12
  for (const entry of manifest.files) {
10
13
  if (seen.has(entry.path) || SECRET.test(entry.path) || entry.path.includes("\\") || entry.path.startsWith("/"))
11
14
  throw new Error("Harbour rejected an unsafe source path.");
12
15
  seen.add(entry.path);
13
- const absolute = resolve(root, entry.path);
14
- if (!absolute.startsWith(resolve(root) + sep))
16
+ const absolute = resolve(rootAbsolute, entry.path);
17
+ if (!absolute.startsWith(rootAbsolute + sep))
15
18
  throw new Error("Harbour rejected a path outside the selected app.");
19
+ let current = rootAbsolute;
20
+ for (const component of entry.path.split("/")) {
21
+ current = resolve(current, component);
22
+ if ((await lstat(current)).isSymbolicLink())
23
+ throw new Error("Harbour rejected an unsafe source path.");
24
+ }
16
25
  const info = await lstat(absolute);
17
26
  if (!info.isFile())
18
27
  throw new Error("The app changed while it was being packaged.");
19
28
  const content = new Uint8Array(await readFile(absolute));
20
29
  if (content.byteLength !== entry.bytes || await sha256Bytes(content) !== entry.sha256)
21
30
  throw new Error(`The app changed while it was being packaged (${entry.path}).`);
22
- files.push({ path: relative(root, absolute).split(sep).join("/"), content });
31
+ files.push({ path: relative(rootAbsolute, absolute).split(sep).join("/"), content });
23
32
  }
24
33
  const archive = await createSourceArchive(manifest, files);
25
34
  const body = new TextEncoder().encode(JSON.stringify(archive));
@@ -32,7 +41,7 @@ export async function putExact(upload, body) {
32
41
  let lastError;
33
42
  for (let attempt = 0; attempt < 2; attempt += 1) {
34
43
  try {
35
- const response = await fetch(upload.url, { method: "PUT", headers, body: Buffer.from(body) });
44
+ const response = await fetch(upload.url, { method: "PUT", headers, body: Buffer.from(body), redirect: "error" });
36
45
  if (!response.ok) {
37
46
  if (![408, 429, 500, 502, 503, 504].includes(response.status))
38
47
  throw new Error(`Harbour source upload failed (${response.status}).`);
@@ -0,0 +1 @@
1
+ export const CLI_VERSION = "0.1.6";
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { lstat, readFile, readdir, stat } from "node:fs/promises";
5
5
  import { basename, dirname, join, relative } from "node:path";
6
6
  import { sha256 } from "./digest.js";
7
7
  const ANALYZER_VERSION = "0.1.0";
@@ -13,6 +13,46 @@ const MAX_FILE_BYTES = 256_000;
13
13
  const IGNORED_DIRS = new Set([".git", ".harbour", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
14
14
  const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
15
15
  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"]);
16
+ function normalizeIncludePath(value) {
17
+ if (!value || value === ".")
18
+ return "";
19
+ if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value))
20
+ throw new Error("ANALYZER_INCLUDE_INVALID: include paths must be relative to the selected app root.");
21
+ const parts = value.split(/[\\/]+/).filter(Boolean);
22
+ if (parts.some(part => part === "." || part === ".."))
23
+ throw new Error("ANALYZER_INCLUDE_INVALID: include paths may not contain traversal segments.");
24
+ return parts.join("/");
25
+ }
26
+ async function collectIncludedFiles(root, includePaths, output, unknowns) {
27
+ const normalized = [...new Set(includePaths.map(normalizeIncludePath))];
28
+ for (const include of normalized) {
29
+ if (include.split("/").some(part => IGNORED_DIRS.has(part)))
30
+ continue;
31
+ const absolute = include ? join(root, ...include.split("/")) : root;
32
+ let info;
33
+ try {
34
+ info = await lstat(absolute);
35
+ }
36
+ catch {
37
+ throw new Error(`ANALYZER_INCLUDE_MISSING: include path does not exist: ${include || "."}`);
38
+ }
39
+ if (info.isSymbolicLink())
40
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: symlink include path is not allowed: ${include || "."}`);
41
+ if (!info.isFile() && !info.isDirectory())
42
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: include path is not a file or directory: ${include}`);
43
+ let current = root;
44
+ for (const part of include.split("/").filter(Boolean)) {
45
+ current = join(current, part);
46
+ const component = await lstat(current);
47
+ if (component.isSymbolicLink())
48
+ throw new Error(`ANALYZER_INCLUDE_UNSAFE: symlink path component is not allowed: ${include}`);
49
+ }
50
+ if (info.isDirectory())
51
+ await collectFiles(root, absolute, output, unknowns);
52
+ else
53
+ await collectFile(root, absolute, output);
54
+ }
55
+ }
16
56
  function extension(path) {
17
57
  const index = path.lastIndexOf(".");
18
58
  return index >= 0 ? path.slice(index) : "";
@@ -33,16 +73,23 @@ async function collectFiles(root, current, output, unknowns) {
33
73
  }
34
74
  if (!entry.isFile())
35
75
  continue;
36
- const absolute = join(current, entry.name);
37
- const rel = relative(root, absolute);
38
- const info = await stat(absolute);
39
- const fact = { path: rel, basename: entry.name, size: info.size };
40
- const safeEnvExample = entry.name === ".env.example" || entry.name.endsWith(".env.example");
41
- if (!SECRET_FILE_NAMES.has(entry.name) && info.size <= MAX_FILE_BYTES && (TEXT_EXTENSIONS.has(extension(entry.name)) || safeEnvExample)) {
42
- fact.content = await readFile(absolute, "utf8");
43
- }
44
- output.push(fact);
76
+ await collectFile(root, join(current, entry.name), output);
77
+ }
78
+ }
79
+ async function collectFile(root, absolute, output) {
80
+ const path = relative(root, absolute);
81
+ if (output.some(file => file.path === path))
82
+ return;
83
+ const info = await lstat(absolute);
84
+ if (!info.isFile() || info.isSymbolicLink())
85
+ throw new Error("ANALYZER_FILE_UNSAFE: selected app file changed or is not a regular file.");
86
+ const name = basename(absolute);
87
+ const fact = { path, basename: name, size: info.size };
88
+ const safeEnvExample = name === ".env.example" || name.endsWith(".env.example");
89
+ if (!SECRET_FILE_NAMES.has(name) && info.size <= MAX_FILE_BYTES && (TEXT_EXTENSIONS.has(extension(name)) || safeEnvExample)) {
90
+ fact.content = await readFile(absolute, "utf8");
45
91
  }
92
+ output.push(fact);
46
93
  }
47
94
  function unique(values) {
48
95
  return [...new Set(values.filter(Boolean))].sort();
@@ -271,16 +318,25 @@ function detectGitProvider(locator) {
271
318
  return "git";
272
319
  }
273
320
  export async function scanWorkspace(root, options = {}) {
321
+ const rootInfo = await lstat(root);
322
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
323
+ throw new Error("ANALYZER_ROOT_INVALID: selected app root must be a real directory.");
274
324
  const unknowns = [];
275
325
  const files = [];
276
- await collectFiles(root, root, files, unknowns);
277
- const fingerprintInput = files.map(file => `${file.path}:${file.size}:${file.content ? createHash("sha256").update(file.content).digest("hex") : "unread"}`).join("\n");
326
+ if (options.includePaths?.length)
327
+ await collectIncludedFiles(root, options.includePaths, files, unknowns);
328
+ else
329
+ await collectFiles(root, root, files, unknowns);
330
+ const scopedFiles = files.sort((a, b) => a.path.localeCompare(b.path));
331
+ if (options.includePaths?.length && scopedFiles.length === 0)
332
+ throw new Error("ANALYZER_INCLUDE_EMPTY: the declared include paths contain no eligible files.");
333
+ const fingerprintInput = scopedFiles.map(file => `${file.path}:${file.size}:${file.content ? createHash("sha256").update(file.content).digest("hex") : "unread"}`).join("\n");
278
334
  const workspaceFingerprint = await sha256(fingerprintInput);
279
- const { dependencies, scripts, packageManagers } = parsePackageJson(files);
280
- const framework = detectFramework(dependencies, files);
335
+ const { dependencies, scripts, packageManagers } = parsePackageJson(scopedFiles);
336
+ const framework = detectFramework(dependencies, scopedFiles);
281
337
  // Secret-bearing files remain visible only in the exclusion policy. They
282
338
  // must never be part of the deployable/source-control file boundary.
283
- const includedFiles = files
339
+ const includedFiles = scopedFiles
284
340
  .filter(file => !SECRET_FILE_NAMES.has(file.basename))
285
341
  .map(file => file.path)
286
342
  .sort();
@@ -295,11 +351,11 @@ export async function scanWorkspace(root, options = {}) {
295
351
  excludedSecretFiles: [...SECRET_FILE_NAMES].sort(),
296
352
  deployCommands: detectDeployCommands(scripts)
297
353
  },
298
- components: detectComponents(framework, dependencies, files),
299
- capabilities: detectCapabilities(files, dependencies),
354
+ components: detectComponents(framework, dependencies, scopedFiles),
355
+ capabilities: detectCapabilities(scopedFiles, dependencies),
300
356
  packageManagers,
301
357
  dependencyNames: dependencies,
302
- environmentVariableNames: detectEnvNames(files),
358
+ environmentVariableNames: detectEnvNames(scopedFiles),
303
359
  testCommands: detectTestCommands(scripts),
304
360
  analysis: {
305
361
  analyzerVersion: ANALYZER_VERSION,
@@ -6,6 +6,7 @@ export class MemorySourceArchiveStore {
6
6
  const value = this.archives.get(objectKey);
7
7
  return value ? clone(value) : undefined;
8
8
  }
9
+ async exists(objectKey) { return this.archives.has(objectKey); }
9
10
  async delete(objectKey) { this.archives.delete(objectKey); }
10
11
  }
11
12
  export async function createSourceManifest(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },