@fourier-labs/harbour 0.1.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/dist/packages/harbour-cli/src/cli.js +32 -0
- package/dist/packages/harbour-cli/src/productionise.js +67 -0
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +47 -0
- package/dist/packages/harbour-cli/src/upload.js +53 -0
- package/dist/src/analyzer.js +311 -0
- package/dist/src/contracts.js +51 -0
- package/dist/src/digest.js +26 -0
- package/dist/src/source-intake.js +124 -0
- package/package.json +15 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
|
+
import { productionise } from "./productionise.js";
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const command = args[0];
|
|
6
|
+
const rootIndex = args.indexOf("--app-root");
|
|
7
|
+
const root = rootIndex >= 0 ? args[rootIndex + 1] : ".";
|
|
8
|
+
const json = args.includes("--json");
|
|
9
|
+
const url = process.env.HARBOUR_MCP_URL;
|
|
10
|
+
const token = process.env.HARBOUR_TOKEN ?? "";
|
|
11
|
+
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")) {
|
|
14
|
+
process.stdout.write(usage);
|
|
15
|
+
}
|
|
16
|
+
else if (command !== "productionise" || !root || !url || !tenant) {
|
|
17
|
+
process.stderr.write(usage);
|
|
18
|
+
process.exitCode = 2;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const client = new RemoteMcpClient(url, token, tenant);
|
|
22
|
+
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`);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
4
|
+
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
5
|
+
import { structured } from "./remote-mcp-client.js";
|
|
6
|
+
import { archiveForManifest, putExact } from "./upload.js";
|
|
7
|
+
export async function productionise(rootArg, client, output, tenantId) {
|
|
8
|
+
const root = resolve(rootArg);
|
|
9
|
+
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
|
+
}
|
|
22
|
+
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.");
|
|
48
|
+
}
|
|
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
|
+
}
|
|
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";
|
|
58
|
+
}
|
|
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();
|
|
65
|
+
}
|
|
66
|
+
return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
|
|
67
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class RemoteMcpClient {
|
|
2
|
+
url;
|
|
3
|
+
token;
|
|
4
|
+
tenant;
|
|
5
|
+
id = 0;
|
|
6
|
+
initialized = false;
|
|
7
|
+
constructor(url, token, tenant) {
|
|
8
|
+
this.url = url;
|
|
9
|
+
this.token = token;
|
|
10
|
+
this.tenant = tenant;
|
|
11
|
+
}
|
|
12
|
+
async initialize() {
|
|
13
|
+
if (this.initialized)
|
|
14
|
+
return;
|
|
15
|
+
await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "harbour-cli", version: "0.1.0" } });
|
|
16
|
+
await this.request("notifications/initialized", undefined, false);
|
|
17
|
+
this.initialized = true;
|
|
18
|
+
}
|
|
19
|
+
async call(name, args) {
|
|
20
|
+
const response = await this.request("tools/call", { name, arguments: args });
|
|
21
|
+
const result = response;
|
|
22
|
+
if (result.isError)
|
|
23
|
+
throw new Error(result.content?.map(item => item.text ?? "").join("\n") || "Harbour rejected the request.");
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
async request(method, params, expectResponse = true) {
|
|
27
|
+
const headers = { "content-type": "application/json", "mcp-protocol-version": "2025-11-25" };
|
|
28
|
+
if (this.token)
|
|
29
|
+
headers.authorization = `Bearer ${this.token}`;
|
|
30
|
+
headers["x-harbour-tenant"] = this.tenant;
|
|
31
|
+
const response = await fetch(this.url, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, ...(params === undefined ? {} : { params }) }) });
|
|
32
|
+
if (!expectResponse)
|
|
33
|
+
return undefined;
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (!response.ok || body.error)
|
|
36
|
+
throw new Error(body.error?.message ?? `Harbour request failed (${response.status}).`);
|
|
37
|
+
return body.result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function structured(result) {
|
|
41
|
+
if (result.isError)
|
|
42
|
+
throw new Error(result.content?.map(item => item.text ?? "").join("\n") || "Harbour rejected the request.");
|
|
43
|
+
return result.structuredContent;
|
|
44
|
+
}
|
|
45
|
+
export function plainText(result) {
|
|
46
|
+
return result.content?.map(item => item.text ?? "").join("\n") ?? "";
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, lstat } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createSourceArchive } from "../../../src/source-intake.js";
|
|
4
|
+
import { sha256Bytes } from "../../../src/digest.js";
|
|
5
|
+
const SECRET = /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i;
|
|
6
|
+
export async function archiveForManifest(root, manifest) {
|
|
7
|
+
const files = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
for (const entry of manifest.files) {
|
|
10
|
+
if (seen.has(entry.path) || SECRET.test(entry.path) || entry.path.includes("\\") || entry.path.startsWith("/"))
|
|
11
|
+
throw new Error("Harbour rejected an unsafe source path.");
|
|
12
|
+
seen.add(entry.path);
|
|
13
|
+
const absolute = resolve(root, entry.path);
|
|
14
|
+
if (!absolute.startsWith(resolve(root) + sep))
|
|
15
|
+
throw new Error("Harbour rejected a path outside the selected app.");
|
|
16
|
+
const info = await lstat(absolute);
|
|
17
|
+
if (!info.isFile())
|
|
18
|
+
throw new Error("The app changed while it was being packaged.");
|
|
19
|
+
const content = new Uint8Array(await readFile(absolute));
|
|
20
|
+
if (content.byteLength !== entry.bytes || await sha256Bytes(content) !== entry.sha256)
|
|
21
|
+
throw new Error(`The app changed while it was being packaged (${entry.path}).`);
|
|
22
|
+
files.push({ path: relative(root, absolute).split(sep).join("/"), content });
|
|
23
|
+
}
|
|
24
|
+
const archive = await createSourceArchive(manifest, files);
|
|
25
|
+
const body = new TextEncoder().encode(JSON.stringify(archive));
|
|
26
|
+
return { body, digest: archive.archiveDigest };
|
|
27
|
+
}
|
|
28
|
+
export async function putExact(upload, body) {
|
|
29
|
+
if (upload.url.startsWith("local://"))
|
|
30
|
+
return;
|
|
31
|
+
const headers = { ...(upload.requiredHeaders ?? {}), ...(upload.contentType ? { "content-type": upload.contentType } : {}) };
|
|
32
|
+
let lastError;
|
|
33
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(upload.url, { method: "PUT", headers, body: Buffer.from(body) });
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
if (![408, 429, 500, 502, 503, 504].includes(response.status))
|
|
38
|
+
throw new Error(`Harbour source upload failed (${response.status}).`);
|
|
39
|
+
throw new Error(`Harbour source upload failed (${response.status}).`);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
lastError = error;
|
|
45
|
+
const retryable = error instanceof Error && (!/\(\d{3}\)/.test(error.message) || /\((408|429|500|502|503|504)\)/.test(error.message));
|
|
46
|
+
if (attempt === 0 && retryable)
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
48
|
+
else
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastError instanceof Error ? lastError : new Error("Harbour source upload failed.");
|
|
53
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
6
|
+
import { sha256 } from "./digest.js";
|
|
7
|
+
const ANALYZER_VERSION = "0.1.0";
|
|
8
|
+
const MAX_FILES = 600;
|
|
9
|
+
const MAX_FILE_BYTES = 256_000;
|
|
10
|
+
// Harbour's own receipts and lab metadata are control-plane artifacts, not
|
|
11
|
+
// application files. Keeping them out also makes reruns idempotent after a
|
|
12
|
+
// previous save has written a receipt into the selected workspace.
|
|
13
|
+
const IGNORED_DIRS = new Set([".git", ".harbour", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
|
|
14
|
+
const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
|
|
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 extension(path) {
|
|
17
|
+
const index = path.lastIndexOf(".");
|
|
18
|
+
return index >= 0 ? path.slice(index) : "";
|
|
19
|
+
}
|
|
20
|
+
async function collectFiles(root, current, output, unknowns) {
|
|
21
|
+
if (output.length >= MAX_FILES)
|
|
22
|
+
return;
|
|
23
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
24
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
25
|
+
if (output.length >= MAX_FILES) {
|
|
26
|
+
unknowns.push(`file limit reached at ${MAX_FILES} files`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
31
|
+
await collectFiles(root, join(current, entry.name), output, unknowns);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!entry.isFile())
|
|
35
|
+
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);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function unique(values) {
|
|
48
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
49
|
+
}
|
|
50
|
+
function parsePackageJson(files) {
|
|
51
|
+
const packageFile = files.find(file => file.path === "package.json" && file.content);
|
|
52
|
+
const dependencies = [];
|
|
53
|
+
const scripts = {};
|
|
54
|
+
if (packageFile?.content) {
|
|
55
|
+
const parsed = JSON.parse(packageFile.content);
|
|
56
|
+
dependencies.push(...Object.keys(parsed.dependencies ?? {}), ...Object.keys(parsed.devDependencies ?? {}));
|
|
57
|
+
Object.assign(scripts, parsed.scripts ?? {});
|
|
58
|
+
if (parsed.packageManager)
|
|
59
|
+
dependencies.push(parsed.packageManager.split("@")[0] ?? "");
|
|
60
|
+
}
|
|
61
|
+
const packageManagers = unique([
|
|
62
|
+
files.some(file => file.basename === "package-lock.json") ? "npm" : "",
|
|
63
|
+
files.some(file => file.basename === "pnpm-lock.yaml") ? "pnpm" : "",
|
|
64
|
+
files.some(file => file.basename === "yarn.lock") ? "yarn" : "",
|
|
65
|
+
files.some(file => file.basename === "bun.lockb" || file.basename === "bun.lock") ? "bun" : "",
|
|
66
|
+
packageFile ? "npm" : ""
|
|
67
|
+
]);
|
|
68
|
+
return { dependencies: unique(dependencies), scripts, packageManagers };
|
|
69
|
+
}
|
|
70
|
+
function detectFramework(dependencies, files) {
|
|
71
|
+
const deps = new Set(dependencies);
|
|
72
|
+
if (deps.has("next"))
|
|
73
|
+
return "nextjs";
|
|
74
|
+
if (deps.has("@remix-run/react"))
|
|
75
|
+
return "remix";
|
|
76
|
+
if (deps.has("@sveltejs/kit"))
|
|
77
|
+
return "sveltekit";
|
|
78
|
+
if (deps.has("astro"))
|
|
79
|
+
return "astro";
|
|
80
|
+
if (deps.has("vite"))
|
|
81
|
+
return "vite";
|
|
82
|
+
if (deps.has("react"))
|
|
83
|
+
return "react";
|
|
84
|
+
if (deps.has("express"))
|
|
85
|
+
return "express";
|
|
86
|
+
if (files.some(file => file.basename === "requirements.txt" || file.basename === "pyproject.toml"))
|
|
87
|
+
return "python";
|
|
88
|
+
return "unknown";
|
|
89
|
+
}
|
|
90
|
+
function detectComponents(framework, dependencies, files) {
|
|
91
|
+
const deps = new Set(dependencies);
|
|
92
|
+
const entries = files.map(file => file.path);
|
|
93
|
+
const components = [];
|
|
94
|
+
const frontendEntries = entries.filter(path => /^(src|app|pages)\//.test(path) && /\.(tsx|jsx|ts|js|html)$/.test(path)).slice(0, 12);
|
|
95
|
+
const backendEntries = entries.filter(path => /^(server|api|src\/server|functions)\//.test(path) && /\.(ts|js|mjs|cjs)$/.test(path)).slice(0, 12);
|
|
96
|
+
if (frontendEntries.length || ["vite", "nextjs", "react", "remix", "sveltekit", "astro"].includes(framework)) {
|
|
97
|
+
components.push({
|
|
98
|
+
componentId: "web-frontend",
|
|
99
|
+
kind: framework === "nextjs" || framework === "remix" ? "SSR_FRONTEND" : "STATIC_FRONTEND",
|
|
100
|
+
runtime: deps.has("vite") || deps.has("react") || deps.has("next") ? "node" : "unknown",
|
|
101
|
+
framework,
|
|
102
|
+
entrypoints: frontendEntries.length ? frontendEntries : ["package.json"]
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (backendEntries.length || deps.has("express") || deps.has("fastify") || deps.has("hono")) {
|
|
106
|
+
components.push({
|
|
107
|
+
componentId: "http-backend",
|
|
108
|
+
kind: "HTTP_BACKEND",
|
|
109
|
+
runtime: "node",
|
|
110
|
+
framework: deps.has("express") ? "express" : deps.has("fastify") ? "fastify" : deps.has("hono") ? "hono" : "unknown",
|
|
111
|
+
entrypoints: backendEntries.length ? backendEntries : ["package.json"]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return components.length ? components : [{ componentId: "app", kind: "UNKNOWN", runtime: "unknown", framework, entrypoints: entries.slice(0, 8) }];
|
|
115
|
+
}
|
|
116
|
+
function detectCapabilities(files, dependencies) {
|
|
117
|
+
const corpus = `${dependencies.join("\n")}\n${files.map(file => `${file.path}\n${file.content ?? ""}`).join("\n")}`;
|
|
118
|
+
const capabilities = [];
|
|
119
|
+
if (/auth|oauth|oidc|saml|next-auth|passport|clerk|auth0|supabase/i.test(corpus)) {
|
|
120
|
+
capabilities.push({ kind: "IDENTITY", providerHint: "existing-auth-code", usage: "Authentication or identity dependency/code detected" });
|
|
121
|
+
}
|
|
122
|
+
if (/postgres|mysql|sqlite|prisma|drizzle|sequelize|mongoose|mongodb|redis/i.test(corpus)) {
|
|
123
|
+
capabilities.push({ kind: "RELATIONAL_DATA", usage: "Database dependency or connection pattern detected" });
|
|
124
|
+
}
|
|
125
|
+
if (/s3|blob|bucket|r2|storage/i.test(corpus)) {
|
|
126
|
+
capabilities.push({ kind: "OBJECT_STORAGE", usage: "Object storage dependency or naming pattern detected" });
|
|
127
|
+
}
|
|
128
|
+
if (/openai|anthropic|gemini|model|llm/i.test(corpus)) {
|
|
129
|
+
capabilities.push({ kind: "MODEL_ENDPOINT", usage: "Model endpoint dependency or naming pattern detected" });
|
|
130
|
+
}
|
|
131
|
+
if (/api[_-]?key|fetch\(|axios|external/i.test(corpus)) {
|
|
132
|
+
capabilities.push({ kind: "EXTERNAL_API", usage: "External API call or credential name pattern detected" });
|
|
133
|
+
}
|
|
134
|
+
if (/process\.env|import\.meta\.env|Deno\.env/i.test(corpus)) {
|
|
135
|
+
capabilities.push({ kind: "SECRETS", usage: "Environment-variable based configuration detected" });
|
|
136
|
+
}
|
|
137
|
+
return capabilities;
|
|
138
|
+
}
|
|
139
|
+
function detectEnvNames(files) {
|
|
140
|
+
const names = [];
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
const content = file.content ?? "";
|
|
143
|
+
for (const match of content.matchAll(/(?:process\.env\.|import\.meta\.env\.|Deno\.env\.get\(["'])([A-Z0-9_]{3,})/g)) {
|
|
144
|
+
names.push(match[1] ?? "");
|
|
145
|
+
}
|
|
146
|
+
if (file.basename === ".env.example" || file.basename.endsWith(".env.example")) {
|
|
147
|
+
for (const line of content.split(/\r?\n/)) {
|
|
148
|
+
const envMatch = /^([A-Z0-9_]{3,})=/.exec(line.trim());
|
|
149
|
+
if (envMatch)
|
|
150
|
+
names.push(envMatch[1] ?? "");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return unique(names);
|
|
155
|
+
}
|
|
156
|
+
function detectTestCommands(scripts) {
|
|
157
|
+
return unique([
|
|
158
|
+
scripts.test ? "npm test" : "",
|
|
159
|
+
scripts.check ? "npm run check" : "",
|
|
160
|
+
scripts.lint ? "npm run lint" : ""
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
function detectDeployCommands(scripts) {
|
|
164
|
+
return unique([
|
|
165
|
+
scripts.build ? "npm run build" : "",
|
|
166
|
+
scripts.start ? "npm start" : "",
|
|
167
|
+
scripts.dev ? "npm run dev" : "",
|
|
168
|
+
scripts.preview ? "npm run preview" : "",
|
|
169
|
+
scripts.deploy ? "npm run deploy" : ""
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
async function detectGitSource(root, fingerprint, options, includedFiles) {
|
|
173
|
+
const gitRoot = options.sourceBoundary === "root"
|
|
174
|
+
? (existsSync(join(root, ".git")) ? root : undefined)
|
|
175
|
+
: findGitRoot(root);
|
|
176
|
+
if (!gitRoot || !allDeployableFilesTracked(gitRoot, root, includedFiles)) {
|
|
177
|
+
const source = {
|
|
178
|
+
schema: "harbour.source-ref/1.0",
|
|
179
|
+
kind: "unversioned_workspace",
|
|
180
|
+
locator: options.sourceLocator ?? `workspace://${basename(root)}`,
|
|
181
|
+
revision: fingerprint,
|
|
182
|
+
recoverable: false
|
|
183
|
+
};
|
|
184
|
+
if (options.sourceProvider)
|
|
185
|
+
source.provider = options.sourceProvider;
|
|
186
|
+
return source;
|
|
187
|
+
}
|
|
188
|
+
const gitDir = await resolveGitDir(gitRoot);
|
|
189
|
+
const headPath = join(gitDir, "HEAD");
|
|
190
|
+
let revision = fingerprint;
|
|
191
|
+
let locator = options.sourceLocator ?? await readFirstRemoteUrl(gitRoot) ?? `git://${basename(gitRoot)}`;
|
|
192
|
+
try {
|
|
193
|
+
const head = (await readFile(headPath, "utf8")).trim();
|
|
194
|
+
if (head.startsWith("ref:")) {
|
|
195
|
+
const ref = head.replace("ref:", "").trim();
|
|
196
|
+
const commit = await readFile(join(gitDir, ref), "utf8").then(value => value.trim()).catch(() => "");
|
|
197
|
+
revision = commit ? `${commit}@${fingerprint}` : `${ref}@${fingerprint}`;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
revision = `${head}@${fingerprint}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
revision = fingerprint;
|
|
205
|
+
}
|
|
206
|
+
const source = {
|
|
207
|
+
schema: "harbour.source-ref/1.0",
|
|
208
|
+
kind: "connected_repository",
|
|
209
|
+
locator,
|
|
210
|
+
revision,
|
|
211
|
+
recoverable: true
|
|
212
|
+
};
|
|
213
|
+
source.provider = options.sourceProvider ?? detectGitProvider(locator);
|
|
214
|
+
return source;
|
|
215
|
+
}
|
|
216
|
+
function allDeployableFilesTracked(gitRoot, appRoot, includedFiles) {
|
|
217
|
+
if (includedFiles.length === 0)
|
|
218
|
+
return false;
|
|
219
|
+
const appPathFromGitRoot = relative(gitRoot, appRoot);
|
|
220
|
+
const tracked = spawnSync("git", ["-C", gitRoot, "ls-files", "--", appPathFromGitRoot || "."], {
|
|
221
|
+
encoding: "utf8"
|
|
222
|
+
});
|
|
223
|
+
if (tracked.status !== 0)
|
|
224
|
+
return false;
|
|
225
|
+
const trackedPaths = new Set(tracked.stdout.split(/\r?\n/).filter(Boolean));
|
|
226
|
+
return includedFiles.every(file => {
|
|
227
|
+
const repoRelativePath = appPathFromGitRoot ? join(appPathFromGitRoot, file) : file;
|
|
228
|
+
return trackedPaths.has(repoRelativePath);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function findGitRoot(start) {
|
|
232
|
+
let current = start;
|
|
233
|
+
while (true) {
|
|
234
|
+
if (existsSync(join(current, ".git")))
|
|
235
|
+
return current;
|
|
236
|
+
const parent = dirname(current);
|
|
237
|
+
if (parent === current)
|
|
238
|
+
return undefined;
|
|
239
|
+
current = parent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function resolveGitDir(gitRoot) {
|
|
243
|
+
const dotGit = join(gitRoot, ".git");
|
|
244
|
+
const info = await stat(dotGit);
|
|
245
|
+
if (info.isDirectory())
|
|
246
|
+
return dotGit;
|
|
247
|
+
const content = await readFile(dotGit, "utf8");
|
|
248
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
249
|
+
if (!match?.[1])
|
|
250
|
+
return dotGit;
|
|
251
|
+
const gitDir = match[1];
|
|
252
|
+
return gitDir.startsWith("/") ? gitDir : join(gitRoot, gitDir);
|
|
253
|
+
}
|
|
254
|
+
async function readFirstRemoteUrl(gitRoot) {
|
|
255
|
+
try {
|
|
256
|
+
const config = await readFile(join(await resolveGitDir(gitRoot), "config"), "utf8");
|
|
257
|
+
const match = /\[remote "origin"\][\s\S]*?\n\s*url = (.+)/.exec(config) ?? /\[remote "[^"]+"\][\s\S]*?\n\s*url = (.+)/.exec(config);
|
|
258
|
+
return match?.[1]?.trim();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function detectGitProvider(locator) {
|
|
265
|
+
if (/^(https:\/\/|git@)github\.com[:/]/i.test(locator) || /\/\/github\.com\//i.test(locator))
|
|
266
|
+
return "github";
|
|
267
|
+
if (/^(https:\/\/|git@)gitlab\.com[:/]/i.test(locator) || /\/\/gitlab\.com\//i.test(locator))
|
|
268
|
+
return "gitlab";
|
|
269
|
+
if (/^(https:\/\/|git@)bitbucket\.org[:/]/i.test(locator) || /\/\/bitbucket\.org\//i.test(locator))
|
|
270
|
+
return "bitbucket";
|
|
271
|
+
return "git";
|
|
272
|
+
}
|
|
273
|
+
export async function scanWorkspace(root, options = {}) {
|
|
274
|
+
const unknowns = [];
|
|
275
|
+
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");
|
|
278
|
+
const workspaceFingerprint = await sha256(fingerprintInput);
|
|
279
|
+
const { dependencies, scripts, packageManagers } = parsePackageJson(files);
|
|
280
|
+
const framework = detectFramework(dependencies, files);
|
|
281
|
+
// Secret-bearing files remain visible only in the exclusion policy. They
|
|
282
|
+
// must never be part of the deployable/source-control file boundary.
|
|
283
|
+
const includedFiles = files
|
|
284
|
+
.filter(file => !SECRET_FILE_NAMES.has(file.basename))
|
|
285
|
+
.map(file => file.path)
|
|
286
|
+
.sort();
|
|
287
|
+
const graphWithoutDigest = {
|
|
288
|
+
schema: "harbour.application-graph/1.0",
|
|
289
|
+
workspaceFingerprint,
|
|
290
|
+
source: await detectGitSource(root, workspaceFingerprint, options, includedFiles),
|
|
291
|
+
deploymentScope: {
|
|
292
|
+
appRoot: basename(root),
|
|
293
|
+
includedFiles,
|
|
294
|
+
excludedDirectories: [...IGNORED_DIRS].sort(),
|
|
295
|
+
excludedSecretFiles: [...SECRET_FILE_NAMES].sort(),
|
|
296
|
+
deployCommands: detectDeployCommands(scripts)
|
|
297
|
+
},
|
|
298
|
+
components: detectComponents(framework, dependencies, files),
|
|
299
|
+
capabilities: detectCapabilities(files, dependencies),
|
|
300
|
+
packageManagers,
|
|
301
|
+
dependencyNames: dependencies,
|
|
302
|
+
environmentVariableNames: detectEnvNames(files),
|
|
303
|
+
testCommands: detectTestCommands(scripts),
|
|
304
|
+
analysis: {
|
|
305
|
+
analyzerVersion: ANALYZER_VERSION,
|
|
306
|
+
coverage: unknowns.length ? "PARTIAL" : "COMPLETE_FOR_SUPPORTED_MANIFESTS",
|
|
307
|
+
unknowns
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
return { ...graphWithoutDigest, graphDigest: await sha256(graphWithoutDigest) };
|
|
311
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = "2025-11-25";
|
|
2
|
+
export function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
export function requireString(object, key) {
|
|
6
|
+
const value = object[key];
|
|
7
|
+
if (typeof value !== "string" || value.length === 0)
|
|
8
|
+
throw new Error(`VALIDATION_FAILED: ${key} must be a non-empty string`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function assertApplicationGraph(value) {
|
|
12
|
+
if (!isRecord(value))
|
|
13
|
+
throw new Error("VALIDATION_FAILED: graph must be an object");
|
|
14
|
+
if (value.schema !== "harbour.application-graph/1.0")
|
|
15
|
+
throw new Error("VALIDATION_FAILED: unsupported graph schema");
|
|
16
|
+
requireString(value, "workspaceFingerprint");
|
|
17
|
+
const graphDigest = requireString(value, "graphDigest");
|
|
18
|
+
// Some coding surfaces emit a bare hexadecimal digest while others include
|
|
19
|
+
// the algorithm prefix. Normalize at the Harbour boundary so one client
|
|
20
|
+
// formatting difference cannot invalidate the later upload/save steps.
|
|
21
|
+
if (/^[0-9a-f]{64}$/i.test(graphDigest))
|
|
22
|
+
value.graphDigest = `sha256:${graphDigest}`;
|
|
23
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(value.graphDigest))
|
|
24
|
+
throw new Error("VALIDATION_FAILED: graphDigest must be a SHA-256 digest");
|
|
25
|
+
if (!isRecord(value.source))
|
|
26
|
+
throw new Error("VALIDATION_FAILED: source is required");
|
|
27
|
+
requireString(value.source, "revision");
|
|
28
|
+
if (!isRecord(value.deploymentScope))
|
|
29
|
+
throw new Error("VALIDATION_FAILED: deploymentScope is required");
|
|
30
|
+
if (!Array.isArray(value.deploymentScope.includedFiles) || value.deploymentScope.includedFiles.length === 0) {
|
|
31
|
+
throw new Error("VALIDATION_FAILED: deploymentScope.includedFiles must include at least one file");
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(value.components) || value.components.length === 0)
|
|
34
|
+
throw new Error("VALIDATION_FAILED: at least one component is required");
|
|
35
|
+
const serialized = JSON.stringify(value);
|
|
36
|
+
if (serialized.length > 65_536)
|
|
37
|
+
throw new Error("MANIFEST_TOO_LARGE: application graph exceeds 64 KiB");
|
|
38
|
+
if (/secret_value|private_key|access_token|refresh_token/i.test(serialized)) {
|
|
39
|
+
throw new Error("RAW_SECRET_REJECTED: graph contains a prohibited field or value marker");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function assertIdentityProfile(value) {
|
|
43
|
+
if (!isRecord(value) || value.schema !== "harbour.enterprise-identity-profile/1.0") {
|
|
44
|
+
throw new Error("VALIDATION_FAILED: unsupported identity profile schema");
|
|
45
|
+
}
|
|
46
|
+
requireString(value, "profileId");
|
|
47
|
+
requireString(value, "issuerRef");
|
|
48
|
+
requireString(value, "clientIdRef");
|
|
49
|
+
if (!Array.isArray(value.requiredScopes))
|
|
50
|
+
throw new Error("VALIDATION_FAILED: requiredScopes must be an array");
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function stableValue(value) {
|
|
2
|
+
if (Array.isArray(value))
|
|
3
|
+
return value.map(stableValue);
|
|
4
|
+
if (value && typeof value === "object") {
|
|
5
|
+
const output = {};
|
|
6
|
+
for (const key of Object.keys(value).sort()) {
|
|
7
|
+
output[key] = stableValue(value[key]);
|
|
8
|
+
}
|
|
9
|
+
return output;
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function stableJson(value) {
|
|
14
|
+
return JSON.stringify(stableValue(value));
|
|
15
|
+
}
|
|
16
|
+
export async function sha256(value) {
|
|
17
|
+
const bytes = new TextEncoder().encode(typeof value === "string" ? value : stableJson(value));
|
|
18
|
+
return sha256Bytes(bytes);
|
|
19
|
+
}
|
|
20
|
+
export async function sha256Bytes(bytes) {
|
|
21
|
+
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes).buffer);
|
|
22
|
+
return `sha256:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
23
|
+
}
|
|
24
|
+
export async function verifyDigest(value, expected) {
|
|
25
|
+
return (await sha256(value)) === expected;
|
|
26
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { sha256, sha256Bytes, stableJson } from "./digest.js";
|
|
2
|
+
export class MemorySourceArchiveStore {
|
|
3
|
+
archives = new Map();
|
|
4
|
+
async put(objectKey, archive) { this.archives.set(objectKey, clone(archive)); }
|
|
5
|
+
async get(objectKey) {
|
|
6
|
+
const value = this.archives.get(objectKey);
|
|
7
|
+
return value ? clone(value) : undefined;
|
|
8
|
+
}
|
|
9
|
+
async delete(objectKey) { this.archives.delete(objectKey); }
|
|
10
|
+
}
|
|
11
|
+
export async function createSourceManifest(input) {
|
|
12
|
+
const files = await Promise.all(input.files.map(async (file) => {
|
|
13
|
+
const bytes = toBytes(file.content);
|
|
14
|
+
return { path: file.path, sha256: await sha256Bytes(bytes), bytes: bytes.byteLength };
|
|
15
|
+
}));
|
|
16
|
+
const draft = { schema: "harbour.source-manifest/1.0", tenantId: input.tenantId, appId: input.appId, operationId: input.operationId, graphDigest: input.graphDigest, files };
|
|
17
|
+
const manifest = { ...draft, manifestDigest: await sha256(draft) };
|
|
18
|
+
validateSourceManifest(manifest);
|
|
19
|
+
return manifest;
|
|
20
|
+
}
|
|
21
|
+
export async function createSourceArchive(manifest, files) {
|
|
22
|
+
validateSourceManifest(manifest);
|
|
23
|
+
const encodedFiles = files.map(file => ({ path: file.path, contentBase64: bytesToBase64(toBytes(file.content)) }));
|
|
24
|
+
const draft = { schema: "harbour.source-archive/1.0", manifest, files: encodedFiles };
|
|
25
|
+
const archive = { ...draft, archiveDigest: await sha256(draft) };
|
|
26
|
+
await validateSourceArchive(archive);
|
|
27
|
+
return archive;
|
|
28
|
+
}
|
|
29
|
+
export function createSourceUploadIntent(input) {
|
|
30
|
+
validateSourceManifest(input.manifest);
|
|
31
|
+
if (input.manifest.tenantId !== input.tenantId || input.manifest.appId !== input.appId || input.manifest.operationId !== input.operationId) {
|
|
32
|
+
throw new Error("SOURCE_UPLOAD_INTENT_SCOPE_MISMATCH: manifest does not belong to this tenant, app, and operation.");
|
|
33
|
+
}
|
|
34
|
+
const now = input.now ?? new Date();
|
|
35
|
+
const expiry = new Date(now.getTime() + (input.ttlMs ?? 15 * 60_000));
|
|
36
|
+
const nonce = crypto.randomUUID();
|
|
37
|
+
return {
|
|
38
|
+
schema: "harbour.source-upload-intent/1.0",
|
|
39
|
+
intentId: `upload-${nonce}`,
|
|
40
|
+
tenantId: input.tenantId,
|
|
41
|
+
appId: input.appId,
|
|
42
|
+
operationId: input.operationId,
|
|
43
|
+
objectKey: `source-staging/${input.tenantId}/${input.appId}/${input.operationId}/${nonce}.json`,
|
|
44
|
+
manifestDigest: input.manifest.manifestDigest,
|
|
45
|
+
preparedAt: now.toISOString(),
|
|
46
|
+
expiresAt: expiry.toISOString(),
|
|
47
|
+
used: false
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function acceptSourceUpload(input) {
|
|
51
|
+
const { intent, archive } = input;
|
|
52
|
+
if (intent.schema !== "harbour.source-upload-intent/1.0" || intent.used)
|
|
53
|
+
throw new Error("SOURCE_UPLOAD_INTENT_USED: upload intent is no longer valid.");
|
|
54
|
+
if (new Date(intent.expiresAt).getTime() <= (input.now ?? new Date()).getTime())
|
|
55
|
+
throw new Error("SOURCE_UPLOAD_INTENT_EXPIRED: upload intent has expired.");
|
|
56
|
+
await validateSourceArchive(archive);
|
|
57
|
+
if (archive.manifest.manifestDigest !== intent.manifestDigest
|
|
58
|
+
|| archive.manifest.tenantId !== intent.tenantId
|
|
59
|
+
|| archive.manifest.appId !== intent.appId
|
|
60
|
+
|| archive.manifest.operationId !== intent.operationId) {
|
|
61
|
+
throw new Error("SOURCE_UPLOAD_INTENT_SCOPE_MISMATCH: archive does not match the upload intent.");
|
|
62
|
+
}
|
|
63
|
+
await input.store.put(intent.objectKey, archive);
|
|
64
|
+
return { ...intent, used: true };
|
|
65
|
+
}
|
|
66
|
+
export function validateSourceManifest(manifest) {
|
|
67
|
+
if (manifest.schema !== "harbour.source-manifest/1.0" || !safeId(manifest.tenantId) || !safeId(manifest.appId) || !safeId(manifest.operationId)) {
|
|
68
|
+
throw new Error("SOURCE_MANIFEST_INVALID: manifest identity is invalid.");
|
|
69
|
+
}
|
|
70
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(manifest.graphDigest) || !/^sha256:[a-f0-9]{64}$/i.test(manifest.manifestDigest) || manifest.files.length === 0) {
|
|
71
|
+
throw new Error("SOURCE_MANIFEST_INVALID: manifest must have a digest and at least one file.");
|
|
72
|
+
}
|
|
73
|
+
const paths = new Set();
|
|
74
|
+
for (const file of manifest.files) {
|
|
75
|
+
if (!safeSourcePath(file.path) || paths.has(file.path) || !/^sha256:[a-f0-9]{64}$/i.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
|
|
76
|
+
throw new Error("SOURCE_MANIFEST_INVALID: manifest contains an unsafe or duplicate file.");
|
|
77
|
+
}
|
|
78
|
+
paths.add(file.path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function validateSourceArchive(archive) {
|
|
82
|
+
if (archive.schema !== "harbour.source-archive/1.0")
|
|
83
|
+
throw new Error("SOURCE_ARCHIVE_INVALID: unsupported archive schema.");
|
|
84
|
+
validateSourceManifest(archive.manifest);
|
|
85
|
+
const expectedArchiveDigest = await sha256({ schema: archive.schema, manifest: archive.manifest, files: archive.files });
|
|
86
|
+
if (archive.archiveDigest !== expectedArchiveDigest)
|
|
87
|
+
throw new Error("SOURCE_ARCHIVE_DIGEST_MISMATCH: archive digest does not match its contents.");
|
|
88
|
+
const expectedManifestDigest = await sha256({ schema: archive.manifest.schema, tenantId: archive.manifest.tenantId, appId: archive.manifest.appId, operationId: archive.manifest.operationId, graphDigest: archive.manifest.graphDigest, files: archive.manifest.files });
|
|
89
|
+
if (archive.manifest.manifestDigest !== expectedManifestDigest)
|
|
90
|
+
throw new Error("SOURCE_MANIFEST_DIGEST_MISMATCH: manifest digest does not match its contents.");
|
|
91
|
+
if (archive.files.length !== archive.manifest.files.length)
|
|
92
|
+
throw new Error("SOURCE_ARCHIVE_INCOMPLETE: archive file count does not match its manifest.");
|
|
93
|
+
const files = new Map(archive.files.map(file => [file.path, file]));
|
|
94
|
+
for (const expected of archive.manifest.files) {
|
|
95
|
+
const actual = files.get(expected.path);
|
|
96
|
+
if (!actual || !safeSourcePath(actual.path))
|
|
97
|
+
throw new Error("SOURCE_ARCHIVE_INCOMPLETE: a manifest file is missing from the archive.");
|
|
98
|
+
const bytes = base64ToBytes(actual.contentBase64);
|
|
99
|
+
if (bytes.byteLength !== expected.bytes || await sha256Bytes(bytes) !== expected.sha256) {
|
|
100
|
+
throw new Error("SOURCE_ARCHIVE_FILE_DIGEST_MISMATCH: archived file does not match the manifest.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Safe to persist as evidence: no source content, archive key, or secret values. */
|
|
105
|
+
export function sourceArchiveEvidence(archive) {
|
|
106
|
+
return { manifestDigest: archive.manifest.manifestDigest, archiveDigest: archive.archiveDigest, fileCount: archive.manifest.files.length, totalBytes: archive.manifest.files.reduce((sum, file) => sum + file.bytes, 0) };
|
|
107
|
+
}
|
|
108
|
+
function safeId(value) { return /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value); }
|
|
109
|
+
function safeSourcePath(path) {
|
|
110
|
+
return Boolean(path) && !path.startsWith("/") && !path.includes("\\") && !path.split("/").some(part => !part || part === "." || part === "..")
|
|
111
|
+
// A checked-in .env.example is a configuration template, not runtime
|
|
112
|
+
// credentials. Keep it with the app boundary while rejecting every real
|
|
113
|
+
// .env variant before source staging.
|
|
114
|
+
&& (!/(^|\/)\.env(?:\.|$)/i.test(path) || /(^|\/)\.env\.example$/i.test(path))
|
|
115
|
+
&& !/(^|\/)(?:id_rsa|.*\.pem|.*\.key)$/i.test(path) && !/(^|\/)(?:node_modules|\.git|\.harbour)(?:\/|$)/.test(path);
|
|
116
|
+
}
|
|
117
|
+
function toBytes(value) { return typeof value === "string" ? new TextEncoder().encode(value) : value; }
|
|
118
|
+
function bytesToBase64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
119
|
+
function base64ToBytes(value) {
|
|
120
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
|
|
121
|
+
throw new Error("SOURCE_ARCHIVE_INVALID: file is not valid base64.");
|
|
122
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
123
|
+
}
|
|
124
|
+
function clone(value) { return JSON.parse(stableJson(value)); }
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fourier-labs/harbour",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Harbour productionisation helper",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/Fourier-Labs-AI/harbour-control-plane.git" },
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
"files": ["dist/packages/harbour-cli/src", "dist/src/analyzer.js", "dist/src/contracts.js", "dist/src/digest.js", "dist/src/source-intake.js", "package.json"],
|
|
10
|
+
"engines": { "node": ">=22.13.0" },
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"pack:check": "npm pack --dry-run"
|
|
14
|
+
}
|
|
15
|
+
}
|