@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.
- package/dist/packages/harbour-cli/src/cli.js +31 -10
- package/dist/packages/harbour-cli/src/output.js +14 -0
- package/dist/packages/harbour-cli/src/productionise.js +122 -54
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +2 -1
- package/dist/packages/harbour-cli/src/upload.js +14 -5
- package/dist/packages/harbour-cli/src/version.js +1 -0
- package/dist/src/analyzer.js +74 -18
- package/dist/src/source-intake.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
13
|
-
if (
|
|
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.
|
|
24
|
-
const
|
|
25
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
56
|
-
const
|
|
57
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
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 = /(^|\/)(
|
|
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(
|
|
14
|
-
if (!absolute.startsWith(
|
|
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(
|
|
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";
|
package/dist/src/analyzer.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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(
|
|
280
|
-
const framework = detectFramework(dependencies,
|
|
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 =
|
|
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,
|
|
299
|
-
capabilities: detectCapabilities(
|
|
354
|
+
components: detectComponents(framework, dependencies, scopedFiles),
|
|
355
|
+
capabilities: detectCapabilities(scopedFiles, dependencies),
|
|
300
356
|
packageManagers,
|
|
301
357
|
dependencyNames: dependencies,
|
|
302
|
-
environmentVariableNames: detectEnvNames(
|
|
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) {
|