@fourier-labs/harbour 0.1.12 → 0.1.13

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.
@@ -78,18 +78,33 @@ export async function login(mcpUrl, tenant, output) {
78
78
  callback.close();
79
79
  }
80
80
  }
81
- export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
81
+ /** Refresh only when the access token is within this margin of expiry. */
82
+ const REFRESH_MARGIN_MS = 5 * 60_000;
83
+ /**
84
+ * Returns a usable access token, refreshing only when the stored one is about
85
+ * to expire. Refresh tokens are single-use, so every CLI process refreshing on
86
+ * start meant two processes at once (a `productionise` still polling and a
87
+ * `status` beside it) spent the same refresh token and one was told to sign in
88
+ * again. A refresh that fails now re-reads the store once: if a sibling
89
+ * process rotated the pair in the meantime, its newer token is used instead.
90
+ */
91
+ export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath(), now = Date.now) {
82
92
  const stored = await loadStoredToken(mcpUrl, tenant, path);
83
93
  if (!stored)
84
94
  return undefined;
85
95
  if (!stored.refreshToken)
86
96
  return stored.accessToken;
97
+ if (stored.accessTokenExpiresAt !== undefined && stored.accessTokenExpiresAt - REFRESH_MARGIN_MS > now())
98
+ return stored.accessToken;
87
99
  try {
88
100
  const refreshed = await token(stored.tokenEndpoint, { grant_type: "refresh_token", client_id: stored.clientId, refresh_token: stored.refreshToken });
89
101
  await saveStoredToken(mcpUrl, tenant, { ...stored, ...refreshed, refreshToken: refreshed.refreshToken ?? stored.refreshToken }, path);
90
102
  return refreshed.accessToken;
91
103
  }
92
104
  catch {
105
+ const rotated = await loadStoredToken(mcpUrl, tenant, path);
106
+ if (rotated && rotated.refreshToken !== stored.refreshToken && (rotated.accessTokenExpiresAt === undefined || rotated.accessTokenExpiresAt - REFRESH_MARGIN_MS > now()))
107
+ return rotated.accessToken;
93
108
  throw new Error("Harbour sign-in expired or was revoked. Run `harbour login` again.");
94
109
  }
95
110
  }
@@ -119,7 +134,7 @@ async function token(endpoint, values) {
119
134
  const response = await json(endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values), redirect: "error" });
120
135
  if (!response.access_token)
121
136
  throw new Error("Harbour sign-in did not return an access token.");
122
- return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}) };
137
+ return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}), ...(typeof response.expires_in === "number" && response.expires_in > 0 ? { accessTokenExpiresAt: Date.now() + response.expires_in * 1_000 } : {}) };
123
138
  }
124
139
  async function json(url, init) { const response = await fetch(url, { ...init, redirect: "error" }); if (!response.ok)
125
140
  throw new Error(`Harbour sign-in request failed (${response.status}).`); return response.json(); }
@@ -1,15 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { RemoteMcpClient } from "./remote-mcp-client.js";
3
3
  import { productionise } from "./productionise.js";
4
- import { safeError, CliError } from "./output.js";
4
+ import { confirmAudience, confirmProfile, dismissSecret, fetchStatus, getAppSetup, listSecrets, outcomeFor, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize, waitForSettled } from "./operations.js";
5
+ import { safeError, CliError, renderSummary } from "./output.js";
5
6
  import { CLI_VERSION } from "./version.js";
6
7
  import { login, logout, refreshStoredToken } from "./auth.js";
7
8
  import { connect, loadConfig, resolveConfig } from "./config.js";
8
9
  const args = process.argv.slice(2);
9
10
  const command = args[0];
10
11
  const connectUrl = args[1];
11
- const rootIndex = args.indexOf("--app-root");
12
- const root = rootIndex >= 0 ? args[rootIndex + 1] : undefined;
12
+ const root = optionValue("--app-root");
13
+ const operationRef = optionValue("--operation");
14
+ const json = args.includes("--json");
15
+ const noWait = args.includes("--no-wait");
16
+ const secretName = optionValue("--name");
17
+ const profileName = optionValue("--name");
18
+ const profileDescription = optionValue("--description");
19
+ const audienceEmails = optionValue("--emails");
20
+ const personal = args.includes("--personal");
21
+ const valueStdin = args.includes("--value-stdin");
22
+ const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
23
+ const wait = args.includes("--wait");
13
24
  const includePaths = [];
14
25
  let optionError;
15
26
  for (let index = 0; index < args.length; index += 1) {
@@ -24,14 +35,38 @@ for (let index = 0; index < args.length; index += 1) {
24
35
  }
25
36
  }
26
37
  const explicitToken = process.env.HARBOUR_TOKEN?.trim() ?? "";
27
- const usage = "Usage: harbour connect <company-start-url> | login | logout | productionise --app-root <path> [--include <relative-path>]... [--json]\nRun `harbour connect <company-start-url>` once, then sign in when Harbour asks.\n";
38
+ const usage = [
39
+ "Usage:",
40
+ " harbour connect <company-start-url>",
41
+ " harbour login | logout",
42
+ " harbour productionise --app-root <path> [--include <relative-path>]... [--no-wait] [--json]",
43
+ " harbour status --operation <reference> [--wait] [--json]",
44
+ " harbour retry --operation <reference> [--no-wait] [--json]",
45
+ " harbour promote --operation <reference> [--no-wait] [--json]",
46
+ " harbour setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
47
+ " harbour profile --operation <reference> [--name <text>] [--description <text>] [--json]",
48
+ " harbour audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
49
+ " harbour secrets list --operation <reference> [--json]",
50
+ " harbour secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
51
+ " harbour secrets dismiss --operation <reference> --name <NAME>",
52
+ "Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
53
+ "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
54
+ "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
55
+ ""
56
+ ].join("\n");
57
+ const OPERATION_COMMANDS = ["status", "retry", "promote", "setup", "profile", "audience", "secrets"];
58
+ const progress = (message) => { process.stderr.write(`${message}\n`); };
28
59
  if (command === "--version" || command === "version") {
29
60
  process.stdout.write(`${CLI_VERSION}\n`);
30
61
  }
31
62
  else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
32
63
  process.stdout.write(usage);
33
64
  }
34
- else if (!["connect", "login", "logout", "productionise"].includes(command) || (command === "connect" && (!connectUrl || connectUrl.startsWith("--"))) || (command === "productionise" && (optionError || !root || root.startsWith("--")))) {
65
+ else if (!["connect", "login", "logout", "productionise", ...OPERATION_COMMANDS].includes(command)
66
+ || (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
67
+ || (command === "productionise" && (optionError || !root))
68
+ || (OPERATION_COMMANDS.includes(command) && !operationRef)
69
+ || (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
35
70
  if (optionError)
36
71
  process.stderr.write(`${optionError}\n`);
37
72
  process.stderr.write(usage);
@@ -51,7 +86,7 @@ else {
51
86
  const url = config.mcpUrl;
52
87
  const tenant = config.tenantId;
53
88
  if (command === "login") {
54
- await login(url, tenant, message => process.stderr.write(`${message}\n`));
89
+ await login(url, tenant, progress);
55
90
  process.stdout.write("Harbour sign-in complete.\n");
56
91
  process.exitCode = 0;
57
92
  }
@@ -65,9 +100,34 @@ else {
65
100
  if (!token)
66
101
  throw new CliError("AUTH_REQUIRED", "Please sign in to Harbour with `harbour login`.");
67
102
  const client = new RemoteMcpClient(url, token, tenant);
68
- const result = await productionise(root, client, message => { process.stderr.write(`${message}\n`); }, tenant, includePaths);
69
- const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
70
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
103
+ let envelope;
104
+ if (command === "productionise") {
105
+ const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait });
106
+ envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
107
+ }
108
+ else {
109
+ let summary;
110
+ if (command === "status")
111
+ summary = wait ? outcomeFor(await waitForSettled(client, operationRef, progress), operationRef).summary : summarize(await fetchStatus(client, operationRef));
112
+ else if (command === "retry")
113
+ summary = await retryDeployment(client, operationRef, progress, { wait: !noWait });
114
+ else if (command === "promote")
115
+ summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait });
116
+ else if (command === "setup")
117
+ summary = { setup: await getAppSetup(client, operationRef) };
118
+ else if (command === "profile")
119
+ summary = { setup: await confirmProfile(client, operationRef, { ...(profileName ? { displayName: profileName } : {}), ...(profileDescription ? { description: profileDescription } : {}) }, progress) };
120
+ else if (command === "audience")
121
+ summary = { setup: await confirmAudience(client, operationRef, audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [], progress) };
122
+ else if (subcommand === "list")
123
+ summary = { secrets: await listSecrets(client, operationRef) };
124
+ else if (subcommand === "set")
125
+ summary = await setSecret(client, operationRef, { name: secretName, ...(personal ? { personal: true } : {}), readValue: valueStdin ? readSecretFromStdin : () => readSecretFromTerminal(`Value for ${secretName} (not shown): `) }, progress);
126
+ else
127
+ summary = await dismissSecret(client, operationRef, secretName, progress);
128
+ envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: operationRef, result: summary };
129
+ }
130
+ process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
71
131
  }
72
132
  }
73
133
  }
@@ -75,7 +135,13 @@ else {
75
135
  const safe = safeError(error);
76
136
  process.stderr.write(`${safe.message}\n`);
77
137
  const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "FAILED", operationStarted: error instanceof CliError ? Boolean(error.operationRef) : false, ...(error instanceof CliError && error.operationRef ? { operationRef: error.operationRef } : {}), error: safe };
78
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
138
+ if (json || command === "productionise")
139
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
79
140
  process.exitCode = 1;
80
141
  }
81
142
  }
143
+ function optionValue(flag) {
144
+ const index = args.indexOf(flag);
145
+ const value = index >= 0 ? args[index + 1] : undefined;
146
+ return value && !value.startsWith("--") ? value : undefined;
147
+ }
@@ -0,0 +1,269 @@
1
+ import { structured } from "./remote-mcp-client.js";
2
+ import { CliError } from "./output.js";
3
+ const DEPLOYMENT_TERMINAL = new Set(["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"]);
4
+ const PRODUCTION_TERMINAL = new Set(["LIVE", "FAILED", "MANUAL_RECOVERY"]);
5
+ export function summarize(status) {
6
+ return {
7
+ ...(status.operation?.stage ? { stage: status.operation.stage } : {}),
8
+ ...(status.sourceSave?.status ? { sourceSave: { status: status.sourceSave.status } } : {}),
9
+ ...(status.deployment ? { deployment: pickDeployment(status.deployment) } : {}),
10
+ ...(status.production ? { production: pickProduction(status.production) } : {}),
11
+ ...(status.waiting ? { waiting: { stage: status.waiting.stage, plainEnglish: status.waiting.plainEnglish } } : {}),
12
+ ...(status.nextAction?.plainEnglish ? { nextStep: status.nextAction.plainEnglish } : {}),
13
+ ...(status.nextTool ? { nextTool: status.nextTool } : {})
14
+ };
15
+ }
16
+ function pickDeployment(value) {
17
+ return {
18
+ state: value.state,
19
+ ...(value.status ? { status: value.status } : {}),
20
+ ...(value.message ? { message: value.message } : {}),
21
+ ...(value.protectedUrl ? { protectedUrl: value.protectedUrl } : {}),
22
+ ...(value.versionId ? { versionId: value.versionId } : {}),
23
+ ...(value.failure ? { failure: { ...(value.failure.message ? { message: value.failure.message } : {}), ...(value.failure.retryable !== undefined ? { retryable: value.failure.retryable } : {}), ...(value.failure.remediationHint ? { remediationHint: value.failure.remediationHint } : {}) } } : {})
24
+ };
25
+ }
26
+ function pickProduction(value) {
27
+ return {
28
+ state: value.state,
29
+ ...(value.status ? { status: value.status } : {}),
30
+ ...(value.message ? { message: value.message } : {}),
31
+ ...(value.productionUrl ? { productionUrl: value.productionUrl } : {}),
32
+ ...(value.previewVersionId ? { previewVersionId: value.previewVersionId } : {}),
33
+ ...(value.failure?.message ? { failure: { message: value.failure.message } } : {})
34
+ };
35
+ }
36
+ export async function fetchStatus(client, operationRef, waitSeconds = 0) {
37
+ await client.initialize();
38
+ return structured(await client.call("harbour_get_operation_status", { operationId: operationRef, waitSeconds }));
39
+ }
40
+ /** True when nothing more will change without a person acting. */
41
+ export function isSettled(status) {
42
+ if (status.waiting)
43
+ return true;
44
+ if (status.production)
45
+ return PRODUCTION_TERMINAL.has(status.production.state);
46
+ if (status.deployment)
47
+ return DEPLOYMENT_TERMINAL.has(status.deployment.state);
48
+ return false;
49
+ }
50
+ /**
51
+ * Follow the operation until the preview deployment (or an in-flight production
52
+ * promotion) settles. Harbour long-polls up to 15 s per call and returns early
53
+ * on change, so the loop is bounded by wall clock rather than by call count.
54
+ * `noDeploymentGraceMs` covers the gap between the source save succeeding and
55
+ * the deployment attempt being recorded; past it, a saved operation with no
56
+ * deployment is reported as such instead of waited on forever.
57
+ */
58
+ export async function waitForSettled(client, operationRef, output, options = {}) {
59
+ const now = options.now ?? Date.now;
60
+ const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
61
+ const maxWaitMs = options.maxWaitMs ?? 30 * 60_000;
62
+ const graceMs = options.noDeploymentGraceMs ?? 90_000;
63
+ const startedAt = now();
64
+ let lastLine = "";
65
+ let consecutiveFailures = 0;
66
+ while (true) {
67
+ const callStarted = now();
68
+ let status;
69
+ try {
70
+ status = await fetchStatus(client, operationRef, 15);
71
+ consecutiveFailures = 0;
72
+ }
73
+ catch (error) {
74
+ // A deployment takes minutes and one status call out of dozens can fail
75
+ // transiently (gateway timeout, dropped connection). The operation is
76
+ // unaffected by our polling, so keep watching; give up only after a run
77
+ // of failures, and say what the last one was instead of a generic error.
78
+ consecutiveFailures += 1;
79
+ if (consecutiveFailures >= 5)
80
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour stopped answering status checks (${safeMessage(error)}). The deployment may still be running; check again with \`harbour status\`.`, operationRef);
81
+ output("Harbour did not answer that status check; trying again.");
82
+ await sleep(Math.min(15_000, 3_000 * consecutiveFailures));
83
+ continue;
84
+ }
85
+ if (isSettled(status))
86
+ return status;
87
+ if (!status.deployment && !status.production) {
88
+ if (now() - startedAt > graceMs)
89
+ return status;
90
+ }
91
+ const line = progressLine(status);
92
+ if (line && line !== lastLine) {
93
+ output(line);
94
+ lastLine = line;
95
+ }
96
+ if (now() - startedAt > maxWaitMs)
97
+ throw new CliError("DEPLOYMENT_TIMEOUT", "Harbour is still deploying the app. Check again later with `harbour status`.", operationRef);
98
+ // Harbour returns immediately when nothing is in flight yet; do not spin.
99
+ const elapsed = now() - callStarted;
100
+ if (elapsed < 3_000)
101
+ await sleep(3_000 - elapsed);
102
+ }
103
+ }
104
+ function safeMessage(error) {
105
+ const raw = error instanceof Error ? error.message : String(error);
106
+ return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
107
+ }
108
+ function progressLine(status) {
109
+ if (status.production)
110
+ return `Harbour is promoting the app to production${status.production.message ? ` (${status.production.message})` : ""}.`;
111
+ if (status.deployment)
112
+ return `Harbour is deploying the app${status.deployment.message ? ` (${status.deployment.message})` : ""}.`;
113
+ return "Harbour saved the app and is preparing the deployment.";
114
+ }
115
+ /** The exit-relevant outcome of a settled status: success, a failure, or a step the maker must take. */
116
+ export function outcomeFor(status, operationRef) {
117
+ const summary = summarize(status);
118
+ if (status.production?.state === "LIVE")
119
+ return { kind: "production_live", summary };
120
+ if (status.production && PRODUCTION_TERMINAL.has(status.production.state))
121
+ throw new CliError("PRODUCTION_PROMOTION_FAILED", status.production.failure?.message ?? status.production.message ?? "Harbour could not complete the production promotion.", operationRef);
122
+ if (status.waiting)
123
+ return { kind: "waiting", summary };
124
+ if (status.deployment?.state === "LIVE")
125
+ return { kind: "live", summary };
126
+ if (status.deployment && DEPLOYMENT_TERMINAL.has(status.deployment.state)) {
127
+ const retryable = status.deployment.failure?.retryable === true;
128
+ throw new CliError(retryable ? "DEPLOYMENT_RETRYABLE_FAILURE" : "DEPLOYMENT_FAILED", `${status.deployment.failure?.message ?? status.deployment.message ?? "Harbour could not complete the deployment."}${retryable ? " Run `harbour retry` to start it again." : ""}`, operationRef);
129
+ }
130
+ return { kind: "no_deployment", summary: { ...summary, nextStep: summary.nextStep ?? "Harbour saved the app but has not started a deployment for this company yet. Ask your Harbour administrator to connect deployment." } };
131
+ }
132
+ export async function retryDeployment(client, operationRef, output, options = {}) {
133
+ await client.initialize();
134
+ const result = structured(await client.call("harbour_retry_deployment", { operationId: operationRef }));
135
+ output(result.retry?.replayed ? "Harbour had already accepted this retry." : "Harbour accepted the deployment retry.");
136
+ if (options.wait === false)
137
+ return summarize(await fetchStatus(client, operationRef));
138
+ return outcomeFor(await waitForSettled(client, operationRef, output), operationRef).summary;
139
+ }
140
+ /**
141
+ * Promotion mirrors the console's "I tested this version" button: the maker
142
+ * must have opened the live preview and say so, and the version they tested
143
+ * is pinned so a redeployed preview is refused as stale rather than promoted
144
+ * unseen.
145
+ */
146
+ export async function promoteToProduction(client, operationRef, output, options = {}) {
147
+ const status = await fetchStatus(client, operationRef);
148
+ if (status.production && !PRODUCTION_TERMINAL.has(status.production.state)) {
149
+ output("Harbour is already promoting this app to production.");
150
+ return options.wait === false ? summarize(status) : outcomeFor(await waitForSettled(client, operationRef, output), operationRef).summary;
151
+ }
152
+ const deployment = status.deployment;
153
+ if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
154
+ throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `harbour status` first.", operationRef);
155
+ output(`Open the protected preview and test it: ${deployment.protectedUrl}`);
156
+ output("When it works as expected, type Tested. then press Enter to promote it to production.");
157
+ const reply = await (options.readConfirmation ?? readLine)();
158
+ if (reply !== "Tested.")
159
+ throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed.", operationRef);
160
+ const result = structured(await client.call("harbour_promote_to_production", {
161
+ operationId: operationRef,
162
+ expectedPreviewVersionId: deployment.versionId,
163
+ confirmation: { schema: "harbour.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: reply }
164
+ }));
165
+ output("Harbour accepted the production promotion.");
166
+ if (options.wait === false)
167
+ return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
168
+ return outcomeFor(await waitForSettled(client, operationRef, output), operationRef).summary;
169
+ }
170
+ export async function readLine() {
171
+ if (!process.stdin.isTTY) {
172
+ const chunks = [];
173
+ for await (const chunk of process.stdin)
174
+ chunks.push(Buffer.from(chunk));
175
+ return Buffer.concat(chunks).toString("utf8").trim();
176
+ }
177
+ return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
178
+ }
179
+ export async function getAppSetup(client, operationRef) {
180
+ await client.initialize();
181
+ return structured(await client.call("harbour_get_app_setup", { operationId: operationRef }));
182
+ }
183
+ export async function confirmProfile(client, operationRef, input, output) {
184
+ const setup = await getAppSetup(client, operationRef);
185
+ const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
186
+ const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
187
+ if (!displayName || !description)
188
+ throw new CliError("PROFILE_INCOMPLETE", "Give the app a name (--name) and a description (--description); Harbour had no suggestion to fall back on.", operationRef);
189
+ if (!setup.profile?.profileVersion)
190
+ throw new CliError("PROFILE_VERSION_MISSING", "Harbour did not return the app details version.", operationRef);
191
+ await client.call("harbour_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
192
+ output(`Harbour recorded the app's name and description: ${displayName}.`);
193
+ return getAppSetup(client, operationRef);
194
+ }
195
+ export async function confirmAudience(client, operationRef, emails, output) {
196
+ await client.initialize();
197
+ await client.call("harbour_confirm_app_audience", { operationId: operationRef, emails });
198
+ output(emails.length ? `Harbour recorded the app's audience: ${emails.length} colleague${emails.length === 1 ? "" : "s"}.` : "Harbour recorded that only you can open the app for now.");
199
+ return getAppSetup(client, operationRef);
200
+ }
201
+ export async function listSecrets(client, operationRef) {
202
+ return (await getAppSetup(client, operationRef)).secrets;
203
+ }
204
+ /**
205
+ * The value is read by the CLI itself — from the terminal with echo off, or
206
+ * from stdin when the caller pipes it — and sent straight to Harbour. It is
207
+ * never printed, never placed in the result, and never seen by an AI tool that
208
+ * merely launched this command.
209
+ */
210
+ export async function setSecret(client, operationRef, input, output) {
211
+ await client.initialize();
212
+ const value = (await input.readValue()).replace(/\r?\n$/, "");
213
+ if (!value)
214
+ throw new CliError("SECRET_VALUE_REQUIRED", "No value was entered.", operationRef);
215
+ const result = structured(await client.call("harbour_set_app_secret", { operationId: operationRef, name: input.name, value, scope: input.personal ? "personal" : "app", ...(input.personal ? { acknowledgePersonal: true } : {}) }));
216
+ output(`Harbour stored the value for ${input.name}.${result.resumedOperationIds?.length ? " The paused deployment is resuming." : ""}`);
217
+ return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
218
+ }
219
+ export async function dismissSecret(client, operationRef, name, output) {
220
+ await client.initialize();
221
+ const result = structured(await client.call("harbour_dismiss_app_secret", { operationId: operationRef, name }));
222
+ output(`Harbour recorded that ${name} is not needed.`);
223
+ return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
224
+ }
225
+ /** Read one line from the controlling terminal with echo off, so the value
226
+ * never lands in shell history, scrollback, or a parent process's capture. */
227
+ export async function readSecretFromTerminal(prompt) {
228
+ const { openSync, readSync, closeSync } = await import("node:fs");
229
+ let fd;
230
+ try {
231
+ fd = openSync("/dev/tty", "r+");
232
+ }
233
+ catch {
234
+ throw new CliError("NO_TERMINAL", "No terminal is available to enter the value. Pipe it on stdin with --value-stdin instead.");
235
+ }
236
+ const { writeSync } = await import("node:fs");
237
+ writeSync(fd, prompt);
238
+ const stty = await import("node:child_process");
239
+ try {
240
+ stty.execSync("stty -echo", { stdio: ["inherit", "ignore", "ignore"] });
241
+ }
242
+ catch { /* best effort: some terminals cannot toggle echo */ }
243
+ const chunks = [];
244
+ const buffer = Buffer.alloc(1);
245
+ try {
246
+ while (true) {
247
+ const read = readSync(fd, buffer, 0, 1, null);
248
+ if (read === 0 || buffer[0] === 0x0a)
249
+ break;
250
+ if (buffer[0] !== 0x0d)
251
+ chunks.push(Buffer.from(buffer));
252
+ }
253
+ }
254
+ finally {
255
+ try {
256
+ stty.execSync("stty echo", { stdio: ["inherit", "ignore", "ignore"] });
257
+ }
258
+ catch { /* ignore */ }
259
+ writeSync(fd, "\n");
260
+ closeSync(fd);
261
+ }
262
+ return Buffer.concat(chunks).toString("utf8");
263
+ }
264
+ export async function readSecretFromStdin() {
265
+ const chunks = [];
266
+ for await (const chunk of process.stdin)
267
+ chunks.push(Buffer.from(chunk));
268
+ return Buffer.concat(chunks).toString("utf8");
269
+ }
@@ -4,6 +4,57 @@ export function safeError(error) {
4
4
  const raw = error instanceof CliError ? error.message : friendlyMessage(original);
5
5
  return { code, message: raw.replace(/https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi, "").replace(/\s+/g, " ").trim().slice(0, 240) || "Harbour could not complete the request." };
6
6
  }
7
+ /** Human-readable stdout for runs without `--json`: the operation reference and
8
+ * whatever link or next step Harbour reported, nothing internal. */
9
+ export function renderSummary(envelope) {
10
+ const lines = [];
11
+ const result = (envelope.result ?? {});
12
+ if (envelope.operationRef)
13
+ lines.push(`Operation reference: ${envelope.operationRef}`);
14
+ if (result.verification)
15
+ lines.push(`Saved app: ${result.verification}${result.assuranceLevel ? ` (${result.assuranceLevel})` : ""}`);
16
+ if (result.production) {
17
+ lines.push(`Production: ${result.production.state}${result.production.productionUrl ? ` — ${result.production.productionUrl}` : ""}`);
18
+ if (result.production.failure?.message)
19
+ lines.push(` ${result.production.failure.message}`);
20
+ }
21
+ if (result.deployment) {
22
+ lines.push(`Preview deployment: ${result.deployment.status ?? result.deployment.state}${result.deployment.protectedUrl ? ` — ${result.deployment.protectedUrl}` : ""}`);
23
+ if (result.deployment.failure?.message)
24
+ lines.push(` ${result.deployment.failure.message}${result.deployment.failure.remediationHint ? ` ${result.deployment.failure.remediationHint}` : ""}`);
25
+ }
26
+ if (result.waiting?.plainEnglish)
27
+ lines.push(`Action needed: ${result.waiting.plainEnglish}`);
28
+ else if (result.nextStep)
29
+ lines.push(`Next: ${result.nextStep}`);
30
+ const setup = envelope.result?.setup;
31
+ const secrets = setup?.secrets ?? envelope.result?.secrets;
32
+ if (setup?.profile) {
33
+ lines.push(`App name: ${setup.profile.displayName ?? "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${setup.profile.suggested.displayName}` : " (not confirmed)"}`);
34
+ if (setup.profile?.description || setup.profile?.suggested?.description)
35
+ lines.push(`Description: ${setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description}`);
36
+ if (setup.audience?.available === false)
37
+ lines.push(`Audience: ${setup.audience.plainEnglish ?? "console only"}`);
38
+ else
39
+ lines.push(`Audience: ${setup.audience?.confirmed ? (setup.audience.emails?.length ? setup.audience.emails.join(", ") : "only you") + " (confirmed)" : "not confirmed"}`);
40
+ }
41
+ if (secrets) {
42
+ if (secrets.available === false)
43
+ lines.push(`Secrets: ${secrets.plainEnglish ?? "console only"}`);
44
+ else if (!secrets.asks?.length)
45
+ lines.push("Secrets: none asked for");
46
+ else
47
+ for (const ask of secrets.asks)
48
+ lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
49
+ }
50
+ // productionise carries only the open steps; the setup commands carry the full view.
51
+ const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : step) : [];
52
+ if (openSteps.length)
53
+ lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`harbour setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
54
+ else if (setup?.profile)
55
+ lines.push("Setup complete.");
56
+ return `${lines.join("\n")}\n`;
57
+ }
7
58
  export class CliError extends Error {
8
59
  code;
9
60
  operationRef;
@@ -6,8 +6,10 @@ import { createSourceManifest } from "../../../src/source-intake.js";
6
6
  import { structured } from "./remote-mcp-client.js";
7
7
  import { archiveForManifest, putMultipart } from "./upload.js";
8
8
  import { CliError } from "./output.js";
9
+ import { getAppSetup, outcomeFor, readLine, waitForSettled } from "./operations.js";
9
10
  import { CLI_VERSION } from "./version.js";
10
- export async function productionise(rootArg, client, output, tenantId, includePaths = []) {
11
+ import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
+ export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
11
13
  const root = resolve(rootArg);
12
14
  output(`Harbour is checking ${basename(root)}.`);
13
15
  if (root === parse(root).root || root === resolve(homedir()))
@@ -71,7 +73,37 @@ export async function productionise(rootArg, client, output, tenantId, includePa
71
73
  const evidence = await waitForSave(client, operationRef, graph, execution, output);
72
74
  const report = structured(await client.call("harbour_build_verification_report", { operationId: operationRef, appId, surface: "codex", graph, sourceControlEvidence: evidence.sourceControlEvidence, runnerEvidence: evidence.runnerEvidence }));
73
75
  output("Harbour verified the saved app.");
74
- return { cliVersion: CLI_VERSION, operationRef, result: safeVerificationResult(report, evidence) };
76
+ const verification = safeVerificationResult(report, evidence);
77
+ if (options.waitForDeployment === false)
78
+ return { cliVersion: CLI_VERSION, operationRef, result: verification };
79
+ // The saved app is not the finished product. The same source save that the
80
+ // console follows to "live" starts a preview deployment; follow it here so
81
+ // the maker gets the app's real protected link, not the example one.
82
+ // Past this point the app is saved and verified; a failure here is about
83
+ // following the deployment, not about the save, and must say so.
84
+ let settled;
85
+ try {
86
+ settled = await waitForSettled(client, operationRef, output, options.waitOptions);
87
+ }
88
+ catch (error) {
89
+ if (error instanceof CliError)
90
+ throw error;
91
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour saved and verified the app but could not follow its deployment (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`harbour status\`.`, operationRef);
92
+ }
93
+ const outcome = outcomeFor(settled, operationRef);
94
+ output(outcomeLine(outcome));
95
+ // The console asks for the app's name, audience and secrets before it
96
+ // offers promotion; say what is still open so a CLI-only builder knows.
97
+ let setup;
98
+ try {
99
+ setup = await getAppSetup(client, operationRef);
100
+ }
101
+ catch {
102
+ setup = undefined;
103
+ }
104
+ if (setup?.pending?.length)
105
+ output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`harbour setup --operation ${operationRef}\`.`);
106
+ return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...outcome.summary, outcome: outcome.kind, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
75
107
  }
76
108
  catch (error) {
77
109
  if (error instanceof CliError || (error && typeof error === "object" && "code" in error))
@@ -123,11 +155,6 @@ function boundedWait(value) {
123
155
  function matchesTool(actual, expected) {
124
156
  return actual.replaceAll(".", "_") === expected;
125
157
  }
126
- function isProhibitedSecretPath(path) {
127
- if (/(^|\/)\.env\.example$/i.test(path))
128
- return false;
129
- return /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i.test(path);
130
- }
131
158
  function safeVerificationResult(value, statusEvidence) {
132
159
  if (!value || typeof value !== "object" || Array.isArray(value))
133
160
  return { verification: "completed" };
@@ -151,9 +178,12 @@ function safeVerificationResult(value, statusEvidence) {
151
178
  : {})
152
179
  };
153
180
  }
154
- async function readApproval() { if (!process.stdin.isTTY) {
155
- const chunks = [];
156
- for await (const chunk of process.stdin)
157
- chunks.push(Buffer.from(chunk));
158
- return Buffer.concat(chunks).toString("utf8").trim();
159
- } return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); }); }
181
+ function outcomeLine(outcome) {
182
+ switch (outcome.kind) {
183
+ case "live": return `Harbour deployed the app. Protected preview: ${outcome.summary.deployment?.protectedUrl ?? ""}`.trim();
184
+ case "production_live": return `The app is live in production: ${outcome.summary.production?.productionUrl ?? ""}`.trim();
185
+ case "waiting": return outcome.summary.waiting?.plainEnglish ?? "Harbour needs one more step in the Harbour console before it can deploy.";
186
+ case "no_deployment": return outcome.summary.nextStep ?? "Harbour saved the app; deployment has not started.";
187
+ }
188
+ }
189
+ const readApproval = readLine;
@@ -2,7 +2,7 @@ import { readFile, lstat } from "node:fs/promises";
2
2
  import { relative, resolve, sep } from "node:path";
3
3
  import { deflateRawSync } from "node:zlib";
4
4
  import { sha256Bytes } from "../../../src/digest.js";
5
- const SECRET = /(^|\/)(?:(?!\.env\.example$)\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i;
5
+ import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
6
6
  export async function archiveForManifest(root, manifest) {
7
7
  const rootAbsolute = resolve(root);
8
8
  if ((await lstat(rootAbsolute)).isSymbolicLink())
@@ -10,7 +10,7 @@ export async function archiveForManifest(root, manifest) {
10
10
  const files = [];
11
11
  const seen = new Set();
12
12
  for (const entry of manifest.files) {
13
- if (seen.has(entry.path) || SECRET.test(entry.path) || entry.path.includes("\\") || entry.path.startsWith("/"))
13
+ if (seen.has(entry.path) || isProhibitedSecretPath(entry.path) || entry.path.includes("\\") || entry.path.startsWith("/"))
14
14
  throw new Error("Harbour rejected an unsafe source path.");
15
15
  seen.add(entry.path);
16
16
  const absolute = resolve(rootAbsolute, entry.path);
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.12";
1
+ export const CLI_VERSION = "0.1.13";
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { lstat, readFile, readdir, stat } from "node:fs/promises";
5
5
  import { basename, dirname, join, relative } from "node:path";
6
6
  import { sha256 } from "./digest.js";
7
+ import { isEnvExamplePath, isSourceIntakeExcludedPath } from "./secret-paths.js";
7
8
  const ANALYZER_VERSION = "0.1.0";
8
9
  const MAX_FILES = 600;
9
10
  const MAX_FILE_BYTES = 256_000;
@@ -12,7 +13,7 @@ const MAX_FILE_BYTES = 256_000;
12
13
  // previous save has written a receipt into the selected workspace.
13
14
  const IGNORED_DIRS = new Set([".git", ".harbour", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
14
15
  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
+ 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", ".sh"]);
16
17
  function normalizeIncludePath(value) {
17
18
  if (!value || value === ".")
18
19
  return "";
@@ -66,6 +67,11 @@ async function collectFiles(root, current, output, unknowns) {
66
67
  unknowns.push(`file limit reached at ${MAX_FILES} files`);
67
68
  return;
68
69
  }
70
+ // A linked worktree represents .git as a small pointer file rather than a
71
+ // directory. It is Git metadata and must stay outside the app boundary in
72
+ // both checkout layouts.
73
+ if (entry.name === ".git")
74
+ continue;
69
75
  if (entry.isDirectory()) {
70
76
  if (!IGNORED_DIRS.has(entry.name))
71
77
  await collectFiles(root, join(current, entry.name), output, unknowns);
@@ -85,8 +91,10 @@ async function collectFile(root, absolute, output) {
85
91
  throw new Error("ANALYZER_FILE_UNSAFE: selected app file changed or is not a regular file.");
86
92
  const name = basename(absolute);
87
93
  const fact = { path, basename: name, size: info.size };
88
- const safeEnvExample = name === ".env.example" || name.endsWith(".env.example");
89
- if (!SECRET_FILE_NAMES.has(name) && info.size <= MAX_FILE_BYTES && (TEXT_EXTENSIONS.has(extension(name)) || safeEnvExample)) {
94
+ // Env templates and extensionless shell-context files (Dockerfile, Procfile)
95
+ // carry env-name evidence; without content they can never produce an ask.
96
+ const safeExtensionless = isEnvTemplateFile(name) || isShellContextFile(name);
97
+ if (!SECRET_FILE_NAMES.has(name) && info.size <= MAX_FILE_BYTES && (TEXT_EXTENSIONS.has(extension(name)) || safeExtensionless)) {
90
98
  fact.content = await readFile(absolute, "utf8");
91
99
  }
92
100
  output.push(fact);
@@ -183,14 +191,66 @@ function detectCapabilities(files, dependencies) {
183
191
  }
184
192
  return capabilities;
185
193
  }
194
+ /** Env-access idioms recognized in any retained text file. Names only —
195
+ * capture groups never span a value. */
196
+ const ENV_ACCESS_PATTERNS = [
197
+ // JS/TS dot access and Deno (original rule).
198
+ /(?:process\.env\.|import\.meta\.env\.|Deno\.env\.get\(["'])([A-Z0-9_]{3,})/g,
199
+ // JS/TS bracket access.
200
+ /(?:process\.env|import\.meta\.env)\[["']([A-Z0-9_]{3,})["']\]/g,
201
+ // Python: os.environ["X"], os.environ.get("X").
202
+ /\bos\.environ(?:\.get\(\s*|\[)["']([A-Z0-9_]{3,})["']/g,
203
+ // Go: os.Getenv("X"), os.LookupEnv("X").
204
+ /\bos\.(?:Getenv|LookupEnv)\(\s*"([A-Z0-9_]{3,})"/g,
205
+ // Ruby: ENV["X"], ENV.fetch("X").
206
+ /\bENV(?:\.fetch\(\s*|\[)["']([A-Z0-9_]{3,})["']/g,
207
+ // Java System.getenv, PHP/C getenv, Python os.getenv — all end in getenv(.
208
+ /\bgetenv\(\s*["']([A-Z0-9_]{3,})["']/g
209
+ ];
210
+ /** Shell parameter expansion: ${X}, ${X:-default}, bare $X. Only applied to
211
+ * shell-context content (see isShellContextFile) — `${X}` in prose or make
212
+ * syntax must never become a builder ask. */
213
+ const SHELL_EXPANSION_PATTERN = /\$(?:\{([A-Z0-9_]{3,})(?::?[-=+?][^}]*)?\}|([A-Z0-9_]{3,})\b)/g;
214
+ function isEnvTemplateFile(basename) {
215
+ return isEnvExamplePath(basename);
216
+ }
217
+ /** Files whose content is shell-like end to end. Markdown and source files are
218
+ * deliberately absent; Makefiles too (`${X}` there is make-variable syntax). */
219
+ function isShellContextFile(basename) {
220
+ if (basename === "Dockerfile" || basename.startsWith("Dockerfile."))
221
+ return true;
222
+ if (basename === "Procfile")
223
+ return true;
224
+ if (basename.endsWith(".sh"))
225
+ return true;
226
+ return /^(?:docker-)?compose[^/]*\.ya?ml$/.test(basename);
227
+ }
228
+ function pushMatches(content, pattern, names) {
229
+ for (const match of content.matchAll(pattern)) {
230
+ names.push(match[1] ?? match[2] ?? "");
231
+ }
232
+ }
186
233
  function detectEnvNames(files) {
187
234
  const names = [];
188
235
  for (const file of files) {
189
236
  const content = file.content ?? "";
190
- for (const match of content.matchAll(/(?:process\.env\.|import\.meta\.env\.|Deno\.env\.get\(["'])([A-Z0-9_]{3,})/g)) {
191
- names.push(match[1] ?? "");
237
+ for (const pattern of ENV_ACCESS_PATTERNS)
238
+ pushMatches(content, pattern, names);
239
+ if (isShellContextFile(file.basename))
240
+ pushMatches(content, SHELL_EXPANSION_PATTERN, names);
241
+ // package.json scripts run in a shell; expansion applies to the script
242
+ // strings alone, never to the raw JSON (dependency names, config values).
243
+ if (file.basename === "package.json") {
244
+ try {
245
+ const scripts = JSON.parse(content).scripts ?? {};
246
+ for (const value of Object.values(scripts)) {
247
+ if (typeof value === "string")
248
+ pushMatches(value, SHELL_EXPANSION_PATTERN, names);
249
+ }
250
+ }
251
+ catch { /* Invalid manifests contribute no names. */ }
192
252
  }
193
- if (file.basename === ".env.example" || file.basename.endsWith(".env.example")) {
253
+ if (isEnvTemplateFile(file.basename)) {
194
254
  for (const line of content.split(/\r?\n/)) {
195
255
  const envMatch = /^([A-Z0-9_]{3,})=/.exec(line.trim());
196
256
  if (envMatch)
@@ -349,13 +409,24 @@ const JDBC_SCHEME_DRIVERS = {
349
409
  bigquery: "bigquery",
350
410
  databricks: "databricks"
351
411
  };
412
+ /**
413
+ * Drivers whose service endpoint is a single fixed global host. Only a driver
414
+ * that can never point anywhere else belongs here: a BigQuery client names no
415
+ * host in code or config, so without this its dependency would carry no host
416
+ * at all, and a hostless dependency is dropped before it ever reaches the
417
+ * registry — the source could never be matched and the app would deploy with
418
+ * no grant and no credential.
419
+ */
420
+ const WELL_KNOWN_DRIVER_HOSTS = {
421
+ bigquery: "bigquery.googleapis.com"
422
+ };
423
+ const DRIVER_BY_WELL_KNOWN_HOST = Object.fromEntries(Object.entries(WELL_KNOWN_DRIVER_HOSTS).map(([driver, host]) => [host, driver]));
352
424
  const CONNECTION_STRING_PATTERN = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?):\/\/([^\s"'`<>\\]+)/gi;
353
425
  const JDBC_PATTERN = /\bjdbc:([a-z0-9]+):\/\/([^\s"'`<>\\]+)/gi;
354
426
  const HTTP_URL_PATTERN = /\bhttps?:\/\/([a-z0-9][a-z0-9.-]*\.[a-z]{2,})(?::\d+)?(?=[/"'`\s?#),;]|$)/gi;
355
427
  const BARE_HOSTNAME_PATTERN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i;
356
428
  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";
429
+ return isEnvExamplePath(path);
359
430
  }
360
431
  /** Extract the hostname from a connection-string authority, discarding any
361
432
  * credentials, port, path, or query. Never returns secret material. */
@@ -460,6 +531,15 @@ export function extractDataDependencies(files, declared) {
460
531
  let driver = detected.driver;
461
532
  if (!driver && drivers.has("supabase") && /\.supabase\.(?:co|in)$/i.test(detected.host))
462
533
  driver = "supabase";
534
+ // A well-known service host counts as that driver's host once the client
535
+ // library is a declared dependency, so the env names sitting beside it
536
+ // (the credential, the project) ride along as database identifiers rather
537
+ // than being filed as an unrelated API endpoint.
538
+ if (!driver) {
539
+ const wellKnown = DRIVER_BY_WELL_KNOWN_HOST[detected.host.toLowerCase()];
540
+ if (wellKnown && drivers.has(wellKnown))
541
+ driver = wellKnown;
542
+ }
463
543
  if (driver) {
464
544
  const group = databaseByDriver.get(driver) ?? { hosts: new Set(), envNames: new Set() };
465
545
  group.hosts.add(detected.host);
@@ -471,10 +551,16 @@ export function extractDataDependencies(files, declared) {
471
551
  apiHosts.push(detected);
472
552
  }
473
553
  }
474
- // Manifest drivers with no host still surface as a hostless database entry.
554
+ // Manifest drivers with no host still surface as a database entry, carrying
555
+ // the driver's fixed service host where it has one.
475
556
  for (const driver of drivers) {
476
- if (!databaseByDriver.has(driver))
477
- databaseByDriver.set(driver, { hosts: new Set(), envNames: new Set() });
557
+ if (databaseByDriver.has(driver))
558
+ continue;
559
+ const wellKnownHost = WELL_KNOWN_DRIVER_HOSTS[driver];
560
+ databaseByDriver.set(driver, {
561
+ hosts: new Set(wellKnownHost ? [wellKnownHost] : []),
562
+ envNames: new Set()
563
+ });
478
564
  }
479
565
  const detectedEntries = [];
480
566
  for (const [driver, group] of [...databaseByDriver.entries()].sort(([a], [b]) => a.localeCompare(b))) {
@@ -535,7 +621,7 @@ export async function scanWorkspace(root, options = {}) {
535
621
  // Secret-bearing files remain visible only in the exclusion policy. They
536
622
  // must never be part of the deployable/source-control file boundary.
537
623
  const includedFiles = scopedFiles
538
- .filter(file => !SECRET_FILE_NAMES.has(file.basename))
624
+ .filter(file => !SECRET_FILE_NAMES.has(file.basename) && !isSourceIntakeExcludedPath(file.path))
539
625
  .map(file => file.path)
540
626
  .sort();
541
627
  const graphWithoutDigest = {
@@ -1,5 +1,6 @@
1
1
  export const PROTOCOL_VERSION = "2025-11-25";
2
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 const WAITING_FOR_ANALYSIS_PLAIN_ENGLISH = "Harbour is checking which runtime settings this app needs. It will continue automatically when that check is available.";
3
4
  export function waitingForSecretsPlainEnglish(missingNames) {
4
5
  const count = missingNames.length;
5
6
  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.`;
@@ -7,7 +8,8 @@ export function waitingForSecretsPlainEnglish(missingNames) {
7
8
  /** Stage → plain-English templates for the two waiting stages. */
8
9
  export const OPERATION_WAITING_STAGE_PLAIN_ENGLISH = {
9
10
  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
+ WAITING_FOR_SECRETS: "Your app needs one or more keys entered securely in your Harbour console before it can be deployed.",
12
+ WAITING_FOR_ANALYSIS: WAITING_FOR_ANALYSIS_PLAIN_ENGLISH
11
13
  };
12
14
  export function isRecord(value) {
13
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -45,10 +47,29 @@ export function assertApplicationGraph(value) {
45
47
  const serialized = JSON.stringify(value);
46
48
  if (serialized.length > 65_536)
47
49
  throw new Error("MANIFEST_TOO_LARGE: application graph exceeds 64 KiB");
48
- if (/secret_value|private_key|access_token|refresh_token/i.test(serialized)) {
50
+ // Environment-variable names are metadata, not secret material. Apps commonly
51
+ // declare names such as HIGGSFIELD_ACCESS_TOKEN or SESSION_COOKIE_SECRET; a
52
+ // whole-document substring scan incorrectly rejected those safe declarations.
53
+ // Reject only prohibited field names and assignment-shaped marker values.
54
+ if (containsRawSecretMarker(value)) {
49
55
  throw new Error("RAW_SECRET_REJECTED: graph contains a prohibited field or value marker");
50
56
  }
51
57
  }
58
+ const RAW_SECRET_FIELDS = new Set(["secret_value", "private_key", "access_token", "refresh_token"]);
59
+ function containsRawSecretMarker(value, parentKey) {
60
+ if (parentKey === "environmentVariableNames")
61
+ return false;
62
+ if (Array.isArray(value))
63
+ return value.some(item => containsRawSecretMarker(item, parentKey));
64
+ if (!isRecord(value)) {
65
+ return typeof value === "string" && /(?:secret_value|private_key|access_token|refresh_token)\s*[:=]/i.test(value);
66
+ }
67
+ return Object.entries(value).some(([key, child]) => {
68
+ if (RAW_SECRET_FIELDS.has(key.toLowerCase()))
69
+ return true;
70
+ return containsRawSecretMarker(child, key);
71
+ });
72
+ }
52
73
  export function assertIdentityProfile(value) {
53
74
  if (!isRecord(value) || value.schema !== "harbour.enterprise-identity-profile/1.0") {
54
75
  throw new Error("VALIDATION_FAILED: unsupported identity profile schema");
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Checked-in environment templates are safe source metadata. Real env files
3
+ * remain excluded because they can contain credentials. Keep this predicate
4
+ * shared by scanning, manifest validation, and the CLI archive guard so a
5
+ * template named `.env.mysql.example` is treated consistently everywhere.
6
+ */
7
+ export function isEnvExamplePath(path) {
8
+ return /(^|\/)(?:\.env(?:\.[^/]+)*)\.(?:example|sample|template|dist)$/i.test(path);
9
+ }
10
+ export function isProhibitedSecretPath(path) {
11
+ if (isEnvExamplePath(path))
12
+ return false;
13
+ return /(^|\/)(?:\.env(?:\.[^/]+)?|\.npmrc|id_rsa|[^/]+\.(?:pem|key))$/i.test(path);
14
+ }
15
+ /** Files the intake worker intentionally leaves out of the Git-bound set. */
16
+ export function isSourceIntakeExcludedPath(path) {
17
+ return /(^|\/)(?:__MACOSX)(?:\/|$)|(^|\/)\.DS_Store$/i.test(path)
18
+ || /(^|\/)\.github\/workflows\//i.test(path);
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { sha256, sha256Bytes, stableJson } from "./digest.js";
2
+ import { isProhibitedSecretPath } from "./secret-paths.js";
2
3
  export class MemorySourceArchiveStore {
3
4
  archives = new Map();
4
5
  async put(objectKey, archive) { this.archives.set(objectKey, clone(archive)); }
@@ -109,11 +110,10 @@ export function sourceArchiveEvidence(archive) {
109
110
  function safeId(value) { return /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(value); }
110
111
  function safeSourcePath(path) {
111
112
  return Boolean(path) && !path.startsWith("/") && !path.includes("\\") && !path.split("/").some(part => !part || part === "." || part === "..")
112
- // A checked-in .env.example is a configuration template, not runtime
113
- // credentials. Keep it with the app boundary while rejecting every real
114
- // .env variant before source staging.
115
- && (!/(^|\/)\.env(?:\.|$)/i.test(path) || /(^|\/)\.env\.example$/i.test(path))
116
- && !/(^|\/)(?:id_rsa|.*\.pem|.*\.key)$/i.test(path) && !/(^|\/)(?:node_modules|\.git|\.harbour)(?:\/|$)/.test(path);
113
+ // Checked-in env templates are configuration metadata, not runtime
114
+ // credentials. All other env variants remain excluded before staging.
115
+ && !isProhibitedSecretPath(path)
116
+ && !/(^|\/)(?:node_modules|\.git|\.harbour)(?:\/|$)/.test(path);
117
117
  }
118
118
  function toBytes(value) { return typeof value === "string" ? new TextEncoder().encode(value) : value; }
119
119
  function bytesToBase64(bytes) { return Buffer.from(bytes).toString("base64"); }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },
7
7
  "repository": { "type": "git", "url": "https://github.com/Fourier-Labs-AI/harbour-governance-control-plane.git" },
8
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"],
9
+ "files": ["dist/packages/harbour-cli/src", "dist/src/analyzer.js", "dist/src/contracts.js", "dist/src/digest.js", "dist/src/secret-paths.js", "dist/src/source-intake.js", "package.json"],
10
10
  "engines": { "node": ">=22.13.0" },
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.json",