@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, putExact } from "./upload.js";
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 = projectName(graph.deploymentScope.appRoot);
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 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 })) } }));
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, "harbour_execute_source_control"))
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 putExact(prepared.sourceUpload, archive.body);
51
- output("Harbour has prepared the secure app package.");
52
- let execution = await execute(client, operationRef, graph);
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 execute(client, operationRef, graph, approval) {
78
- const args = { operationId: operationRef, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
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 { createSourceArchive } from "../../../src/source-intake.js";
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 archive = await createSourceArchive(manifest, files);
34
- const body = new TextEncoder().encode(JSON.stringify(archive));
35
- return { body, digest: archive.archiveDigest };
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.11";
1
+ export const CLI_VERSION = "0.1.12";
@@ -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())
@@ -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.11",
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" },