@fourier-labs/harbour 0.1.30 → 0.1.31
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/README.md +3 -0
- package/dist/packages/harbour-cli/src/agent-setup.js +3 -2
- package/dist/packages/harbour-cli/src/check.js +27 -8
- package/dist/packages/harbour-cli/src/cli.js +27 -17
- package/dist/packages/harbour-cli/src/integrations.js +54 -2
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +3 -3
- package/dist/packages/harbour-cli/src/operations.js +106 -29
- package/dist/packages/harbour-cli/src/output.js +53 -16
- package/dist/packages/harbour-cli/src/productionise.js +13 -13
- package/dist/packages/harbour-cli/src/starter.js +2 -2
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ harbour init --app-root <path> [--upgrade] starter app in an emp
|
|
|
32
32
|
harbour dev --app-root <path> [--reset] run the app locally on one loopback origin
|
|
33
33
|
harbour stop --app-root <path> stop local services, keep data
|
|
34
34
|
harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys; report in .harbour/local/check-report.json
|
|
35
|
+
harbour integrations catalog --app-root <path> [--json] the company's connections as .harbour/integrations.json names them, with approved channels/views per environment
|
|
35
36
|
harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>]
|
|
36
37
|
harbour integrations status --app-root <path> [--json]
|
|
37
38
|
harbour connect <company-start-url> once per company; then harbour login | logout
|
|
@@ -39,6 +40,8 @@ harbour productionise --app-root <path> [--wait] [--json] save + preview deploy
|
|
|
39
40
|
harbour status | retry | promote | setup | profile | audience | secrets … --operation <reference>
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
`productionise`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `harbour status --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
|
|
44
|
+
|
|
42
45
|
`harbour --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
|
|
43
46
|
|
|
44
47
|
## Development
|
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { CLI_VERSION } from "./version.js";
|
|
8
|
+
import { continueCommand } from "./operations.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
10
|
export const MANAGED_START = "<!-- harbour:kit:start -->";
|
|
10
11
|
export const MANAGED_END = "<!-- harbour:kit:end -->";
|
|
@@ -157,9 +158,9 @@ The person you are working with may not be a developer. They say what they want
|
|
|
157
158
|
- "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start pulls images and takes a minute or two). Wait for the line \`Harbour dev is running: http://127.0.0.1:<port>\` and give them that link. Do this unasked as soon as the first check is green — they should always have the link. Locally they are a fixture user; no company sign-in is needed.
|
|
158
159
|
- "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Failures in the app's code are yours to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
|
|
159
160
|
- "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`harbour check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. In development a Send posts for real to the approved channel — press it once to verify and tell them you did. If you have no browser, say that the button itself is untested.
|
|
160
|
-
- "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Never guess a connection, channel, view or mailbox name — a guessed one is refused before IT's queue ever sees it, so nothing appears for IT to approve; use the exact names the person or IT gave you, and if you have none, that is the one question to ask before declaring anything. Declare the connection and only the operations the app really calls in \`.harbour/integrations.json\` (the closed set is in the per-app block), then \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\`. Request the preview grant in the same turn (\`--environment preview\`) so shipping does not wait on a second IT decision. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the channel, view or mailbox is not on the connection yet: IT adds it in the Harbour console under Controls & integrations → API integrations → the provider tile → Configure → Channels (a warehouse view: Controls & integrations → Databases → the source → Views for the environment), and then you run the same request command again. Say "IT has to add <name> to the Slack connection first; the app works without it until then" and nothing more. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
|
|
161
|
+
- "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Never guess a connection, channel, view or mailbox name — a guessed one is refused before IT's queue ever sees it, so nothing appears for IT to approve; use the exact names the person or IT gave you, and if you have none, that is the one question to ask before declaring anything. Before asking, run \`harbour integrations catalog --app-root . --json\` — it lists the company's connections by the exact identifier \`.harbour/integrations.json\` uses, the operations IT allows on each and the approved channels, views and mailboxes per environment — and declare only identifiers and approved names it lists. Declare the connection and only the operations the app really calls in \`.harbour/integrations.json\` (the closed set is in the per-app block), then \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\`. Request the preview grant in the same turn (\`--environment preview\`) so shipping does not wait on a second IT decision. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the channel, view or mailbox is not on the connection yet: IT adds it in the Harbour console under Controls & integrations → API integrations → the provider tile → Configure → Channels (a warehouse view: Controls & integrations → Databases → the source → Views for the environment), and then you run the same request command again. Say "IT has to add <name> to the Slack connection first; the app works without it until then" and nothing more. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
|
|
161
162
|
- "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (through \`ai()\` in \`src/harbour.client.ts\`) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
|
|
162
|
-
- "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`), and \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. If your tool cuts the command off before it finishes,
|
|
163
|
+
- "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`), and \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. Keep \`operationRef\`; \`harbour setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`harbour profile\` / \`harbour audience\` / \`harbour secrets set\` fill it in.
|
|
163
164
|
- "make it live for everyone", "go to production" → only after they have tried the preview: \`harbour promote --operation <ref> --json\` with the operation reference from productionise. Report the production link, or that an operator approval is pending.
|
|
164
165
|
- "stop it" → \`harbour stop --app-root .\` (local data kept). \`harbour dev --reset --app-root .\` deletes local data — only when they explicitly ask to start over.
|
|
165
166
|
|
|
@@ -37,15 +37,34 @@ export async function runChecks(root, options) {
|
|
|
37
37
|
for (const check of gate.checks)
|
|
38
38
|
record(check);
|
|
39
39
|
let integrations = "not tested";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
if (options.governance) {
|
|
41
|
+
// Reads that could not run are one check the report carries as not_run with
|
|
42
|
+
// the reason, beside the gate's own skipped checks where an agent reads
|
|
43
|
+
// outcomes — not exit 1 with the reason dropped, and not a stderr line beside
|
|
44
|
+
// `"not tested"`, the shape of a run that never asked. Observed 2026-09-12
|
|
45
|
+
// (CLI 0.1.29): every gate check passed, then the grant list's transport
|
|
46
|
+
// failure ended the run as `fetch failed` (CLI_FAILED) with no report written.
|
|
47
|
+
const skipped = (why) => record({ name: "integrations", status: "not_run", detail: why });
|
|
48
|
+
const lock = await readKitLock(root).catch(() => undefined);
|
|
49
|
+
const { declaration, errors } = await readDeclaration(root);
|
|
50
|
+
if (!lock?.appId)
|
|
51
|
+
skipped("the app is not linked yet; `harbour integrations request` links it");
|
|
52
|
+
else if (errors.length)
|
|
53
|
+
skipped(`.harbour/integrations.json is invalid: ${errors[0]}`);
|
|
54
|
+
else {
|
|
55
|
+
try {
|
|
56
|
+
integrations = await testIntegrationReads(lock.appId, options.governance, declaration, output);
|
|
57
|
+
if (!integrations.length)
|
|
58
|
+
output("No READY development grants with read operations; real integrations were not exercised.");
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Governance's answer about the grant list — unreachable, sign-in gone, a refusal — is about the leg, never about the app.
|
|
62
|
+
if (!(error instanceof CliError))
|
|
63
|
+
throw error;
|
|
64
|
+
skipped(error.message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
46
67
|
}
|
|
47
|
-
else if (options.governance)
|
|
48
|
-
output("Integrations were not tested: the app is not linked yet or the declaration is invalid.");
|
|
49
68
|
// After the gate, because `.harbour/checks/*.mjs` is part of the deployed
|
|
50
69
|
// tree (kit.ts `sourceDigest`) and the gate may have regenerated it: the
|
|
51
70
|
// report has to pin the tree the journeys actually ran against, or
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
3
3
|
import { productionise } from "./productionise.js";
|
|
4
|
-
import { confirmAudience, confirmProfile, dismissSecret, fetchStatus, getAppSetup, listSecrets,
|
|
5
|
-
import { CliError, failureEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
4
|
+
import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
|
|
5
|
+
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
6
6
|
import { CLI_VERSION } from "./version.js";
|
|
7
7
|
import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
|
|
8
8
|
import { connect, loadConfig, resolveConfig } from "./config.js";
|
|
@@ -14,7 +14,7 @@ import { startDev } from "./dev.js";
|
|
|
14
14
|
import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
15
15
|
import { stopAllHarbourProjects } from "./docker-networks.js";
|
|
16
16
|
import { runChecks } from "./check.js";
|
|
17
|
-
import { GovernanceClient, integrationsStatus, requestIntegrations } from "./integrations.js";
|
|
17
|
+
import { GovernanceClient, integrationsCatalog, integrationsStatus, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
18
18
|
const args = process.argv.slice(2);
|
|
19
19
|
const command = args[0];
|
|
20
20
|
const connectUrl = args[1];
|
|
@@ -39,8 +39,13 @@ const reason = optionValue("--reason");
|
|
|
39
39
|
const environment = optionValue("--environment");
|
|
40
40
|
const operations = optionValue("--operations");
|
|
41
41
|
const expiresAt = optionValue("--expires-at");
|
|
42
|
+
/** Seconds `productionise`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
|
|
43
|
+
const maxWaitSeconds = args.includes("--max-wait") ? Number(optionValue("--max-wait")) : undefined;
|
|
44
|
+
const waitOptions = maxWaitSeconds ? { maxWaitMs: maxWaitSeconds * 1000 } : {};
|
|
42
45
|
const includePaths = [];
|
|
43
46
|
let optionError;
|
|
47
|
+
if (maxWaitSeconds !== undefined && !(Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0))
|
|
48
|
+
optionError = "--max-wait takes a whole number of seconds.";
|
|
44
49
|
for (let index = 0; index < args.length; index += 1) {
|
|
45
50
|
if (args[index] === "--include") {
|
|
46
51
|
const value = args[index + 1];
|
|
@@ -57,10 +62,10 @@ const usage = [
|
|
|
57
62
|
"Usage:",
|
|
58
63
|
" harbour connect <company-start-url>",
|
|
59
64
|
" harbour login | logout",
|
|
60
|
-
" harbour productionise --app-root <path> [--include <relative-path>]... [--no-wait] [--json]",
|
|
61
|
-
" harbour status --operation <reference> [--wait] [--json]",
|
|
62
|
-
" harbour retry --operation <reference> [--no-wait] [--json]",
|
|
63
|
-
" harbour promote --operation <reference> [--no-wait] [--json]",
|
|
65
|
+
" harbour productionise --app-root <path> [--include <relative-path>]... [--no-wait] [--max-wait <seconds>] [--json]",
|
|
66
|
+
" harbour status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
|
|
67
|
+
" harbour retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
68
|
+
" harbour promote --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
|
|
64
69
|
" harbour setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
|
|
65
70
|
" harbour profile --operation <reference> [--name <text>] [--description <text>] [--json]",
|
|
66
71
|
" harbour audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
|
|
@@ -75,8 +80,10 @@ const usage = [
|
|
|
75
80
|
" harbour check --app-root <path> [--integrations] [--json] types, build, then the pipeline's kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, journeys, operation coverage (+ authorised real reads)",
|
|
76
81
|
" harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
|
|
77
82
|
" harbour integrations status --app-root <path> [--json]",
|
|
83
|
+
" harbour integrations catalog --app-root <path> [--json] the company's connections as .harbour/integrations.json names them: identifiers, allowed operations, approved channels/views/mailboxes per environment (no app needed)",
|
|
78
84
|
"Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
|
|
79
85
|
"productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
|
|
86
|
+
`--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
|
|
80
87
|
"For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make, then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse.",
|
|
81
88
|
"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.",
|
|
82
89
|
""
|
|
@@ -108,9 +115,10 @@ else if (command === "agent-setup") {
|
|
|
108
115
|
}
|
|
109
116
|
else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
110
117
|
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
111
|
-
|| (
|
|
118
|
+
|| (["productionise", "status", "retry", "promote"].includes(command) && optionError)
|
|
119
|
+
|| (command === "productionise" && !root)
|
|
112
120
|
|| (LOCAL_COMMANDS.includes(command) && !root && !(command === "stop" && stopAll))
|
|
113
|
-
|| (command === "integrations" && (!root || !subcommand || !["request", "status"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
|
|
121
|
+
|| (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
|
|
114
122
|
|| (OPERATION_COMMANDS.includes(command) && !operationRef)
|
|
115
123
|
|| (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
|
|
116
124
|
if (optionError)
|
|
@@ -231,24 +239,26 @@ else {
|
|
|
231
239
|
const governance = new GovernanceClient(config.apiUrl, token, tenant);
|
|
232
240
|
const result = subcommand === "status"
|
|
233
241
|
? await integrationsStatus(target, governance)
|
|
234
|
-
:
|
|
242
|
+
: subcommand === "catalog"
|
|
243
|
+
? await integrationsCatalog(governance)
|
|
244
|
+
: await requestIntegrations(target, governance, tenant, EMBEDDED_KIT_BUNDLE, { connection: args[2], reason: reason, environment, operations: operations?.split(",").map(value => value.trim()).filter(Boolean), expiresAt });
|
|
235
245
|
envelope = summaryEnvelope(result);
|
|
236
246
|
if (!json)
|
|
237
|
-
progress(subcommand === "status" ? renderIntegrationsStatus(result) : renderRequest(result));
|
|
247
|
+
progress(subcommand === "status" ? renderIntegrationsStatus(result) : subcommand === "catalog" ? renderIntegrationsCatalog(result) : renderRequest(result));
|
|
238
248
|
}
|
|
239
249
|
else if (command === "productionise") {
|
|
240
250
|
// Kit apps: preview grants are checked (and the app linked) before any operation starts, so productionise never mints a second app for the same root.
|
|
241
|
-
const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE } });
|
|
242
|
-
envelope =
|
|
251
|
+
const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE } });
|
|
252
|
+
envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
|
|
243
253
|
}
|
|
244
254
|
else {
|
|
245
255
|
let summary;
|
|
246
256
|
if (command === "status")
|
|
247
|
-
summary = wait ?
|
|
257
|
+
summary = wait ? await follow(client, operationRef, progress, waitOptions) : summarize(await fetchStatus(client, operationRef));
|
|
248
258
|
else if (command === "retry")
|
|
249
|
-
summary = await retryDeployment(client, operationRef, progress, { wait: !noWait });
|
|
259
|
+
summary = await retryDeployment(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
250
260
|
else if (command === "promote")
|
|
251
|
-
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait });
|
|
261
|
+
summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions });
|
|
252
262
|
else if (command === "setup")
|
|
253
263
|
summary = { setup: await getAppSetup(client, operationRef) };
|
|
254
264
|
else if (command === "profile")
|
|
@@ -261,7 +271,7 @@ else {
|
|
|
261
271
|
summary = await setSecret(client, operationRef, { name: secretName, ...(personal ? { personal: true } : {}), readValue: valueStdin ? readSecretFromStdin : () => readSecretFromTerminal(`Value for ${secretName} (not shown): `) }, progress);
|
|
262
272
|
else
|
|
263
273
|
summary = await dismissSecret(client, operationRef, secretName, progress);
|
|
264
|
-
envelope =
|
|
274
|
+
envelope = operationEnvelope(summary, operationRef, CLI_VERSION);
|
|
265
275
|
}
|
|
266
276
|
process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
|
|
267
277
|
}
|
|
@@ -21,11 +21,17 @@ export class GovernanceClient {
|
|
|
21
21
|
list(appId) {
|
|
22
22
|
return this.call("GET", `/v1/development/apps/${encodeURIComponent(appId)}/integrations?tenantId=${encodeURIComponent(this.tenantId)}`);
|
|
23
23
|
}
|
|
24
|
+
/** Tenant-scoped: needs no linked app, so it answers on a builder's first turn. */
|
|
25
|
+
catalog() {
|
|
26
|
+
return this.call("GET", `/v1/development/integrations/catalog?tenantId=${encodeURIComponent(this.tenantId)}`);
|
|
27
|
+
}
|
|
24
28
|
execute(appId, body) {
|
|
25
29
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integrations/execute`, body);
|
|
26
30
|
}
|
|
27
31
|
async call(method, path, body) {
|
|
28
|
-
const
|
|
32
|
+
const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
|
|
33
|
+
const response = await this.fetchImpl(url, { method, headers: { authorization: `Bearer ${this.token}`, "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
|
|
34
|
+
.catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Harbour governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
|
|
29
35
|
const parsed = await response.json().catch(() => ({}));
|
|
30
36
|
if (response.status === 401)
|
|
31
37
|
throw new CliError("AUTH_REQUIRED", "Please sign in to Harbour with `harbour login`.");
|
|
@@ -34,6 +40,22 @@ export class GovernanceClient {
|
|
|
34
40
|
return (parsed.data ?? parsed);
|
|
35
41
|
}
|
|
36
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* What actually failed under a `fetch` rejection. undici reports every transport
|
|
45
|
+
* failure as `TypeError: fetch failed` and keeps the reason in `cause`: `getaddrinfo
|
|
46
|
+
* ENOTFOUND <host>`, `connect ECONNREFUSED <ip>:<port>`, a ConnectTimeoutError
|
|
47
|
+
* (UND_ERR_CONNECT_TIMEOUT, after 10 s), `unexpected redirect` under `redirect:
|
|
48
|
+
* "error"`, or an AggregateError with an empty message over one error per address
|
|
49
|
+
* tried. Observed 2026-09-12 (CLI 0.1.29): `harbour check --integrations` printed
|
|
50
|
+
* `fetch failed` and nothing else, so what was wrong — and that nothing about the
|
|
51
|
+
* app was — could not be told from the output.
|
|
52
|
+
*/
|
|
53
|
+
function transportFailure(error) {
|
|
54
|
+
const cause = (error instanceof Error ? error.cause ?? error : error);
|
|
55
|
+
const words = (failure) => { const { message } = (failure ?? {}); return typeof message === "string" ? message.trim() : ""; };
|
|
56
|
+
const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
|
|
57
|
+
return typeof cause?.code === "string" && !text.includes(cause.code) ? `${text} (${cause.code})` : text;
|
|
58
|
+
}
|
|
37
59
|
/** Links the app once (stable idempotency key) and records the appId in kit.lock. */
|
|
38
60
|
export async function ensureLinkedApp(root, client, tenantId, bundle) {
|
|
39
61
|
const lock = (await readKitLock(root)) ?? newKitLock(bundle, tenantId);
|
|
@@ -45,6 +67,7 @@ export async function ensureLinkedApp(root, client, tenantId, bundle) {
|
|
|
45
67
|
await writeKitLock(root, { ...lock, appId: linked.appId, tenantId });
|
|
46
68
|
return linked.appId;
|
|
47
69
|
}
|
|
70
|
+
export const ENVIRONMENTS = ["development", "preview", "production"];
|
|
48
71
|
/**
|
|
49
72
|
* One request per identity mode from the declaration's scope (or the named subset).
|
|
50
73
|
* Polls the grant list for up to 30 s; PENDING is reported as pending, never as ready.
|
|
@@ -54,7 +77,7 @@ export async function requestIntegrations(root, client, tenantId, bundle, option
|
|
|
54
77
|
if (errors.length)
|
|
55
78
|
throw new CliError("DECLARATION_INVALID", `.harbour/integrations.json is invalid: ${errors[0]}`);
|
|
56
79
|
const environment = options.environment ?? "development";
|
|
57
|
-
if (!
|
|
80
|
+
if (!ENVIRONMENTS.includes(environment))
|
|
58
81
|
throw new CliError("USAGE", "--environment must be development, preview or production.");
|
|
59
82
|
if (!options.reason.trim())
|
|
60
83
|
throw new CliError("USAGE", "--reason <text> is required.");
|
|
@@ -180,6 +203,35 @@ export function requestScope(declaration, connection, only) {
|
|
|
180
203
|
...(identityMode === "app" && entry.operations.includes("slack.message.post") && declared.presentation ? { presentation: declared.presentation } : {})
|
|
181
204
|
}));
|
|
182
205
|
}
|
|
206
|
+
/** Where connection, channel, view and mailbox names come from, so an agent never guesses one (2026-09-12: six of nineteen turns spent on refused guesses). */
|
|
207
|
+
export async function integrationsCatalog(client) {
|
|
208
|
+
const listed = await client.catalog();
|
|
209
|
+
return { connections: listed.connections.map(entry => ({
|
|
210
|
+
connection: entry.connection, kind: entry.kind, ...(entry.provider ? { provider: entry.provider } : {}), ...(entry.connector ? { connector: entry.connector } : {}), ...(entry.installation ? { installation: entry.installation } : {}),
|
|
211
|
+
displayName: entry.displayName, status: entry.status,
|
|
212
|
+
operations: entry.operations.map(item => ({ operation: item.operation, identities: [...item.identities] })),
|
|
213
|
+
resources: Object.fromEntries(ENVIRONMENTS.map(environment => [environment, (entry.resources[environment] ?? []).map(resource => ({ name: resource.name, ...(resource.columns ? { columns: [...resource.columns] } : {}) }))]))
|
|
214
|
+
})) };
|
|
215
|
+
}
|
|
216
|
+
const PROVIDER_WORDS = { slack: "Slack", gmail: "Gmail" };
|
|
217
|
+
const INSTALLATION_WORDS = { installed: "bot installed", not_installed: "bot not installed yet: IT installs it in the Harbour console", reconnect_required: "bot needs reconnecting: IT does that in the Harbour console" };
|
|
218
|
+
/** One block per connection: the identifier to declare, what it is, the allowed operations with their identity, and the approved names per environment. */
|
|
219
|
+
export function renderIntegrationsCatalog(result) {
|
|
220
|
+
if (!result.connections.length)
|
|
221
|
+
return "No connections are registered for your company yet; IT adds them in the Harbour console under Controls & integrations. Do not declare one until it is listed here.";
|
|
222
|
+
const lines = [];
|
|
223
|
+
for (const entry of result.connections) {
|
|
224
|
+
const what = entry.kind === "saas" ? `${PROVIDER_WORDS[entry.provider ?? ""] ?? entry.provider} connection` : `${entry.connector ?? "database"} warehouse`;
|
|
225
|
+
const state = [entry.status, ...(entry.installation && INSTALLATION_WORDS[entry.installation] ? [INSTALLATION_WORDS[entry.installation]] : [])].join(", ");
|
|
226
|
+
lines.push(`\`${entry.connection}\` — ${what} "${entry.displayName}" (${state})`);
|
|
227
|
+
lines.push(` operations: ${entry.operations.map(item => `${item.operation} (${item.identities.join(" or ")})`).join(", ") || "none allowed"}`);
|
|
228
|
+
for (const environment of ENVIRONMENTS) {
|
|
229
|
+
const names = entry.resources[environment].map(resource => `${resource.name}${resource.columns?.length ? ` (${resource.columns.join(", ")})` : ""}`);
|
|
230
|
+
lines.push(` ${environment}: ${names.join(", ") || "nothing approved yet"}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
183
235
|
export async function integrationsStatus(root, client) {
|
|
184
236
|
const lock = await readKitLock(root);
|
|
185
237
|
if (!lock?.appId)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "harbour.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.1.
|
|
3
|
+
"kitVersion": "0.1.31",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@harbour/app-sdk",
|
|
6
6
|
"version": "1.1.0",
|
|
@@ -8,8 +8,8 @@ export const PUBLISHED_KIT_BUNDLE = {
|
|
|
8
8
|
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:88e00a9dac87f5281285ca1d881c7766f8fa04fcee00376f1c60e10e848bf16f"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:915320a5265e1f740c7d6078da164537666043883b69ec8404a3b5036dc8533a",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:d000142b60b2b3e97b0c2043fc0d8e13621d5d6467d0d048cf2c69e7a80fe014"
|
|
13
13
|
},
|
|
14
14
|
"brief": {
|
|
15
15
|
"fingerprint": "4b3c61acdce47995c15e5ce8c38d5e636a624cd79ef7bebb9401b6228d129d11"
|
|
@@ -20,9 +20,12 @@ function pickDeployment(value) {
|
|
|
20
20
|
...(value.message ? { message: value.message } : {}),
|
|
21
21
|
...(value.protectedUrl ? { protectedUrl: value.protectedUrl } : {}),
|
|
22
22
|
...(value.versionId ? { versionId: value.versionId } : {}),
|
|
23
|
-
...(value.failure ? { failure:
|
|
23
|
+
...(value.failure ? { failure: pickFailure(value.failure) } : {})
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
function pickFailure(value) {
|
|
27
|
+
return { ...(value.message ? { message: value.message } : {}), ...(value.retryable !== undefined ? { retryable: value.retryable } : {}), ...(value.remediationHint ? { remediationHint: value.remediationHint } : {}) };
|
|
28
|
+
}
|
|
26
29
|
/**
|
|
27
30
|
* The refusal reason source intake stored on the operation. Read the same way
|
|
28
31
|
* `pickDeployment` reads a deployment failure: keep the server's own code,
|
|
@@ -55,8 +58,9 @@ function pickProduction(value) {
|
|
|
55
58
|
...(value.status ? { status: value.status } : {}),
|
|
56
59
|
...(value.message ? { message: value.message } : {}),
|
|
57
60
|
...(value.productionUrl ? { productionUrl: value.productionUrl } : {}),
|
|
61
|
+
...(value.versionId ? { versionId: value.versionId } : {}),
|
|
58
62
|
...(value.previewVersionId ? { previewVersionId: value.previewVersionId } : {}),
|
|
59
|
-
...(value.failure
|
|
63
|
+
...(value.failure ? { failure: pickFailure(value.failure) } : {})
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
export async function fetchStatus(client, operationRef, waitSeconds = 0) {
|
|
@@ -73,6 +77,10 @@ export function isSettled(status) {
|
|
|
73
77
|
return DEPLOYMENT_TERMINAL.has(status.deployment.state);
|
|
74
78
|
return false;
|
|
75
79
|
}
|
|
80
|
+
/** What the wait was before `--max-wait` existed: a caller that never passes it waits exactly as long as it did. */
|
|
81
|
+
const DEFAULT_MAX_WAIT_MS = 30 * 60_000;
|
|
82
|
+
/** A line at least this often while nothing changes, so a wrapper with its own idle timeout (an agent's shell tool) keeps seeing output; see `follow`. */
|
|
83
|
+
const HEARTBEAT_MS = 60_000;
|
|
76
84
|
/**
|
|
77
85
|
* Follow the operation until the preview deployment (or an in-flight production
|
|
78
86
|
* promotion) settles. Harbour long-polls up to 15 s per call and returns early
|
|
@@ -80,20 +88,36 @@ export function isSettled(status) {
|
|
|
80
88
|
* `noDeploymentGraceMs` covers the gap between the source save succeeding and
|
|
81
89
|
* the deployment attempt being recorded; past it, a saved operation with no
|
|
82
90
|
* deployment is reported as such instead of waited on forever.
|
|
91
|
+
*
|
|
92
|
+
* Returns for exactly three reasons, and the status says which: it settled;
|
|
93
|
+
* nothing was in flight when the grace window closed; or something was in
|
|
94
|
+
* flight when the ceiling passed. The ceiling is never thrown — see `follow`.
|
|
95
|
+
* Two waits, two bounds: the grace window while nothing is in flight, the
|
|
96
|
+
* ceiling once something is. Each long-poll and each pause is cut to what is
|
|
97
|
+
* left of the bound, so `--max-wait N` answers within N seconds — an agent's
|
|
98
|
+
* tool with a total cap of N sees the answer instead of killing the command.
|
|
83
99
|
*/
|
|
84
100
|
export async function waitForSettled(client, operationRef, output, options = {}) {
|
|
85
101
|
const now = options.now ?? Date.now;
|
|
86
102
|
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
87
|
-
const maxWaitMs = options.maxWaitMs ??
|
|
103
|
+
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
88
104
|
const graceMs = options.noDeploymentGraceMs ?? 90_000;
|
|
89
105
|
const startedAt = now();
|
|
106
|
+
// Until the first status says what is in flight, the shorter bound holds.
|
|
107
|
+
let bound = Math.min(graceMs, maxWaitMs);
|
|
108
|
+
let last;
|
|
90
109
|
let lastLine = "";
|
|
110
|
+
let lastOutputAt = startedAt;
|
|
91
111
|
let consecutiveFailures = 0;
|
|
92
112
|
while (true) {
|
|
93
113
|
const callStarted = now();
|
|
114
|
+
const remainingMs = startedAt + bound - callStarted;
|
|
115
|
+
// Nothing more fits inside the bound: the watch answers with what it last saw.
|
|
116
|
+
if (last && remainingMs <= 1_000)
|
|
117
|
+
return last;
|
|
94
118
|
let status;
|
|
95
119
|
try {
|
|
96
|
-
status = await fetchStatus(client, operationRef, 15);
|
|
120
|
+
status = await fetchStatus(client, operationRef, Math.min(15, Math.max(0, Math.floor(remainingMs / 1000))));
|
|
97
121
|
consecutiveFailures = 0;
|
|
98
122
|
}
|
|
99
123
|
catch (error) {
|
|
@@ -110,52 +134,105 @@ export async function waitForSettled(client, operationRef, output, options = {})
|
|
|
110
134
|
await sleep(Math.min(15_000, 3_000 * consecutiveFailures));
|
|
111
135
|
continue;
|
|
112
136
|
}
|
|
137
|
+
last = status;
|
|
113
138
|
if (isSettled(status))
|
|
114
139
|
return status;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return status;
|
|
118
|
-
}
|
|
140
|
+
const at = now();
|
|
141
|
+
bound = status.deployment || status.production ? maxWaitMs : graceMs;
|
|
119
142
|
const line = progressLine(status);
|
|
120
|
-
if (line
|
|
143
|
+
if (line !== lastLine) {
|
|
121
144
|
output(line);
|
|
122
145
|
lastLine = line;
|
|
146
|
+
lastOutputAt = at;
|
|
123
147
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
else if (at - lastOutputAt >= HEARTBEAT_MS) {
|
|
149
|
+
output(runningLine(status, at - startedAt, operationRef));
|
|
150
|
+
lastOutputAt = at;
|
|
151
|
+
}
|
|
152
|
+
// Harbour returns immediately when nothing is in flight yet; do not spin — and never past the bound.
|
|
153
|
+
const elapsed = at - callStarted;
|
|
128
154
|
if (elapsed < 3_000)
|
|
129
|
-
await sleep(3_000 - elapsed);
|
|
155
|
+
await sleep(Math.min(3_000 - elapsed, Math.max(0, startedAt + bound - at)));
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
158
|
function safeMessage(error) {
|
|
133
159
|
const raw = error instanceof Error ? error.message : String(error);
|
|
134
160
|
return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
|
|
135
161
|
}
|
|
162
|
+
function activity(status) {
|
|
163
|
+
return status.production ? "promoting the app to production" : status.deployment ? "deploying the app" : "preparing the deployment";
|
|
164
|
+
}
|
|
136
165
|
function progressLine(status) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
166
|
+
const stage = status.production ?? status.deployment;
|
|
167
|
+
return stage ? `Harbour is ${activity(status)}${stage.message ? ` (${stage.message})` : ""}.` : "Harbour saved the app and is preparing the deployment.";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The one sentence for a watch that has not ended: the heartbeat prints it as
|
|
171
|
+
* time passes, and a ceiling that passes returns it as the next step. A
|
|
172
|
+
* wrapper that stops watching, or an agent whose tool did, runs the command
|
|
173
|
+
* it names and continues the same operation — it never starts another.
|
|
174
|
+
*/
|
|
175
|
+
function runningLine(status, elapsedMs, operationRef) {
|
|
176
|
+
const seconds = Math.round(elapsedMs / 1000);
|
|
177
|
+
return `Harbour is still ${activity(status)} (${Math.floor(seconds / 60)}m${seconds % 60}s). Safe to stop watching: \`${continueCommand(operationRef)}\` picks it up; do not start another.`;
|
|
142
178
|
}
|
|
143
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* The one command that resumes a watch, as the CLI's help and the agent guide
|
|
181
|
+
* print it too. Bounded at two minutes so a wrapper with an idle timeout gets
|
|
182
|
+
* an answer and runs it again, rather than cutting a 30-minute wait off and
|
|
183
|
+
* learning nothing.
|
|
184
|
+
*/
|
|
185
|
+
export function continueCommand(operationRef) {
|
|
186
|
+
return `harbour status --operation ${operationRef} --wait --max-wait 120 --json`;
|
|
187
|
+
}
|
|
188
|
+
/** The exit-relevant outcome of a status — success, a step the maker must take, an operation still in flight — as the summary with its `outcome` named; a failure is thrown. */
|
|
144
189
|
export function outcomeFor(status, operationRef) {
|
|
145
190
|
const summary = summarize(status);
|
|
191
|
+
// A failed stage, reported as the stage recorded it: its sentence, then its
|
|
192
|
+
// fix, with the summary attached so the envelope carries the version the
|
|
193
|
+
// console's releases tab and the email name. Only a stage that recorded no
|
|
194
|
+
// sentence at all gets the generic one.
|
|
195
|
+
const failed = (code, stage, fallback, suffix = "") => new CliError(code, `${stage.failure?.message ?? stage.message ?? fallback}${suffix}`, operationRef, stage.failure?.remediationHint, summary);
|
|
146
196
|
if (status.production?.state === "LIVE")
|
|
147
|
-
return {
|
|
197
|
+
return { ...summary, outcome: "production_live" };
|
|
148
198
|
if (status.production && PRODUCTION_TERMINAL.has(status.production.state))
|
|
149
|
-
throw
|
|
199
|
+
throw failed("PRODUCTION_PROMOTION_FAILED", status.production, "Harbour could not complete the production promotion.");
|
|
150
200
|
if (status.waiting)
|
|
151
|
-
return {
|
|
201
|
+
return { ...summary, outcome: "waiting" };
|
|
202
|
+
// A promotion in flight rides on a LIVE preview; the promotion is what is being followed.
|
|
203
|
+
if (status.production)
|
|
204
|
+
return { ...summary, outcome: "running" };
|
|
152
205
|
if (status.deployment?.state === "LIVE")
|
|
153
|
-
return {
|
|
206
|
+
return { ...summary, outcome: "live" };
|
|
154
207
|
if (status.deployment && DEPLOYMENT_TERMINAL.has(status.deployment.state)) {
|
|
155
208
|
const retryable = status.deployment.failure?.retryable === true;
|
|
156
|
-
throw
|
|
209
|
+
throw failed(retryable ? "DEPLOYMENT_RETRYABLE_FAILURE" : "DEPLOYMENT_FAILED", status.deployment, "Harbour could not complete the deployment.", retryable ? " Run `harbour retry` to start it again." : "");
|
|
157
210
|
}
|
|
158
|
-
|
|
211
|
+
if (status.deployment)
|
|
212
|
+
return { ...summary, outcome: "running" };
|
|
213
|
+
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Harbour saved the app but has not started a deployment for this company yet. Ask your Harbour administrator to connect deployment." };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* `waitForSettled` then `outcomeFor`, as the one summary a command returns.
|
|
217
|
+
*
|
|
218
|
+
* A ceiling that passes is a `running` outcome and never an error: exit 0, the
|
|
219
|
+
* operation untouched, and the summary says how long this command watched and
|
|
220
|
+
* names the exact command that resumes the same watch. Measured 2026-09-12:
|
|
221
|
+
* the ceiling was a thrown `DEPLOYMENT_TIMEOUT`, an agent's shell tool cut the
|
|
222
|
+
* silent 30-minute wait off long before it, the agent started a second deploy
|
|
223
|
+
* to get "a status-producing call", and the first one's refusal reached the
|
|
224
|
+
* human by email and never reached the agent. The heartbeat, the bounded
|
|
225
|
+
* `--max-wait`, and this outcome each close one part of that.
|
|
226
|
+
*/
|
|
227
|
+
export async function follow(client, operationRef, output, options = {}) {
|
|
228
|
+
const now = options.now ?? Date.now;
|
|
229
|
+
const startedAt = now();
|
|
230
|
+
const status = await waitForSettled(client, operationRef, output, options);
|
|
231
|
+
const summary = outcomeFor(status, operationRef);
|
|
232
|
+
if (summary.outcome !== "running")
|
|
233
|
+
return summary;
|
|
234
|
+
const elapsedMs = now() - startedAt;
|
|
235
|
+
return { ...summary, elapsedSeconds: Math.round(elapsedMs / 1000), nextStep: runningLine(status, elapsedMs, operationRef) };
|
|
159
236
|
}
|
|
160
237
|
export async function retryDeployment(client, operationRef, output, options = {}) {
|
|
161
238
|
await client.initialize();
|
|
@@ -163,7 +240,7 @@ export async function retryDeployment(client, operationRef, output, options = {}
|
|
|
163
240
|
output(result.retry?.replayed ? "Harbour had already accepted this retry." : "Harbour accepted the deployment retry.");
|
|
164
241
|
if (options.wait === false)
|
|
165
242
|
return summarize(await fetchStatus(client, operationRef));
|
|
166
|
-
return
|
|
243
|
+
return follow(client, operationRef, output, options.waitOptions);
|
|
167
244
|
}
|
|
168
245
|
/**
|
|
169
246
|
* Promotion mirrors the console's "I tested this version" button: the maker
|
|
@@ -175,7 +252,7 @@ export async function promoteToProduction(client, operationRef, output, options
|
|
|
175
252
|
const status = await fetchStatus(client, operationRef);
|
|
176
253
|
if (status.production && !PRODUCTION_TERMINAL.has(status.production.state)) {
|
|
177
254
|
output("Harbour is already promoting this app to production.");
|
|
178
|
-
return options.wait === false ? summarize(status) :
|
|
255
|
+
return options.wait === false ? summarize(status) : follow(client, operationRef, output, options.waitOptions);
|
|
179
256
|
}
|
|
180
257
|
const deployment = status.deployment;
|
|
181
258
|
if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
|
|
@@ -193,7 +270,7 @@ export async function promoteToProduction(client, operationRef, output, options
|
|
|
193
270
|
output("Harbour accepted the production promotion.");
|
|
194
271
|
if (options.wait === false)
|
|
195
272
|
return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
|
|
196
|
-
return
|
|
273
|
+
return follow(client, operationRef, output, options.waitOptions);
|
|
197
274
|
}
|
|
198
275
|
export async function readLine() {
|
|
199
276
|
if (!process.stdin.isTTY) {
|
|
@@ -99,11 +99,29 @@ function wholeWordsAfter(said, start) {
|
|
|
99
99
|
*/
|
|
100
100
|
export function failureEnvelope(error, cliVersion) {
|
|
101
101
|
const operationRef = error instanceof CliError ? error.operationRef : undefined;
|
|
102
|
-
|
|
102
|
+
const result = error instanceof CliError ? error.result : undefined;
|
|
103
|
+
return { schema: "harbour.cli-result/1.0", cliVersion, status: "FAILED", operationStarted: Boolean(operationRef), ...(operationRef ? { operationRef } : {}), error: safeError(error), ...(result !== undefined ? { result } : {}) };
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The envelope for a command that finished without error: `SUCCEEDED`, or
|
|
107
|
+
* `RUNNING` when the operation it followed is still in flight (the result's
|
|
108
|
+
* `outcome`, set by `follow`) — see `CliEnvelope.status`.
|
|
109
|
+
*/
|
|
110
|
+
export function operationEnvelope(result, operationRef, cliVersion) {
|
|
111
|
+
const running = result?.outcome === "running";
|
|
112
|
+
return { schema: "harbour.cli-result/1.0", cliVersion, status: running ? "RUNNING" : "SUCCEEDED", operationStarted: true, operationRef, result };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The reason and the fix on one line, and — when the failure is a deployment
|
|
116
|
+
* or a promotion the result knows the version of — the stage line beneath it,
|
|
117
|
+
* so the builder and whoever reads the console or the email are looking at
|
|
118
|
+
* the same attempt.
|
|
119
|
+
*/
|
|
104
120
|
export function renderFailure(envelope) {
|
|
105
121
|
const error = envelope.error;
|
|
106
|
-
|
|
122
|
+
const result = (envelope.result ?? {});
|
|
123
|
+
const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
|
|
124
|
+
return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${stages.join("")}\n`;
|
|
107
125
|
}
|
|
108
126
|
/**
|
|
109
127
|
* Server-supplied text on its way to a terminal, given exactly the treatment
|
|
@@ -140,6 +158,9 @@ function said(value) {
|
|
|
140
158
|
* - `operationRef` — the handle every later `--operation` needs, and already
|
|
141
159
|
* exempt on the failure path: `safeError` redacts the message and the hint,
|
|
142
160
|
* never the reference they travel with. The same policy on both paths.
|
|
161
|
+
* - `deployment.versionId`, `production.versionId` — printed only on a failed
|
|
162
|
+
* stage, and for the same reason: it is the reference the console and the
|
|
163
|
+
* email carry for that attempt, and a bounded or reworded one names nothing.
|
|
143
164
|
* - `secrets.asks[].name` and `.prefilledFromPath` — identifiers, not prose. A
|
|
144
165
|
* secret named `AUTHORIZATION_TOKEN` matches the header pattern and would be
|
|
145
166
|
* erased whole, printing `Secret : needs a value`; the path is read off the
|
|
@@ -169,22 +190,14 @@ export function renderSummary(envelope) {
|
|
|
169
190
|
if (verification)
|
|
170
191
|
lines.push(`Saved app: ${verification}${assurance ? ` (${assurance})` : ""}`);
|
|
171
192
|
if (result.production) {
|
|
172
|
-
lines.push(
|
|
173
|
-
// `|| NO_DETAIL` for the reason `safeError` has it: redaction can empty a
|
|
174
|
-
// string, and a failure reported as two spaces is worse than one that says
|
|
175
|
-
// plainly this CLI has nothing to show.
|
|
193
|
+
lines.push(stageLine("production", result.production));
|
|
176
194
|
if (result.production.failure?.message)
|
|
177
|
-
lines.push(` ${
|
|
195
|
+
lines.push(` ${failureText(result.production.failure)}`);
|
|
178
196
|
}
|
|
179
197
|
if (result.deployment) {
|
|
180
|
-
lines.push(
|
|
181
|
-
if (result.deployment.failure?.message)
|
|
182
|
-
|
|
183
|
-
// bounds it separately from the message: appending it first would spend
|
|
184
|
-
// the message’s own budget on it.
|
|
185
|
-
const hint = said(result.deployment.failure.remediationHint);
|
|
186
|
-
lines.push(` ${said(result.deployment.failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`);
|
|
187
|
-
}
|
|
198
|
+
lines.push(stageLine("deployment", result.deployment));
|
|
199
|
+
if (result.deployment.failure?.message)
|
|
200
|
+
lines.push(` ${failureText(result.deployment.failure)}`);
|
|
188
201
|
}
|
|
189
202
|
// Gated on the redacted value rather than the raw one, so guidance that is
|
|
190
203
|
// nothing but a link falls through to the next step instead of printing a
|
|
@@ -223,14 +236,38 @@ export function renderSummary(envelope) {
|
|
|
223
236
|
lines.push("Setup complete.");
|
|
224
237
|
return `${lines.join("\n")}\n`;
|
|
225
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* `Preview deployment: FAILED (fourier#app#preview#000001) — <url>`. On a
|
|
241
|
+
* failure the version is the reference the console's releases tab and the
|
|
242
|
+
* email carry, printed so everyone is looking at the same attempt. It is an
|
|
243
|
+
* identifier, exempt from `said` for the reason the operation reference is.
|
|
244
|
+
*/
|
|
245
|
+
function stageLine(kind, stage) {
|
|
246
|
+
const [label, state, url] = kind === "production" ? ["Production", stage.state, stage.productionUrl] : ["Preview deployment", stage.status ?? stage.state, stage.protectedUrl];
|
|
247
|
+
return `${label}: ${said(state)}${stage.failure && stage.versionId ? ` (${stage.versionId})` : ""}${url ? ` — ${url}` : ""}`;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The message, then the hint, each redacted and bounded on its own — exactly
|
|
251
|
+
* as `safeError` bounds them separately, so the hint never spends the
|
|
252
|
+
* message's budget. `|| NO_DETAIL` for the reason `safeError` has it: redaction
|
|
253
|
+
* can empty a string, and a failure reported as two spaces is worse than one
|
|
254
|
+
* that says plainly this CLI has nothing to show.
|
|
255
|
+
*/
|
|
256
|
+
function failureText(failure) {
|
|
257
|
+
const hint = said(failure.remediationHint);
|
|
258
|
+
return `${said(failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`;
|
|
259
|
+
}
|
|
226
260
|
export class CliError extends Error {
|
|
227
261
|
code;
|
|
228
262
|
operationRef;
|
|
229
263
|
remediationHint;
|
|
230
|
-
|
|
264
|
+
result;
|
|
265
|
+
/** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
|
|
266
|
+
constructor(code, message, operationRef, remediationHint, result) {
|
|
231
267
|
super(message);
|
|
232
268
|
this.code = code;
|
|
233
269
|
this.operationRef = operationRef;
|
|
234
270
|
this.remediationHint = remediationHint;
|
|
271
|
+
this.result = result;
|
|
235
272
|
}
|
|
236
273
|
}
|
|
@@ -6,7 +6,7 @@ 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 {
|
|
9
|
+
import { follow, getAppSetup, pickSourceFailure, readLine } from "./operations.js";
|
|
10
10
|
import { CLI_VERSION } from "./version.js";
|
|
11
11
|
import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
12
12
|
import { assertPreviewIntegrationsReady } from "./integrations.js";
|
|
@@ -106,17 +106,16 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
106
106
|
// the maker gets the app's real protected link, not the example one.
|
|
107
107
|
// Past this point the app is saved and verified; a failure here is about
|
|
108
108
|
// following the deployment, not about the save, and must say so.
|
|
109
|
-
let
|
|
109
|
+
let summary;
|
|
110
110
|
try {
|
|
111
|
-
|
|
111
|
+
summary = await follow(client, operationRef, output, options.waitOptions);
|
|
112
112
|
}
|
|
113
113
|
catch (error) {
|
|
114
114
|
if (error instanceof CliError)
|
|
115
115
|
throw error;
|
|
116
116
|
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);
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
output(outcomeLine(outcome));
|
|
118
|
+
output(outcomeLine(summary));
|
|
120
119
|
// The console asks for the app's name, audience and secrets before it
|
|
121
120
|
// offers promotion; say what is still open so a CLI-only builder knows.
|
|
122
121
|
let setup;
|
|
@@ -128,7 +127,7 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
128
127
|
}
|
|
129
128
|
if (setup?.pending?.length)
|
|
130
129
|
output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`harbour setup --operation ${operationRef}\`.`);
|
|
131
|
-
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...
|
|
130
|
+
return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
|
|
132
131
|
}
|
|
133
132
|
catch (error) {
|
|
134
133
|
throw await operationFailure(client, operationRef, error);
|
|
@@ -251,7 +250,7 @@ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
|
|
|
251
250
|
*/
|
|
252
251
|
async function operationFailure(client, operationRef, error) {
|
|
253
252
|
if (error instanceof CliError)
|
|
254
|
-
return error.operationRef ? error : new CliError(error.code, error.message, operationRef, error.remediationHint);
|
|
253
|
+
return error.operationRef ? error : new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
|
|
255
254
|
const detail = errorDetail(error);
|
|
256
255
|
const state = await probeOperation(client, operationRef);
|
|
257
256
|
// A pending-setup operation is not a failed operation: nothing is wrong with
|
|
@@ -379,12 +378,13 @@ function safeVerificationResult(value, statusEvidence) {
|
|
|
379
378
|
: {})
|
|
380
379
|
};
|
|
381
380
|
}
|
|
382
|
-
function outcomeLine(
|
|
383
|
-
switch (outcome
|
|
384
|
-
case "live": return `Harbour deployed the app. Protected preview: ${
|
|
385
|
-
case "production_live": return `The app is live in production: ${
|
|
386
|
-
case "waiting": return
|
|
387
|
-
|
|
381
|
+
function outcomeLine(summary) {
|
|
382
|
+
switch (summary.outcome) {
|
|
383
|
+
case "live": return `Harbour deployed the app. Protected preview: ${summary.deployment?.protectedUrl ?? ""}`.trim();
|
|
384
|
+
case "production_live": return `The app is live in production: ${summary.production?.productionUrl ?? ""}`.trim();
|
|
385
|
+
case "waiting": return summary.waiting?.plainEnglish ?? "Harbour needs one more step in the Harbour console before it can deploy.";
|
|
386
|
+
// `running` and `no_deployment` both carry their own next step.
|
|
387
|
+
default: return summary.nextStep ?? "Harbour saved the app; deployment has not started.";
|
|
388
388
|
}
|
|
389
389
|
}
|
|
390
390
|
const readApproval = readLine;
|
|
@@ -92,7 +92,7 @@ export function managedBlock() {
|
|
|
92
92
|
"",
|
|
93
93
|
"- Identity, data and files go through `@harbour/app-sdk` only: `harbour.identity.current()`, `harbour.data.from(table)`, `harbour.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
|
|
94
94
|
"- Company systems (Slack, Gmail, warehouse views) are reached only through `harbour.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.harbour/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (declared per app: `app` posts as the company's Slack bot, Isomorph AI, under the name IT approved; `user` posts as the signed-in person after their consent), `gmail.thread.list`, `gmail.message.read` and `gmail.message.send` (user identity — reads and one plain-text send as the signed-in person, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
|
|
95
|
-
"- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection with only the operations the app calls, then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
|
|
95
|
+
"- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
|
|
96
96
|
"- A Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect, a timer or a background queue, and never send during checks.",
|
|
97
97
|
"- A Slack message is posted either as the app (the company's Slack bot, Isomorph AI, under the IT-approved name: declare `\"presentation\": { \"displayName\": \"<app name>\" }` on the Slack connection, optional `iconEmoji`; every app-mode post ends with \"Posted by <app> on Isomorph\") or as the person (`\"identity\": \"user\"` on `slack.message.post`, their own consent; an older consent answers `USER_RECONNECT_REQUIRED` — offer Connect again) — never pretend one is the other. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
|
|
98
98
|
"- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. App-identity operations (`slack.message.post` declared `\"identity\": \"app\"`, `warehouse.view.read`) never call connect — it is refused as unapproved user access. Missing consent never falls back to another account.",
|
|
@@ -104,7 +104,7 @@ export function managedBlock() {
|
|
|
104
104
|
"- Schema changes are SQL files in `migrations/`, applied by `harbour dev` and `harbour check`. Every table: ENABLE ROW LEVEL SECURITY + a policy; GRANT every verb a policy allows to `harbour_app_gateway` and to no other role — `harbour check` runs the pipeline's database gate and names any table/policy/grant that breaks this, with the fix.",
|
|
105
105
|
"- `.harbour/checks/` holds the app's retained journeys: one per capability the app's own code uses (`harbour.data.*`, `harbour.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `harbour check` generates them from `migrations/` and the app's own source and deletes the ones the app no longer needs, so the way to keep them right is to run it in the same edit that changes the app — not to write or remove these files by hand. The pairing is two-way and the `flow` gate refuses the deploy in both directions. Start using a capability and it needs its own retained check: `harbour check` writes it, except for the ones it reports it cannot generate (`actions`, `realtime`), which you write yourself. Stop using one — a deleted section, a dropped table, a feature the app no longer has — and its retained check must be deleted in that same edit: `harbour check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `harbour check` and the deployment pipeline replay `.harbour/checks/` against a real App Gateway and refuse the app (`flow.check-failed: the candidate's own retained checks no longer pass`) when a check exercises something the code no longer does.",
|
|
106
106
|
"- A generated check starts with a `// harbour:generated` line carrying a digest of its own body; that is how `harbour check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Harbour keeps your version, stops updating it and never deletes it, and keeping it honest is then your job. The starter's pairing is: `notes-journey.mjs` + `notes-cross-user.mjs` with the `notes` table and the Notes section of `src/App.tsx`; `files-journey.mjs` with the \"Private files\" section, the only code that calls `harbour.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `harbour check` rewrites and deletes to match — `.harbour/checks/files-journey.mjs` goes with that section, and you delete it by hand in that same edit only if you have edited it. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
|
|
107
|
-
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `harbour integrations request <connection> --reason <text> --app-root .`, `harbour integrations status --app-root .`, `harbour productionise --app-root .`. Company calls in `dev` use the account from `harbour login`; the local fixture user is only the app's identity.",
|
|
107
|
+
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `harbour integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `harbour integrations request <connection> --reason <text> --app-root .`, `harbour integrations status --app-root .`, `harbour productionise --app-root .`. Company calls in `dev` use the account from `harbour login`; the local fixture user is only the app's identity.",
|
|
108
108
|
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is in the user-level `isomorph` skill / `~/.codex/AGENTS.md` block installed by `harbour agent-setup`.",
|
|
109
109
|
MANAGED_END
|
|
110
110
|
].join("\n");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.31";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"harbour": {
|
|
35
35
|
"kitBundle": {
|
|
36
36
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
37
|
-
"version": "0.1.
|
|
37
|
+
"version": "0.1.31"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|