@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.
- package/dist/packages/harbour-cli/src/agent-setup.js +8 -5
- package/dist/packages/harbour-cli/src/output.js +87 -16
- package/dist/packages/harbour-cli/src/productionise.js +181 -8
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +66 -6
- package/dist/packages/harbour-cli/src/starter.js +46 -9
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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).
|
|
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
|
|
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\`.
|
|
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
|
|
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
|
|
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) ||
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
41
|
-
if (
|
|
42
|
-
throw
|
|
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
|
-
|
|
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
|
|
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
|
|
116
|
-
3.
|
|
117
|
-
4. \`harbour
|
|
118
|
-
5. \`harbour
|
|
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
|
-
//
|
|
152
|
-
// flow gate
|
|
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
|
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.1.24";
|