@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.
- package/dist/packages/harbour-cli/src/auth.js +17 -2
- package/dist/packages/harbour-cli/src/cli.js +76 -10
- package/dist/packages/harbour-cli/src/operations.js +269 -0
- package/dist/packages/harbour-cli/src/output.js +51 -0
- package/dist/packages/harbour-cli/src/productionise.js +43 -13
- package/dist/packages/harbour-cli/src/upload.js +2 -2
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +98 -12
- package/dist/src/contracts.js +23 -2
- package/dist/src/secret-paths.js +19 -0
- package/dist/src/source-intake.js +5 -5
- package/package.json +2 -2
|
@@ -78,18 +78,33 @@ export async function login(mcpUrl, tenant, output) {
|
|
|
78
78
|
callback.close();
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
|
|
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 {
|
|
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
|
|
12
|
-
const
|
|
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 =
|
|
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)
|
|
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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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) ||
|
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.1.13";
|
package/dist/src/analyzer.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
477
|
-
|
|
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 = {
|
package/dist/src/contracts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
113
|
-
// credentials.
|
|
114
|
-
|
|
115
|
-
&&
|
|
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.
|
|
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",
|