@fourier-labs/harbour 0.1.11 → 0.1.12
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.
|
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import { scanWorkspace } from "../../../src/analyzer.js";
|
|
5
5
|
import { createSourceManifest } from "../../../src/source-intake.js";
|
|
6
6
|
import { structured } from "./remote-mcp-client.js";
|
|
7
|
-
import { archiveForManifest,
|
|
7
|
+
import { archiveForManifest, putMultipart } from "./upload.js";
|
|
8
8
|
import { CliError } from "./output.js";
|
|
9
9
|
import { CLI_VERSION } from "./version.js";
|
|
10
10
|
export async function productionise(rootArg, client, output, tenantId, includePaths = []) {
|
|
@@ -39,23 +39,28 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
39
39
|
const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph }));
|
|
40
40
|
if (submitted.nextTool && !matchesTool(submitted.nextTool, "harbour_prepare_source_upload"))
|
|
41
41
|
throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
|
|
42
|
-
const appId =
|
|
42
|
+
const appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
|
|
43
|
+
if (!appId)
|
|
44
|
+
throw new CliError("APP_IDENTITY_MISSING", "Harbour did not return the app identity for this operation.", operationRef);
|
|
43
45
|
const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
|
|
44
|
-
const
|
|
46
|
+
const preliminaryArchive = await archiveForManifest(root, manifest);
|
|
47
|
+
const prepared = structured(await client.call("harbour_prepare_source_upload", { operationId: operationRef, appId, surface: "codex", graph, format: "zip", filename: `${appId}.zip`, compressedBytes: preliminaryArchive.body.byteLength, manifest: { schema: "harbour.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
|
|
45
48
|
if (!prepared.acceptedManifest || !prepared.sourceUpload)
|
|
46
49
|
throw new CliError("UPLOAD_CONTRACT_MISSING", "Harbour did not return a safe upload contract.", operationRef);
|
|
47
|
-
if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "
|
|
50
|
+
if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "harbour_complete_source_upload"))
|
|
48
51
|
throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
|
|
49
52
|
const archive = await archiveForManifest(root, prepared.acceptedManifest);
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Harbour is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
|
|
54
|
+
await client.call("harbour_complete_source_upload", { operationId: operationRef, intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest });
|
|
55
|
+
output("Harbour uploaded the secure app package and is inspecting it.");
|
|
56
|
+
await waitForIntake(client, operationRef);
|
|
57
|
+
let execution = await execute(client, operationRef, appId, graph);
|
|
53
58
|
if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
|
|
54
59
|
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
60
|
const approval = await readApproval();
|
|
56
61
|
if (approval !== "Approved.")
|
|
57
62
|
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."] });
|
|
63
|
+
execution = await execute(client, operationRef, appId, 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
64
|
}
|
|
60
65
|
if (execution.status === "ADMIN_SETUP_REQUIRED")
|
|
61
66
|
throw new CliError("ADMIN_SETUP_REQUIRED", "The company code connection needs one-time administrator setup.", operationRef);
|
|
@@ -64,7 +69,7 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
64
69
|
if (execution.status === "RETRYABLE_FAILURE")
|
|
65
70
|
throw new CliError("RETRYABLE_FAILURE", "Harbour could not complete the save yet. Resume this operation safely.", operationRef);
|
|
66
71
|
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 }));
|
|
72
|
+
const report = structured(await client.call("harbour_build_verification_report", { operationId: operationRef, appId, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
|
|
68
73
|
output("Harbour verified the saved app.");
|
|
69
74
|
return { cliVersion: CLI_VERSION, operationRef, result: safeVerificationResult(report, evidence) };
|
|
70
75
|
}
|
|
@@ -74,8 +79,18 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
74
79
|
throw new CliError("OPERATION_FAILED", "Harbour could not complete the started operation.", operationRef);
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
|
-
async function
|
|
78
|
-
|
|
82
|
+
async function waitForIntake(client, operationRef) {
|
|
83
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
84
|
+
const status = structured(await client.call("harbour_get_operation_status", { operationId: operationRef, waitSeconds: 3 }));
|
|
85
|
+
if (status.operation?.stage === "awaiting-approval" || status.sourceSave?.status === "QUEUED")
|
|
86
|
+
return;
|
|
87
|
+
if (status.operation?.stage === "failed")
|
|
88
|
+
throw new CliError("PACKAGE_REJECTED", "Harbour could not safely accept this app package.", operationRef);
|
|
89
|
+
}
|
|
90
|
+
throw new CliError("INSPECTION_TIMEOUT", "Harbour is still inspecting the app package. Resume this operation safely.", operationRef);
|
|
91
|
+
}
|
|
92
|
+
async function execute(client, operationRef, appId, graph, approval) {
|
|
93
|
+
const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
|
|
79
94
|
if (approval)
|
|
80
95
|
args.approval = approval;
|
|
81
96
|
return structured(await client.call("harbour_execute_source_control", args)).sourceControlExecution ?? {};
|
|
@@ -136,7 +151,6 @@ function safeVerificationResult(value, statusEvidence) {
|
|
|
136
151
|
: {})
|
|
137
152
|
};
|
|
138
153
|
}
|
|
139
|
-
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"; }
|
|
140
154
|
async function readApproval() { if (!process.stdin.isTTY) {
|
|
141
155
|
const chunks = [];
|
|
142
156
|
for await (const chunk of process.stdin)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, lstat } from "node:fs/promises";
|
|
2
2
|
import { relative, resolve, sep } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { deflateRawSync } from "node:zlib";
|
|
4
4
|
import { sha256Bytes } from "../../../src/digest.js";
|
|
5
5
|
const SECRET = /(^|\/)(?:(?!\.env\.example$)\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i;
|
|
6
6
|
export async function archiveForManifest(root, manifest) {
|
|
@@ -30,9 +30,110 @@ export async function archiveForManifest(root, manifest) {
|
|
|
30
30
|
throw new Error(`The app changed while it was being packaged (${entry.path}).`);
|
|
31
31
|
files.push({ path: relative(rootAbsolute, absolute).split(sep).join("/"), content });
|
|
32
32
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const body = new Uint8Array(deterministicZip(files.sort((left, right) => left.path.localeCompare(right.path))));
|
|
34
|
+
return { body, digest: await sha256Bytes(body) };
|
|
35
|
+
}
|
|
36
|
+
function deterministicZip(files) {
|
|
37
|
+
const local = [];
|
|
38
|
+
const central = [];
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const name = Buffer.from(file.path, "utf8");
|
|
42
|
+
const raw = Buffer.from(file.content);
|
|
43
|
+
const compressed = deflateRawSync(raw, { level: 9 });
|
|
44
|
+
const crc = crc32(raw);
|
|
45
|
+
const header = Buffer.alloc(30);
|
|
46
|
+
header.writeUInt32LE(0x04034b50, 0);
|
|
47
|
+
header.writeUInt16LE(20, 4);
|
|
48
|
+
header.writeUInt16LE(0x0800, 6);
|
|
49
|
+
header.writeUInt16LE(8, 8);
|
|
50
|
+
header.writeUInt16LE(0, 10);
|
|
51
|
+
header.writeUInt16LE(0x21, 12);
|
|
52
|
+
header.writeUInt32LE(crc, 14);
|
|
53
|
+
header.writeUInt32LE(compressed.byteLength, 18);
|
|
54
|
+
header.writeUInt32LE(raw.byteLength, 22);
|
|
55
|
+
header.writeUInt16LE(name.byteLength, 26);
|
|
56
|
+
const directory = Buffer.alloc(46);
|
|
57
|
+
directory.writeUInt32LE(0x02014b50, 0);
|
|
58
|
+
directory.writeUInt16LE(0x0314, 4);
|
|
59
|
+
directory.writeUInt16LE(20, 6);
|
|
60
|
+
directory.writeUInt16LE(0x0800, 8);
|
|
61
|
+
directory.writeUInt16LE(8, 10);
|
|
62
|
+
directory.writeUInt16LE(0, 12);
|
|
63
|
+
directory.writeUInt16LE(0x21, 14);
|
|
64
|
+
directory.writeUInt32LE(crc, 16);
|
|
65
|
+
directory.writeUInt32LE(compressed.byteLength, 20);
|
|
66
|
+
directory.writeUInt32LE(raw.byteLength, 24);
|
|
67
|
+
directory.writeUInt16LE(name.byteLength, 28);
|
|
68
|
+
directory.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
69
|
+
directory.writeUInt32LE(offset, 42);
|
|
70
|
+
local.push(header, name, compressed);
|
|
71
|
+
central.push(directory, name);
|
|
72
|
+
offset += header.byteLength + name.byteLength + compressed.byteLength;
|
|
73
|
+
}
|
|
74
|
+
const centralBytes = central.reduce((sum, value) => sum + value.byteLength, 0);
|
|
75
|
+
const end = Buffer.alloc(22);
|
|
76
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
77
|
+
end.writeUInt16LE(files.length, 8);
|
|
78
|
+
end.writeUInt16LE(files.length, 10);
|
|
79
|
+
end.writeUInt32LE(centralBytes, 12);
|
|
80
|
+
end.writeUInt32LE(offset, 16);
|
|
81
|
+
return Buffer.concat([...local, ...central, end]);
|
|
82
|
+
}
|
|
83
|
+
function crc32(bytes) {
|
|
84
|
+
let crc = 0xffffffff;
|
|
85
|
+
for (const byte of bytes) {
|
|
86
|
+
crc ^= byte;
|
|
87
|
+
for (let bit = 0; bit < 8; bit += 1)
|
|
88
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
89
|
+
}
|
|
90
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
91
|
+
}
|
|
92
|
+
export async function putMultipart(upload, body, progress) {
|
|
93
|
+
if (body.byteLength !== upload.compressedBytes)
|
|
94
|
+
throw new Error("Harbour ZIP changed after the upload session was prepared.");
|
|
95
|
+
const completed = new Map((upload.acknowledgedParts ?? []).map(part => [part.partNumber, part.etag]));
|
|
96
|
+
let acknowledgedBytes = [...completed.keys()].reduce((sum, partNumber) => sum + Math.min(upload.partSizeBytes, body.byteLength - (partNumber - 1) * upload.partSizeBytes), 0);
|
|
97
|
+
progress?.(acknowledgedBytes, body.byteLength);
|
|
98
|
+
await mapConcurrent(upload.parts, 3, async (part) => {
|
|
99
|
+
const start = (part.partNumber - 1) * upload.partSizeBytes;
|
|
100
|
+
const bytes = body.subarray(start, Math.min(body.byteLength, start + upload.partSizeBytes));
|
|
101
|
+
if (part.url.startsWith("local://")) {
|
|
102
|
+
completed.set(part.partNumber, `local-${part.partNumber}`);
|
|
103
|
+
acknowledgedBytes += bytes.byteLength;
|
|
104
|
+
progress?.(Math.min(acknowledgedBytes, body.byteLength), body.byteLength);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let response;
|
|
108
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
109
|
+
try {
|
|
110
|
+
response = await fetch(part.url, { method: "PUT", headers: part.requiredHeaders ?? {}, body: Buffer.from(bytes), redirect: "error" });
|
|
111
|
+
if (response.ok)
|
|
112
|
+
break;
|
|
113
|
+
if (![408, 429, 500, 502, 503, 504].includes(response.status))
|
|
114
|
+
throw new Error(`Harbour ZIP part upload failed (${response.status}).`);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (attempt === 2)
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
|
|
121
|
+
}
|
|
122
|
+
if (!response?.ok)
|
|
123
|
+
throw new Error(`Harbour ZIP part upload failed (${response?.status ?? "network"}).`);
|
|
124
|
+
const etag = response.headers.get("etag");
|
|
125
|
+
if (!etag)
|
|
126
|
+
throw new Error("Harbour ZIP part upload did not return an ETag.");
|
|
127
|
+
completed.set(part.partNumber, etag);
|
|
128
|
+
acknowledgedBytes += bytes.byteLength;
|
|
129
|
+
progress?.(Math.min(acknowledgedBytes, body.byteLength), body.byteLength);
|
|
130
|
+
});
|
|
131
|
+
return [...completed].sort(([left], [right]) => left - right).map(([partNumber, etag]) => ({ partNumber, etag }));
|
|
132
|
+
}
|
|
133
|
+
async function mapConcurrent(items, concurrency, work) {
|
|
134
|
+
let cursor = 0;
|
|
135
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (cursor < items.length)
|
|
136
|
+
await work(items[cursor++]); }));
|
|
36
137
|
}
|
|
37
138
|
export async function putExact(upload, body) {
|
|
38
139
|
if (upload.url.startsWith("local://"))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.12";
|
package/dist/src/analyzer.js
CHANGED
|
@@ -317,6 +317,204 @@ function detectGitProvider(locator) {
|
|
|
317
317
|
return "bitbucket";
|
|
318
318
|
return "git";
|
|
319
319
|
}
|
|
320
|
+
const MANIFEST_DRIVER_MAP = {
|
|
321
|
+
pg: "postgres",
|
|
322
|
+
postgres: "postgres",
|
|
323
|
+
mssql: "sqlserver",
|
|
324
|
+
tedious: "sqlserver",
|
|
325
|
+
"snowflake-sdk": "snowflake",
|
|
326
|
+
"@google-cloud/bigquery": "bigquery",
|
|
327
|
+
"@databricks/sql": "databricks",
|
|
328
|
+
mysql: "mysql",
|
|
329
|
+
mysql2: "mysql",
|
|
330
|
+
mongodb: "mongodb",
|
|
331
|
+
redis: "redis",
|
|
332
|
+
ioredis: "redis",
|
|
333
|
+
"@supabase/supabase-js": "supabase"
|
|
334
|
+
};
|
|
335
|
+
const CONNECTION_SCHEME_DRIVERS = {
|
|
336
|
+
postgres: "postgres",
|
|
337
|
+
postgresql: "postgres",
|
|
338
|
+
mysql: "mysql",
|
|
339
|
+
mongodb: "mongodb",
|
|
340
|
+
"mongodb+srv": "mongodb",
|
|
341
|
+
redis: "redis",
|
|
342
|
+
rediss: "redis"
|
|
343
|
+
};
|
|
344
|
+
const JDBC_SCHEME_DRIVERS = {
|
|
345
|
+
postgresql: "postgres",
|
|
346
|
+
sqlserver: "sqlserver",
|
|
347
|
+
snowflake: "snowflake",
|
|
348
|
+
mysql: "mysql",
|
|
349
|
+
bigquery: "bigquery",
|
|
350
|
+
databricks: "databricks"
|
|
351
|
+
};
|
|
352
|
+
const CONNECTION_STRING_PATTERN = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?):\/\/([^\s"'`<>\\]+)/gi;
|
|
353
|
+
const JDBC_PATTERN = /\bjdbc:([a-z0-9]+):\/\/([^\s"'`<>\\]+)/gi;
|
|
354
|
+
const HTTP_URL_PATTERN = /\bhttps?:\/\/([a-z0-9][a-z0-9.-]*\.[a-z]{2,})(?::\d+)?(?=[/"'`\s?#),;]|$)/gi;
|
|
355
|
+
const BARE_HOSTNAME_PATTERN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i;
|
|
356
|
+
function isEnvExampleFile(path) {
|
|
357
|
+
const name = path.split("/").pop() ?? path;
|
|
358
|
+
return name === ".env.example" || name.endsWith(".env.example") || name === ".env.sample" || name === ".env.template";
|
|
359
|
+
}
|
|
360
|
+
/** Extract the hostname from a connection-string authority, discarding any
|
|
361
|
+
* credentials, port, path, or query. Never returns secret material. */
|
|
362
|
+
function hostFromAuthority(authority) {
|
|
363
|
+
const withoutCredentials = authority.split("@").pop() ?? "";
|
|
364
|
+
const host = withoutCredentials.split(/[/:?#,]/)[0] ?? "";
|
|
365
|
+
return /^[a-z0-9][a-z0-9.-]*$/i.test(host) ? host.toLowerCase() : undefined;
|
|
366
|
+
}
|
|
367
|
+
function detectManifestDrivers(files) {
|
|
368
|
+
const drivers = new Set();
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
const name = file.path.split("/").pop() ?? file.path;
|
|
371
|
+
if (!file.content)
|
|
372
|
+
continue;
|
|
373
|
+
if (name === "package.json") {
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(file.content);
|
|
376
|
+
for (const dependency of [...Object.keys(parsed.dependencies ?? {}), ...Object.keys(parsed.devDependencies ?? {})]) {
|
|
377
|
+
const driver = MANIFEST_DRIVER_MAP[dependency.toLowerCase()];
|
|
378
|
+
if (driver)
|
|
379
|
+
drivers.add(driver);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Malformed manifest: skip, extraction stays best-effort.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (name === "requirements.txt") {
|
|
387
|
+
for (const line of file.content.split(/\r?\n/)) {
|
|
388
|
+
const requirement = line.trim().split(/[=<>!~\[;\s]/)[0]?.toLowerCase() ?? "";
|
|
389
|
+
const driver = MANIFEST_DRIVER_MAP[requirement];
|
|
390
|
+
if (driver)
|
|
391
|
+
drivers.add(driver);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return drivers;
|
|
396
|
+
}
|
|
397
|
+
function recordHost(hosts, host, driver, envName) {
|
|
398
|
+
const existing = hosts.get(host) ?? { host, driver: undefined, envNames: new Set() };
|
|
399
|
+
if (driver && !existing.driver)
|
|
400
|
+
existing.driver = driver;
|
|
401
|
+
if (envName)
|
|
402
|
+
existing.envNames.add(envName);
|
|
403
|
+
hosts.set(host, existing);
|
|
404
|
+
}
|
|
405
|
+
/** Scan one text fragment for connection strings, JDBC URLs, and http(s)
|
|
406
|
+
* base URLs, recording hostnames (never credentials or secret values). */
|
|
407
|
+
function scanFragmentForHosts(hosts, fragment, envName) {
|
|
408
|
+
for (const match of fragment.matchAll(CONNECTION_STRING_PATTERN)) {
|
|
409
|
+
const driver = CONNECTION_SCHEME_DRIVERS[(match[1] ?? "").toLowerCase()];
|
|
410
|
+
const host = hostFromAuthority(match[2] ?? "");
|
|
411
|
+
if (host)
|
|
412
|
+
recordHost(hosts, host, driver, envName);
|
|
413
|
+
}
|
|
414
|
+
for (const match of fragment.matchAll(JDBC_PATTERN)) {
|
|
415
|
+
const driver = JDBC_SCHEME_DRIVERS[(match[1] ?? "").toLowerCase()];
|
|
416
|
+
const host = hostFromAuthority(match[2] ?? "");
|
|
417
|
+
if (host)
|
|
418
|
+
recordHost(hosts, host, driver, envName);
|
|
419
|
+
}
|
|
420
|
+
for (const match of fragment.matchAll(HTTP_URL_PATTERN)) {
|
|
421
|
+
const host = (match[1] ?? "").toLowerCase();
|
|
422
|
+
if (host)
|
|
423
|
+
recordHost(hosts, host, undefined, envName);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
export function extractDataDependencies(files, declared) {
|
|
427
|
+
const drivers = detectManifestDrivers(files);
|
|
428
|
+
const hosts = new Map();
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
const content = file.content;
|
|
431
|
+
if (!content)
|
|
432
|
+
continue;
|
|
433
|
+
if (isEnvExampleFile(file.path)) {
|
|
434
|
+
// Env-example files associate the env var NAME with any hostname found
|
|
435
|
+
// in its value. Values that are not hostnames/URLs are never emitted.
|
|
436
|
+
for (const line of content.split(/\r?\n/)) {
|
|
437
|
+
const envMatch = /^([A-Z0-9_]{3,})=(.*)$/.exec(line.trim());
|
|
438
|
+
if (!envMatch)
|
|
439
|
+
continue;
|
|
440
|
+
const [, envName, value] = envMatch;
|
|
441
|
+
const trimmed = (value ?? "").trim().replace(/^["']|["']$/g, "");
|
|
442
|
+
scanFragmentForHosts(hosts, trimmed, envName);
|
|
443
|
+
if (BARE_HOSTNAME_PATTERN.test(trimmed)) {
|
|
444
|
+
const host = hostFromAuthority(trimmed);
|
|
445
|
+
if (host)
|
|
446
|
+
recordHost(hosts, host, undefined, envName);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
scanFragmentForHosts(hosts, content, undefined);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Classify each host: database when a DB driver or DB connection-string
|
|
455
|
+
// scheme is implicated for that host, otherwise api. Supabase hosts count
|
|
456
|
+
// as database when the supabase client library is a declared dependency.
|
|
457
|
+
const databaseByDriver = new Map();
|
|
458
|
+
const apiHosts = [];
|
|
459
|
+
for (const detected of hosts.values()) {
|
|
460
|
+
let driver = detected.driver;
|
|
461
|
+
if (!driver && drivers.has("supabase") && /\.supabase\.(?:co|in)$/i.test(detected.host))
|
|
462
|
+
driver = "supabase";
|
|
463
|
+
if (driver) {
|
|
464
|
+
const group = databaseByDriver.get(driver) ?? { hosts: new Set(), envNames: new Set() };
|
|
465
|
+
group.hosts.add(detected.host);
|
|
466
|
+
for (const envName of detected.envNames)
|
|
467
|
+
group.envNames.add(envName);
|
|
468
|
+
databaseByDriver.set(driver, group);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
apiHosts.push(detected);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Manifest drivers with no host still surface as a hostless database entry.
|
|
475
|
+
for (const driver of drivers) {
|
|
476
|
+
if (!databaseByDriver.has(driver))
|
|
477
|
+
databaseByDriver.set(driver, { hosts: new Set(), envNames: new Set() });
|
|
478
|
+
}
|
|
479
|
+
const detectedEntries = [];
|
|
480
|
+
for (const [driver, group] of [...databaseByDriver.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
481
|
+
detectedEntries.push({ kind: "database", identifiers: { hosts: [...group.hosts].sort(), driver, envNames: [...group.envNames].sort() }, origin: "detected" });
|
|
482
|
+
}
|
|
483
|
+
for (const detected of apiHosts.sort((a, b) => a.host.localeCompare(b.host))) {
|
|
484
|
+
detectedEntries.push({ kind: "api", identifiers: { hosts: [detected.host], envNames: [...detected.envNames].sort() }, origin: "detected" });
|
|
485
|
+
}
|
|
486
|
+
// Merge declared entries: declared wins on host collision; origin stays
|
|
487
|
+
// faithful to what the caller supplied (defaulting to "declared").
|
|
488
|
+
const declaredEntries = (declared ?? []).map(entry => ({
|
|
489
|
+
kind: entry.kind,
|
|
490
|
+
identifiers: {
|
|
491
|
+
hosts: [...new Set(entry.identifiers.hosts.map(host => host.toLowerCase()))].sort(),
|
|
492
|
+
...(entry.identifiers.driver ? { driver: entry.identifiers.driver } : {}),
|
|
493
|
+
envNames: [...new Set(entry.identifiers.envNames)].sort()
|
|
494
|
+
},
|
|
495
|
+
origin: entry.origin ?? "declared"
|
|
496
|
+
}));
|
|
497
|
+
const declaredHosts = new Set(declaredEntries.flatMap(entry => entry.identifiers.hosts));
|
|
498
|
+
const merged = [...declaredEntries];
|
|
499
|
+
for (const entry of detectedEntries) {
|
|
500
|
+
const hadHosts = entry.identifiers.hosts.length > 0;
|
|
501
|
+
const survivingHosts = entry.identifiers.hosts.filter(host => !declaredHosts.has(host));
|
|
502
|
+
if (hadHosts && survivingHosts.length === 0)
|
|
503
|
+
continue;
|
|
504
|
+
merged.push({ ...entry, identifiers: { ...entry.identifiers, hosts: survivingHosts } });
|
|
505
|
+
}
|
|
506
|
+
return merged;
|
|
507
|
+
}
|
|
508
|
+
/** Additive export: run the analyzer's existing env-name detection over an
|
|
509
|
+
* in-memory {path, content} file list (e.g. files unpacked from an upload). */
|
|
510
|
+
export function detectEnvironmentVariableNames(files) {
|
|
511
|
+
return detectEnvNames(files.map(file => ({
|
|
512
|
+
path: file.path,
|
|
513
|
+
basename: file.path.split("/").pop() ?? file.path,
|
|
514
|
+
size: file.content?.length ?? 0,
|
|
515
|
+
...(file.content !== undefined ? { content: file.content } : {})
|
|
516
|
+
})));
|
|
517
|
+
}
|
|
320
518
|
export async function scanWorkspace(root, options = {}) {
|
|
321
519
|
const rootInfo = await lstat(root);
|
|
322
520
|
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
|
package/dist/src/contracts.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export const PROTOCOL_VERSION = "2025-11-25";
|
|
2
|
+
export const WAITING_FOR_DATA_ACCESS_PLAIN_ENGLISH = "Your app uses a company data source that isn't set up for apps yet. I've asked your IT admin — I'll continue automatically once it's approved.";
|
|
3
|
+
export function waitingForSecretsPlainEnglish(missingNames) {
|
|
4
|
+
const count = missingNames.length;
|
|
5
|
+
return `Your app uses ${count} key${count === 1 ? "" : "s"} (${missingNames.join(", ")}). Enter them securely in your Harbour console — I'll continue once they're set.`;
|
|
6
|
+
}
|
|
7
|
+
/** Stage → plain-English templates for the two waiting stages. */
|
|
8
|
+
export const OPERATION_WAITING_STAGE_PLAIN_ENGLISH = {
|
|
9
|
+
WAITING_FOR_DATA_ACCESS: WAITING_FOR_DATA_ACCESS_PLAIN_ENGLISH,
|
|
10
|
+
WAITING_FOR_SECRETS: "Your app needs one or more keys entered securely in your Harbour console before it can be deployed."
|
|
11
|
+
};
|
|
2
12
|
export function isRecord(value) {
|
|
3
13
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
14
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
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" },
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/Fourier-Labs-AI/harbour-governance-control-plane.git" },
|
|
8
8
|
"publishConfig": { "access": "public" },
|
|
9
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
10
|
"engines": { "node": ">=22.13.0" },
|