@fourier-labs/harbour 0.1.23 → 0.1.24

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.
@@ -144,23 +144,25 @@ description: Build, run, check and ship a company app on Isomorph (Harbour) from
144
144
  /** One guide, shared by the Claude Code skill and the Codex AGENTS.md block. Written for an agent working with a non-developer. */
145
145
  export const AGENT_GUIDE = `# Isomorph app kit
146
146
 
147
- The person you are working with may not be a developer. They say what they want in plain English; you build it with the Harbour kit and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Never paste JSON, logs, stack traces or file contents at them. Turn every failure into one sentence about what happened and one about what happens next. Prefer \`--json\` output and read it yourself.
147
+ The person you are working with may not be a developer. They say what they want in plain English; you build it with the Harbour kit and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Turn every failure into one sentence about what happened and one about what happens next. Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
148
148
 
149
149
  ## Getting ready (do this yourself, once per machine and folder)
150
150
 
151
- 1. CLI, before \`harbour init\` or anything else: compare the installed CLI with the current release and upgrade it whenever it is behind. A CLI that merely runs is not good enough only a current one is. \`harbour agent-setup\` prints both versions and says plainly when the installed one is missing or older (its \`--json\` result carries \`cli.upgradeRequired\` and \`cli.remediation\`); without that output, compare \`harbour --version\` with \`npx -y @fourier-labs/harbour --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @fourier-labs/harbour\` and confirm \`harbour --version\` now matches, then continue. Every later command runs the *installed* CLI, so a stale one builds an app that passes every local check and is then refused by the deployment pipeline (\`kit_bundle_incompatible\`) minutes later, with nothing in the app to fix. The kit needs Node 22+ and Docker Desktop running; if Docker is missing, that is the one thing to tell them to install, in one line.
151
+ 1. CLI, before \`harbour init\` or anything else: compare the installed CLI with the current release and upgrade it whenever it is behind a CLI that merely runs is not good enough, only a current one is. \`harbour agent-setup\` prints both versions and says plainly when the installed one is missing or older (its \`--json\` result carries \`cli.upgradeRequired\` and \`cli.remediation\`); without that output, compare \`harbour --version\` with \`npx -y @fourier-labs/harbour --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @fourier-labs/harbour\` and confirm \`harbour --version\` now matches, then continue. Every later command runs the *installed* CLI, so a stale one builds an app that passes every local check and is then refused by the deployment pipeline (\`kit_bundle_incompatible\`) minutes later, with nothing in the app to fix. The kit needs Node 22+ and Docker Desktop running; if Docker is missing, that is the one thing to tell them to install, in one line.
152
152
  2. Folder: if the current folder has no \`.harbour/\` directory, run \`harbour init --app-root .\` — an empty folder gets a small starter app, an existing Vite + React app gets the kit files added and nothing overwritten. Then read the "Harbour development kit" block in CLAUDE.md / AGENTS.md; it holds the per-app rules.
153
153
  3. Sign-in, needed only for company systems and shipping: run \`harbour login\`. It opens the browser and the person finishes the sign-in there — the one step they do themselves; tell them so in one line. If login says the company is not connected yet, ask them for the Harbour link their IT/admin gave them and run \`harbour connect <link>\` first.
154
154
 
155
155
  ## What they say → what you do
156
156
 
157
157
  - "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. Locally they are a fixture user; no company sign-in is needed.
158
- - "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Say in plain words what passed, what failed, and the one thing to do. Failures in the app's code are yours to fix fix, then check again.
158
+ - "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
159
  - "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. 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\`. 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\`. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
160
- - "ship it", "put it online", "let my team try it" → each declared connection first needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`). Then \`harbour productionise --app-root . --wait --json\` and give them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. 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. Tell them shipping takes a few minutes and what it is doing.
160
+ - "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. 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.
161
161
  - "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.
162
162
  - "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.
163
163
 
164
+ When a command refuses, the refusal names its own reason and its own fix: change that one thing, then run it again. Sending the same app again unchanged gets the same answer every time, so a repeat attempt is never the next step — and never start a second deploy of an app while one is running, because concurrent deploys of one app cancel each other.
165
+
164
166
  ## Building the app
165
167
 
166
168
  - Identity, data and files go through \`@harbour/app-sdk\` only: \`harbour.identity.current()\`, \`harbour.data.from(table)\`, \`harbour.files.*\`. Company systems go only through \`harbour.integrations.execute\` with declared operations. Never open a database, bucket or company URL from browser code, and never add another backend, auth library or deployment config: the kit is the whole path.
@@ -172,4 +174,5 @@ The person you are working with may not be a developer. They say what they want
172
174
  ## Talking to the person
173
175
 
174
176
  - Plain words, short: "Your app is running at <link>.", "All 6 checks passed.", "One check failed: votes were not being saved — fixed, checking again.", "IT has to approve Slack; the app works without it until then."
175
- - Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.`;
177
+ - Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.
178
+ - End a turn with at most one question, and only when a decision is genuinely theirs to make and you cannot go on without it. Never offer to do something this guide already tells you to do unasked — do it and report what happened.`;
@@ -1,14 +1,96 @@
1
+ /** The one line for a failure that arrived with no detail at all. It is a
2
+ * statement about what this CLI knows, not a claim about what happened, so it
3
+ * is safe to print when — and only when — there is genuinely nothing to show. */
4
+ export const NO_DETAIL = "Harbour could not complete the request.";
5
+ /**
6
+ * The failure as it was actually reported: the code it came with and the words
7
+ * it came with, redacted and capped. Nothing here infers what went wrong.
8
+ *
9
+ * Every non-`CliError` used to go through a keyword classifier over the message
10
+ * text, which did not merely lose the reason — it asserted false ones. A refusal
11
+ * reading "Harbour has no saved app for this operation yet" matched on `save`
12
+ * and printed as "Saving to company code storage failed safely; nothing was
13
+ * deployed", sending a human after a code-storage failure that never happened
14
+ * and leaving an agent with nothing true to act on. Detail we do not have is
15
+ * now reported as detail we do not have.
16
+ */
1
17
  export function safeError(error) {
2
18
  const code = error instanceof CliError ? error.code : "CLI_FAILED";
3
- const original = error instanceof Error ? error.message : "";
4
- const raw = error instanceof CliError ? error.message : friendlyMessage(original);
19
+ const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
5
20
  // The hint is a separate field rather than more sentence: it is often as long
6
21
  // as the message, and appending it would push the reason itself past the cap.
7
22
  const hint = error instanceof CliError && error.remediationHint ? redact(error.remediationHint) : "";
8
- return { code, message: redact(raw) || "Harbour could not complete the request.", ...(hint ? { remediationHint: hint } : {}) };
23
+ return { code, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}) };
9
24
  }
25
+ /** Anything that must never reach a terminal, a log or an envelope. */
26
+ const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
27
+ /** The bound on one reported string. It is the width of a terminal paragraph
28
+ * and of a field an agent has to read in full, and it is what
29
+ * `tests/cli-server-failure-detail.test.ts` holds the envelope to. This change
30
+ * does not raise it: a bigger fixed head-cap fails again on a longer message,
31
+ * because the problem is which end is kept, not how much. */
32
+ const DETAIL_BOUND = 240;
33
+ /** Marks where this CLI cut, so neither a human nor an agent reads the two
34
+ * retained halves as one continuous sentence. */
35
+ const ELISION = " \u2026 ";
36
+ /**
37
+ * Redact, then bound — keeping both ends.
38
+ *
39
+ * Bounding used to be `.slice(0, DETAIL_BOUND)`: keep the head, drop the tail.
40
+ * Server failure text is built the other way round. The boilerplate and the
41
+ * error class lead; the identifying token — the file, the command, the exit
42
+ * status — is last. Measured 2026-09-11 on one vibecoder session: a 317-char
43
+ * `transform.non-retryable: kit.check-failed:` refusal named `files-journey.mjs`
44
+ * at index 266, and a second, independently reproduced refusal the same day put
45
+ * it at index 278 of 295. Both sat past 240; neither filename was ever printed.
46
+ * The builder repaired the wrong file twice, at 2m23s and 2m12s of pipeline.
47
+ *
48
+ * The cap did not merely shorten those messages, it removed the answer and left
49
+ * a grammatical sentence behind, so two engineers with full log access read
50
+ * "...(real App Gateway" and went hunting in CloudWatch rather than registering
51
+ * that the CLI had cut it.
52
+ *
53
+ * So an over-long string now keeps BOTH ends — the head carries the error class,
54
+ * the tail carries the file and the exit status — with `ELISION` marking the
55
+ * gap. `DETAIL_BOUND` is unchanged, so nothing larger reaches a terminal or an
56
+ * envelope than before; the same characters are spent on both ends instead of
57
+ * one. Every string `safeError` reports goes through here, `remediationHint`
58
+ * included: hints have the same shape, and the command a builder has to run is
59
+ * the last one in them.
60
+ *
61
+ * This reads nothing and decides nothing from the words themselves — the split
62
+ * is positional. Stripping "known boilerplate prefixes" was considered and
63
+ * rejected for exactly that reason: it needs a maintained list of the server's
64
+ * phrasings, it deletes words the server actually said, and it is the kind of
65
+ * meaning-guessing the classifier deleted above was deleted for.
66
+ */
10
67
  function redact(value) {
11
- return value.replace(/https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi, "").replace(/\s+/g, " ").trim().slice(0, 240);
68
+ const said = value.replace(SECRETS, "").replace(/\s+/g, " ").trim();
69
+ if (said.length <= DETAIL_BOUND)
70
+ return said;
71
+ const keep = DETAIL_BOUND - ELISION.length;
72
+ const headEnd = Math.floor(keep / 2);
73
+ return `${wholeWordsBefore(said, headEnd)}${ELISION}${wholeWordsAfter(said, said.length - (keep - headEnd))}`;
74
+ }
75
+ /** How far from a seam this will look for a word boundary. Bounded, so text
76
+ * with no spaces near the seam keeps its full share either way. */
77
+ const SNAP = 24;
78
+ /** `said.slice(0, end)` and `said.slice(start)`, backed off to a word boundary
79
+ * when the seam falls inside a word. The elision has to read as a cut: the
80
+ * engineer who read "(real App Gateway " went to CloudWatch rather than
81
+ * noticing the CLI had truncated it, and a seam that reads as a typo is the
82
+ * same failure in miniature. */
83
+ function wholeWordsBefore(said, end) {
84
+ if (said[end] === " ")
85
+ return said.slice(0, end);
86
+ const at = said.lastIndexOf(" ", end);
87
+ return at > 0 && end - at <= SNAP ? said.slice(0, at) : said.slice(0, end);
88
+ }
89
+ function wholeWordsAfter(said, start) {
90
+ if (said[start - 1] === " ")
91
+ return said.slice(start);
92
+ const at = said.indexOf(" ", start);
93
+ return at >= 0 && at - start <= SNAP ? said.slice(at + 1) : said.slice(start);
12
94
  }
13
95
  /**
14
96
  * What a failed command reports: the JSON envelope on stdout and the line on
@@ -21,7 +103,7 @@ export function failureEnvelope(error, cliVersion) {
21
103
  }
22
104
  export function renderFailure(envelope) {
23
105
  const error = envelope.error;
24
- return `${error?.message ?? "Harbour could not complete the request."}${error?.remediationHint ? ` ${error.remediationHint}` : ""}\n`;
106
+ return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}\n`;
25
107
  }
26
108
  /** Human-readable stdout for runs without `--json`: the operation reference and
27
109
  * whatever link or next step Harbour reported, nothing internal. */
@@ -85,14 +167,3 @@ export class CliError extends Error {
85
167
  this.remediationHint = remediationHint;
86
168
  }
87
169
  }
88
- function friendlyMessage(message) {
89
- if (/connect|connection profile|tenant|company/i.test(message))
90
- return "This company is not connected to Harbour yet.";
91
- if (/sign-in|oauth|authorize|token|invalid_scope|redirect|callback|403|401/i.test(message))
92
- return "Please sign in to Harbour. If sign-in still fails, your account may not be allowed for this company yet.";
93
- if (/unsafe|secret|changed|digest|manifest|archive/i.test(message))
94
- return "Harbour found unsafe or changed files and stopped safely.";
95
- if (/source|save|worker|provider|github/i.test(message))
96
- return "Saving to company code storage failed safely; nothing was deployed.";
97
- return "Harbour could not complete the request.";
98
- }
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { basename, parse, resolve } from "node:path";
3
- import { readFile } from "node:fs/promises";
3
+ import { readdir, readFile } from "node:fs/promises";
4
4
  import { scanWorkspace } from "../../../src/analyzer.js";
5
5
  import { createSourceManifest } from "../../../src/source-intake.js";
6
6
  import { structured } from "./remote-mcp-client.js";
@@ -10,8 +10,8 @@ import { getAppSetup, outcomeFor, pickSourceFailure, readLine, waitForSettled }
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertPreviewIntegrationsReady } from "./integrations.js";
13
- import { recordKitAppId } from "./kit.js";
14
- import { preflightDatabaseGate } from "./check.js";
13
+ import { kitPaths, recordKitAppId, sourceDigest } from "./kit.js";
14
+ import { preflightDatabaseGate, readReport } from "./check.js";
15
15
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
16
16
  const root = resolve(rootArg);
17
17
  output(`Harbour is checking ${basename(root)}.`);
@@ -22,6 +22,14 @@ export async function productionise(rootArg, client, output, tenantId, includePa
22
22
  throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
23
23
  if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
24
24
  throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
25
+ // The app's own retained checks are the cheapest gate Harbour has and the
26
+ // most expensive one to discover in the cloud. Measured: a builder shipped
27
+ // without running `harbour check`, the retained checks no longer matched the
28
+ // code, and the pipeline's `flow` gate refused it twice — 2m23s and 2m12s per
29
+ // discovery — for a mismatch `harbour check` names locally in about five
30
+ // seconds. Refuse here, before the tree is even read into memory, and long
31
+ // before any grant lookup, database replay, operation or upload.
32
+ await assertChecksPassedForTree(root, output);
25
33
  const files = await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
26
34
  output(`Harbour found ${files.length} app files.`);
27
35
  // A declared connection without its preview grant would only park the
@@ -123,11 +131,82 @@ export async function productionise(rootArg, client, output, tenantId, includePa
123
131
  return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...outcome.summary, outcome: outcome.kind, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
124
132
  }
125
133
  catch (error) {
126
- if (error instanceof CliError || (error && typeof error === "object" && "code" in error))
127
- throw error;
128
- throw new CliError("OPERATION_FAILED", "Harbour could not complete the started operation.", operationRef);
134
+ throw await operationFailure(client, operationRef, error);
129
135
  }
130
136
  }
137
+ /**
138
+ * What a builder is told to do when the checks have not passed. `harbour check`
139
+ * exercises the retained journeys and the pipeline's operation-coverage gate
140
+ * against the running app, so both commands are named: the app has to be up
141
+ * before the checks that matter can run at all.
142
+ */
143
+ const RUN_THE_CHECKS = "Start the app with `harbour dev --app-root .`, then run `harbour check --app-root .` in a second terminal. Fix whatever it reports, then run this again.";
144
+ const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .harbour/local/check-report.json), then run `harbour check --app-root .` again with `harbour dev --app-root .` running.";
145
+ /**
146
+ * The checks the deployment pipeline replays in the cloud, and the only ones
147
+ * `harbour check` cannot run without `harbour dev` up. They are recorded as
148
+ * `not_run` — not as a pass — when the app was not running, which is exactly
149
+ * the state that used to reach the pipeline and fail there. `journeys` is the
150
+ * placeholder name the report uses when none of them ran; each journey that did
151
+ * run is recorded under its own `journey:<file>` name.
152
+ */
153
+ function isReplayedGate(name) {
154
+ return name === "flow" || name === "journeys" || name.startsWith("journey:");
155
+ }
156
+ /** The retained checks this app keeps, enumerated exactly as `harbour check` and the pipeline enumerate them. */
157
+ async function retainedChecks(root) {
158
+ return (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
159
+ }
160
+ /** At most three names, so one long list of journey files cannot push the reason itself past the report cap. */
161
+ function nameList(names) {
162
+ return names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
163
+ }
164
+ /**
165
+ * Refuses a tree whose own retained checks have not passed *for that tree*.
166
+ *
167
+ * `harbour check` writes `.harbour/local/check-report.json` with the digest of
168
+ * the source it ran against, so three different things have to be true before a
169
+ * deployment is worth anyone's time: the checks ran, they ran against these
170
+ * bytes, and the ones that matter actually ran rather than being skipped.
171
+ *
172
+ * Scope: an app that keeps no retained checks is not gated. This is not a
173
+ * loophole — `.harbour/checks/` is what the pipeline replays, and an app that
174
+ * keeps none is refused there by the same coverage gate the moment its code
175
+ * performs any operation. Gating it here would only refuse, with a fix it
176
+ * cannot carry out, every app that never adopted the kit.
177
+ *
178
+ * The digest is recomputed by the same `sourceDigest` `harbour check` used, so
179
+ * a match means one function saw the same bytes twice and no parallel
180
+ * implementation can drift into refusing a checked tree. It spans the whole app
181
+ * boundary, so a `--include` submission is compared against the tree the checks
182
+ * actually ran on: conservative where the two differ, never permissive.
183
+ */
184
+ async function assertChecksPassedForTree(root, output) {
185
+ const retained = await retainedChecks(root);
186
+ if (!retained.length)
187
+ return;
188
+ const plural = retained.length === 1 ? "" : "s";
189
+ const report = await readReport(root);
190
+ if (!report)
191
+ throw new CliError("CHECKS_NOT_RUN", `This app keeps ${retained.length} check${plural} that say whether it still works, and ${retained.length === 1 ? "it has" : "they have"} never been run against this code. Harbour does not deploy an app whose own checks have not passed.`, undefined, RUN_THE_CHECKS);
192
+ const tree = await sourceDigest(root);
193
+ if (report.sourceDigest !== tree.digest)
194
+ throw new CliError("CHECKS_STALE", "The app's code has changed since its checks last ran, so that result no longer describes what would be deployed. Harbour does not deploy an app whose own checks have not passed for this exact code.", undefined, RUN_THE_CHECKS);
195
+ const checks = Array.isArray(report.checks) ? report.checks : [];
196
+ // "Skipped" is not "passed". A report whose journeys and coverage gate never
197
+ // ran says nothing about whether the app works, and accepting it would hand
198
+ // the discovery straight back to the pipeline.
199
+ const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass" && check.status !== "fail").map(check => check.name);
200
+ const flowRan = checks.some(check => check.name === "flow" && (check.status === "pass" || check.status === "fail"));
201
+ if (notRun.length || !flowRan) {
202
+ const names = notRun.length ? notRun : ["flow"];
203
+ throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}: ${names.length === 1 ? "that check needs" : "those checks need"} the app running. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
204
+ }
205
+ const failed = checks.filter(check => check.status === "fail").map(check => check.name);
206
+ if (failed.length || report.passed !== true)
207
+ throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Harbour does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
208
+ output(`The app's own checks passed for this exact code (${retained.length} retained check${plural}, last run ${report.createdAt}).`);
209
+ }
131
210
  async function waitForIntake(client, operationRef) {
132
211
  for (let attempt = 0; attempt < 60; attempt += 1) {
133
212
  const status = structured(await client.call("harbour_get_operation_status", { operationId: operationRef, waitSeconds: 3 }));
@@ -147,8 +226,102 @@ async function waitForIntake(client, operationRef) {
147
226
  * any detail at all.
148
227
  */
149
228
  function intakeRejection(status, operationRef) {
150
- const failure = pickSourceFailure(status);
151
- return new CliError(failure?.code ?? "PACKAGE_REJECTED", failure?.message ?? "Harbour could not safely accept this app package.", operationRef, failure?.remediationHint);
229
+ return refusal(pickSourceFailure(status), "PACKAGE_REJECTED", "Harbour could not safely accept this app package.", operationRef);
230
+ }
231
+ /**
232
+ * The one way this file turns "the server refused" into a failure the builder
233
+ * can act on: the server's own code, sentence and fix where it gave them, the
234
+ * generic pair only where it gave nothing, and the operation reference always.
235
+ */
236
+ function refusal(failure, fallbackCode, fallbackMessage, operationRef) {
237
+ return new CliError(failure?.code ?? fallbackCode, failure?.message ?? fallbackMessage, operationRef, failure?.remediationHint);
238
+ }
239
+ /**
240
+ * Why the whole operation stopped. The outer catch used to answer this with one
241
+ * fixed sentence for everything that was not already a `CliError`, which is
242
+ * every plain refusal the server raises — so the reason stayed on the server
243
+ * and the builder was told only that something failed. Read the reason from
244
+ * three places, in the order that serves the builder:
245
+ *
246
+ * 1. A `CliError` already carries a reason; keep it, and only make sure it
247
+ * names the operation, since by here one exists.
248
+ * 2. The operation is not failed at all but waiting for its profile, audience
249
+ * or secrets — say what is pending and which commands supply it.
250
+ * 3. Otherwise the thrown value's own detail, then the refusal intake recorded
251
+ * on the operation, and only then the generic line.
252
+ */
253
+ async function operationFailure(client, operationRef, error) {
254
+ if (error instanceof CliError)
255
+ return error.operationRef ? error : new CliError(error.code, error.message, operationRef, error.remediationHint);
256
+ const detail = errorDetail(error);
257
+ const state = await probeOperation(client, operationRef);
258
+ // A pending-setup operation is not a failed operation: nothing is wrong with
259
+ // it and re-running `productionise` after the setup commands carries it on.
260
+ // It is still a failed *command* — no app was saved, verified or deployed —
261
+ // so it keeps a non-zero exit rather than reporting a success that did not
262
+ // happen, and carries the real next step instead of a bare code.
263
+ if (state.pending.length)
264
+ return new CliError(detail?.code ?? "SETUP_REQUIRED", state.plainEnglish ?? pendingSentence(state.pending), operationRef, setupRemediation(operationRef));
265
+ return refusal(detail ?? state.failure, "OPERATION_FAILED", "Harbour could not complete the started operation.", operationRef);
266
+ }
267
+ /**
268
+ * What the thrown value itself says. A refusal reaches us as a bare `Error`
269
+ * carrying the server's sentence, and — once errors carry them — the server's
270
+ * stable code and remediation too; all of it must survive to the builder. A
271
+ * runtime fault (`TypeError` and friends) is a defect in this CLI rather than
272
+ * an answer for the builder, so its message is not borrowed as the reason
273
+ * unless it also carries a code.
274
+ */
275
+ function errorDetail(error) {
276
+ if (!error || typeof error !== "object")
277
+ return undefined;
278
+ const raw = error;
279
+ const code = text(raw.code);
280
+ const remediationHint = text(raw.remediationHint);
281
+ const message = code || (error instanceof Error && error.constructor === Error) ? text(raw.message) : undefined;
282
+ if (!code && !message && !remediationHint)
283
+ return undefined;
284
+ return { ...(code ? { code } : {}), ...(message ? { message } : {}), ...(remediationHint ? { remediationHint } : {}) };
285
+ }
286
+ function text(value) {
287
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
288
+ }
289
+ /**
290
+ * What the server says about the operation once a call has failed. Both reads
291
+ * are best-effort: the reason the builder gets must never depend on a second
292
+ * call succeeding, so a probe that fails simply contributes nothing.
293
+ */
294
+ async function probeOperation(client, operationRef) {
295
+ let failure;
296
+ try {
297
+ failure = pickSourceFailure(structured(await client.call("harbour_get_operation_status", { operationId: operationRef, waitSeconds: 0 })));
298
+ }
299
+ catch { /* no status: fall back to what the error said */ }
300
+ try {
301
+ const setup = await getAppSetup(client, operationRef);
302
+ return { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}), ...(failure ? { failure } : {}) };
303
+ }
304
+ catch {
305
+ return { pending: [], ...(failure ? { failure } : {}) };
306
+ }
307
+ }
308
+ /** The console's post-save steps, in the words the CLI already uses for them. */
309
+ const SETUP_STEPS = {
310
+ confirm_app_profile: "its name and description confirmed",
311
+ confirm_app_audience: "its audience confirmed",
312
+ provide_secrets: "the secrets it asked for"
313
+ };
314
+ function pendingSentence(pending) {
315
+ return `The app still needs: ${pending.map(step => SETUP_STEPS[step] ?? step).join("; ")}.`;
316
+ }
317
+ /**
318
+ * `harbour retry` is what the help offers for resuming an operation, but it
319
+ * restarts a deployment — on an operation still waiting for its setup there is
320
+ * no deployment to restart, and running `productionise` again is what carries
321
+ * it on. Name the commands that actually work, and say which one does not.
322
+ */
323
+ function setupRemediation(operationRef) {
324
+ return `Run \`harbour profile\` and \`harbour audience\`, then \`harbour productionise\` again — not \`harbour retry\`. Full list: \`harbour setup --operation ${operationRef}\`.`;
152
325
  }
153
326
  async function execute(client, operationRef, appId, graph, approval) {
154
327
  const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
@@ -1,5 +1,50 @@
1
1
  import { CLI_VERSION } from "./version.js";
2
2
  import { CliError } from "./output.js";
3
+ /** The code for a refusal the server did not label. Its own words still reach
4
+ * the builder; only the machine label is ours, and it says exactly that. */
5
+ const UNLABELLED_REFUSAL = "HARBOUR_REFUSED";
6
+ /**
7
+ * Harbour labels part of its refusals `CODE: sentence` on the wire — `src/mcp.ts`
8
+ * raises `APP_NOT_REGISTERED: …`, `SOURCE_UPLOAD_REQUIRED: …`, and the tool
9
+ * result carries that text and nothing else. Lifting the prefix into `code` is
10
+ * not classification: the label is the server's own, the sentence is kept
11
+ * whole either way, and a miss only means the refusal goes out unlabelled.
12
+ */
13
+ const LABELLED = /^([A-Z][A-Z0-9_]{2,63}):[ \t]+(\S[\s\S]*)$/;
14
+ function refusalText(result) {
15
+ return (result.content ?? []).map(item => item.text ?? "").join("\n").trim();
16
+ }
17
+ /**
18
+ * A tool refusal, carried whole: the server's own code, its own sentence and
19
+ * its own fix. `structuredContent` is read first because that is where a code
20
+ * belongs and where the server will put one once `toolError` carries one; today
21
+ * only the labelled prefix is available.
22
+ */
23
+ export function serverRefusal(result) {
24
+ const text = refusalText(result);
25
+ const detail = result.structuredContent && typeof result.structuredContent === "object" ? result.structuredContent : undefined;
26
+ const declared = typeof detail?.code === "string" ? detail.code.trim() : "";
27
+ const hint = typeof detail?.remediationHint === "string" && detail.remediationHint.trim() ? detail.remediationHint.trim() : undefined;
28
+ const labelled = declared ? null : LABELLED.exec(text);
29
+ return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Harbour rejected the request.", undefined, hint);
30
+ }
31
+ /** A JSON-RPC error is Harbour refusing the call itself (an unknown method, a
32
+ * body it could not parse). Its numeric code is the only one it has, so it is
33
+ * reported as such rather than flattened away. */
34
+ function rpcRefusal(error, status) {
35
+ const text = error.message?.trim() ?? "";
36
+ const labelled = LABELLED.exec(text);
37
+ const code = labelled?.[1] ?? (typeof error.code === "number" ? `HARBOUR_RPC_${error.code}` : `HARBOUR_HTTP_${status}`);
38
+ return new CliError(code, (labelled?.[2] ?? text) || `Harbour refused the request (HTTP ${status}).`);
39
+ }
40
+ async function readRpc(response) {
41
+ try {
42
+ return await response.json();
43
+ }
44
+ catch {
45
+ throw new Error(`Harbour returned a response that was not JSON (HTTP ${response.status}).`);
46
+ }
47
+ }
3
48
  export class RemoteMcpClient {
4
49
  url;
5
50
  tenant;
@@ -22,7 +67,7 @@ export class RemoteMcpClient {
22
67
  const response = await this.request("tools/call", { name, arguments: args });
23
68
  const result = response;
24
69
  if (result.isError)
25
- throw new Error(result.content?.map(item => item.text ?? "").join("\n") || "Harbour rejected the request.");
70
+ throw serverRefusal(result);
26
71
  return result;
27
72
  }
28
73
  async request(method, params, expectResponse = true) {
@@ -37,9 +82,16 @@ export class RemoteMcpClient {
37
82
  throw new CliError("AUTH_REQUIRED", "Harbour sign-in expired or was revoked. Run `harbour login` again.");
38
83
  if (!expectResponse)
39
84
  return undefined;
40
- const parsed = await response.json();
41
- if (!response.ok || parsed.error)
42
- throw new Error(parsed.error?.message ?? `Harbour request failed (${response.status}).`);
85
+ const parsed = await readRpc(response);
86
+ if (parsed.error)
87
+ throw rpcRefusal(parsed.error, response.status);
88
+ // A non-ok response with no JSON-RPC error in it is the network between us
89
+ // and Harbour, not Harbour's answer, so it stays a plain Error: a long poll
90
+ // treats plain Errors as transient and tries again (see `waitForSettled`),
91
+ // and only `CliError` stops it. The wording is decided here rather than
92
+ // guessed from this string downstream.
93
+ if (!response.ok)
94
+ throw new Error(`Harbour request failed (HTTP ${response.status}).`);
43
95
  return parsed.result;
44
96
  }
45
97
  async post(body) {
@@ -47,12 +99,20 @@ export class RemoteMcpClient {
47
99
  const token = await this.token();
48
100
  if (token)
49
101
  headers.authorization = `Bearer ${token}`;
50
- return fetch(this.url, { method: "POST", headers, body });
102
+ try {
103
+ return await fetch(this.url, { method: "POST", headers, body });
104
+ }
105
+ catch (error) {
106
+ // Plain Error for the same reason as the non-ok branch: a dropped
107
+ // connection mid-deployment is transient and the poll must be free to
108
+ // retry. `redact()` strips the URL before any of this is printed.
109
+ throw new Error(`Harbour could not be reached (${error instanceof Error ? error.message : "no details"}).`);
110
+ }
51
111
  }
52
112
  }
53
113
  export function structured(result) {
54
114
  if (result.isError)
55
- throw new Error(result.content?.map(item => item.text ?? "").join("\n") || "Harbour rejected the request.");
115
+ throw serverRefusal(result);
56
116
  return result.structuredContent;
57
117
  }
58
118
  export function plainText(result) {
@@ -99,6 +99,8 @@ export function managedBlock() {
99
99
  "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
100
100
  "- No secrets, tokens, `.env` values or fetched company content in source. `.harbour/local/` is ignored and never committed; `.harbour/integrations.json` and `.harbour/kit.lock.json` are committed.",
101
101
  "- 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.",
102
+ "- `.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. The pairing is two-way and the `flow` gate refuses the deploy in both directions, so an edit that changes what the app does changes its checks in the same edit. Start using a capability and it needs its own retained check, written in that edit. 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, 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.",
103
+ "- 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.*`. Replacing the notes table with the app's own means rewriting both notes checks for that table; removing the \"Private files\" section means deleting `.harbour/checks/files-journey.mjs` in that same edit. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
102
104
  "- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (declaration, types, build, migrations + the pipeline's database gate, journeys), `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.",
103
105
  "- 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`.",
104
106
  MANAGED_END
@@ -112,10 +114,11 @@ description: Build, run and check this Harbour app with the Harbour CLI (dev, ch
112
114
  Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
113
115
 
114
116
  1. \`harbour dev --app-root .\` starts Postgres, storage and one Harbour gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
115
- 2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.harbour/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 4). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
116
- 3. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only).
117
- 4. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
118
- 5. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
117
+ 2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.harbour/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 5). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
118
+ 3. Keep \`.harbour/checks/\` paired with the code, in the same edit that changes it: one retained journey per capability the app's own code uses, and none for a capability it no longer uses. A new feature needs a new check; a feature you delete or replace (a dropped table, a removed section) means deleting or rewriting its check right then. \`harbour check\` and the deployment pipeline replay these checks against a real App Gateway and refuse the app (\`flow.check-failed\`) in both directions — a capability no check exercises, and a check that exercises something the code no longer does.
119
+ 4. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only).
120
+ 5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
121
+ 6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
119
122
  `;
120
123
  /**
121
124
  * Kit infrastructure: structural files every kit app needs, on every path —
@@ -148,8 +151,13 @@ function kitFiles() {
148
151
  function starterFiles(bundle) {
149
152
  return {
150
153
  "migrations/0001_notes.sql": MIGRATION,
151
- // One retained check per capability the starter's UI uses: the pipeline's
152
- // flow gate refuses an app whose checks never exercise a declared capability.
154
+ // Exactly one retained check per capability the starter's UI uses, and none for a
155
+ // capability it does not: the flow gate runs both ways it refuses an app whose
156
+ // checks never exercise a declared capability, and it refuses an app whose checks
157
+ // no longer pass because the code behind them is gone. The starter's own UI still
158
+ // calls `harbour.files.*` in its "Private files" section, so `files-journey.mjs`
159
+ // belongs here; an app that drops that section deletes the check with it (the
160
+ // pairing is stated in the generated App.tsx, README.md and the CLAUDE.md block).
153
161
  ".harbour/checks/notes-journey.mjs": JOURNEY_CHECK,
154
162
  ".harbour/checks/files-journey.mjs": FILES_JOURNEY_CHECK,
155
163
  // The pipeline's cross-user denial, run locally: the last gate class that
@@ -178,7 +186,12 @@ const STARTER_README = `# Harbour app
178
186
 
179
187
  Created by \`harbour init\`. Run \`harbour dev --app-root .\` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.
180
188
 
181
- \`.harbour/checks/\` holds one journey per capability the app uses (\`notes-journey.mjs\` for data, \`files-journey.mjs\` for files) and \`notes-cross-user.mjs\`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts; \`harbour check\` and the deployment pipeline refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.
189
+ \`.harbour/checks/\` holds one journey per capability the app uses (\`notes-journey.mjs\` for data, \`files-journey.mjs\` for files) and \`notes-cross-user.mjs\`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts.
190
+
191
+ The checks and the code are one pair, and \`harbour check\` and the deployment pipeline's \`flow\` gate enforce the pair in both directions against a real App Gateway. Both directions refuse the deploy:
192
+
193
+ - **A capability with no check.** They refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.
194
+ - **A check with no capability.** They refuse an app whose retained checks no longer pass against its own code, so a feature you delete or replace means deleting or rewriting its check in the same edit. Replacing the starter's \`notes\` table with your own means rewriting \`notes-journey.mjs\` and \`notes-cross-user.mjs\` for that table. Removing the "Private files" section — the only code here that calls \`harbour.files.*\` — means \`rm .harbour/checks/files-journey.mjs\` right then; left behind, it exercises a capability the app no longer has and the deploy is refused with \`flow.check-failed: files-journey.mjs: exit status 1\`.
182
195
 
183
196
  ## Adding a company system (Slack, Gmail, a warehouse view)
184
197
 
@@ -214,7 +227,7 @@ const report = await integrations().execute<{ rows: Array<{ week: string; total:
214
227
  });
215
228
  \`\`\`
216
229
 
217
- A send (\`slack.message.post\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is a user action: \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`. Anything the app calls needs its own retained check under \`.harbour/checks/\`.
230
+ A send (\`slack.message.post\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is a user action: \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit.
218
231
  `;
219
232
  const VITE_CONFIG = `import { defineConfig } from "vite";
220
233
  import react from "@vitejs/plugin-react";
@@ -282,6 +295,7 @@ export function App() {
282
295
  const { data } = await harbour.data.from<Note>("notes").select("*").order("created_at", { ascending: false });
283
296
  setNotes(data);
284
297
  }, []);
298
+ // files capability — this call and \`upload\` below are what .harbour/checks/files-journey.mjs exercises.
285
299
  const loadFiles = useCallback(async () => { setFiles((await harbour.files.list("private/")) as StoredFile[]); }, []);
286
300
 
287
301
  useEffect(() => {
@@ -299,7 +313,7 @@ export function App() {
299
313
  };
300
314
  const toggle = async (note: Note) => { await harbour.data.from("notes").update({ done: !note.done }).eq("id", note.id); await loadNotes(); };
301
315
  const remove = async (note: Note) => { await harbour.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
302
- const upload = async (file: File) => { await harbour.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
316
+ const upload = async (file: File) => { await harbour.files.upload(\`private/\${file.name}\`, file); await loadFiles(); }; // .harbour/checks/files-journey.mjs
303
317
 
304
318
  // Company systems are not part of the starter: .harbour/integrations.json declares
305
319
  // nothing, so nothing about this app waits on IT. Add one only when the app really
@@ -324,6 +338,9 @@ export function App() {
324
338
  <p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Harbour; locally the fixture user).</p>
325
339
  {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
326
340
 
341
+ {/* Notes — paired with .harbour/checks/notes-journey.mjs (data) and
342
+ .harbour/checks/notes-cross-user.mjs. Replacing this table with the app's own
343
+ means rewriting both checks for that table in the same edit. */}
327
344
  <section>
328
345
  <h2>Notes</h2>
329
346
  <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
@@ -340,6 +357,11 @@ export function App() {
340
357
  </ul>
341
358
  </section>
342
359
 
360
+ {/* Private files — the only code in this app that calls harbour.files.*, and so the
361
+ only reason .harbour/checks/files-journey.mjs exists. Delete this section and delete
362
+ that check in the same edit: the deploy's flow gate replays .harbour/checks/ against a
363
+ real App Gateway and refuses an app whose checks exercise a capability its code no
364
+ longer has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
343
365
  <section>
344
366
  <h2>Private files</h2>
345
367
  <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
@@ -369,6 +391,11 @@ GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
369
391
  const JOURNEY_CHECK = `// Journey check: the signed-in identity can create, read, tick and delete a note through
370
392
  // the running app (HARBOUR_APP_URL). Runs under \`harbour check\` while \`harbour dev\`
371
393
  // is up, and in the deployment pipeline's retained-check container.
394
+ //
395
+ // Paired with the \`notes\` table (migrations/0001_notes.sql) and the Notes section of
396
+ // src/App.tsx. Replace that table with the app's own and this check is rewritten for the
397
+ // new table in the same edit; drop the feature entirely and this file is deleted. A check
398
+ // left behind for code the app no longer has fails the deploy's flow gate.
372
399
  import assert from "node:assert/strict";
373
400
 
374
401
  const appUrl = process.env.HARBOUR_APP_URL;
@@ -400,6 +427,10 @@ const CROSS_USER_CHECK = `// Cross-user check: a second signed-in person cannot
400
427
  // owner-scoped table; this is the same check run locally, so user isolation can no longer
401
428
  // be green under \`harbour check\` and red in CodeBuild. Runs while \`harbour dev\` is up,
402
429
  // and in the pipeline's retained-check container.
430
+ //
431
+ // Paired with the \`notes\` table: an app that replaces \`notes\` with its own owner-scoped
432
+ // table rewrites this check for that table in the same edit, and one that drops the table
433
+ // deletes this file. A check left behind for code the app no longer has fails the flow gate.
403
434
  import assert from "node:assert/strict";
404
435
 
405
436
  const appUrl = process.env.HARBOUR_APP_URL;
@@ -448,6 +479,12 @@ const FILES_JOURNEY_CHECK = `// Journey check: the signed-in identity can upload
448
479
  // The deployment pipeline refuses an app whose checks never exercise a capability
449
480
  // its own code uses, so this runs under \`harbour check\` while \`harbour dev\` is up,
450
481
  // and in the pipeline's retained-check container.
482
+ //
483
+ // This file exists only for that "Private files" section — the only code in the app that
484
+ // calls \`harbour.files.*\`. If the app stops offering file upload, DELETE THIS FILE in the
485
+ // same edit that removes the section: the gate runs both ways, and a retained check for a
486
+ // capability the code no longer has refuses the deploy with
487
+ // "flow.check-failed: files-journey.mjs: exit status 1".
451
488
  import assert from "node:assert/strict";
452
489
 
453
490
  const appUrl = process.env.HARBOUR_APP_URL;
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.23";
1
+ export const CLI_VERSION = "0.1.24";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {