@fourier-labs/harbour 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,12 +7,12 @@ The Harbour CLI: sets up, runs, checks and ships an Isomorph app. It is meant to
7
7
  You describe the app in plain English inside Claude Code or Codex; the agent installs and runs everything. One paste, once per computer:
8
8
 
9
9
  ```
10
- npx -y @fourier-labs/harbour@stable agent-setup
10
+ npx -y @fourier-labs/harbour agent-setup
11
11
  ```
12
12
 
13
13
  Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit: it writes the `isomorph` skill for Claude Code (`~/.claude/skills/isomorph/SKILL.md`) and a fenced block in your global Codex instructions (`~/.codex/AGENTS.md`), and never touches your other skills or instructions.
14
14
 
15
- That `npx` line runs the current release, but `harbour init`, `dev` and `check` afterwards run whatever `harbour` is installed on this machine. So `agent-setup` also compares the two and says, in its output and in `result.cli` of `--json` (`state`, `upgradeRequired`, `remediation`), when the installed CLI is missing or behind — a stale one carries an old kit bundle and builds an app the deployment pipeline refuses. The fix is always `npm i -g @fourier-labs/harbour@stable`.
15
+ That `npx` line runs the current release, but `harbour init`, `dev` and `check` afterwards run whatever `harbour` is installed on this machine. So `agent-setup` also compares the two and says, in its output and in `result.cli` of `--json` (`state`, `upgradeRequired`, `remediation`), when the installed CLI is missing or behind — a stale one carries an old kit bundle and builds an app the deployment pipeline refuses. The fix is always `npm i -g @fourier-labs/harbour`.
16
16
 
17
17
  Then, in an empty folder:
18
18
 
@@ -8,8 +8,12 @@ import { CLI_VERSION } from "./version.js";
8
8
  const execFileAsync = promisify(execFile);
9
9
  export const MANAGED_START = "<!-- harbour:kit:start -->";
10
10
  export const MANAGED_END = "<!-- harbour:kit:end -->";
11
- /** The one command that installs or upgrades the CLI; every instruction in the kit names this tag. */
12
- export const CLI_INSTALL_COMMAND = "npm i -g @fourier-labs/harbour@stable";
11
+ /**
12
+ * The one command that installs or upgrades the CLI. It carries no dist-tag: npm resolves an
13
+ * untagged name through `latest`, which is what a release publishes, so every instruction in
14
+ * the kit names the package the same way a person typing it from memory would (LLD §15.5).
15
+ */
16
+ export const CLI_INSTALL_COMMAND = "npm i -g @fourier-labs/harbour";
13
17
  /** Where the two agents read their user-level instructions from; both honour the tools' own override variables. */
14
18
  export function agentPaths(env = process.env) {
15
19
  const home = env.HARBOUR_AGENT_HOME?.trim() || homedir();
@@ -144,7 +148,7 @@ The person you are working with may not be a developer. They say what they want
144
148
 
145
149
  ## Getting ready (do this yourself, once per machine and folder)
146
150
 
147
- 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@stable --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @fourier-labs/harbour@stable\` 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.
148
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.
149
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.
150
154
 
@@ -2,7 +2,7 @@
2
2
  import { RemoteMcpClient } from "./remote-mcp-client.js";
3
3
  import { productionise } from "./productionise.js";
4
4
  import { confirmAudience, confirmProfile, dismissSecret, fetchStatus, getAppSetup, listSecrets, outcomeFor, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize, waitForSettled } from "./operations.js";
5
- import { safeError, CliError, renderSummary } from "./output.js";
5
+ import { CliError, failureEnvelope, renderFailure, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
8
  import { connect, loadConfig, resolveConfig } from "./config.js";
@@ -255,13 +255,12 @@ else {
255
255
  }
256
256
  }
257
257
  catch (error) {
258
- const safe = safeError(error);
259
- process.stderr.write(`${safe.message}\n`);
260
- const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "FAILED", operationStarted: error instanceof CliError ? Boolean(error.operationRef) : false, ...(error instanceof CliError && error.operationRef ? { operationRef: error.operationRef } : {}), error: safe };
258
+ const envelope = failureEnvelope(error, CLI_VERSION);
259
+ process.stderr.write(renderFailure(envelope));
261
260
  if (json || command === "productionise")
262
261
  process.stdout.write(`${JSON.stringify(envelope)}\n`);
263
262
  // Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
264
- process.exitCode = safe.code === "INTEGRATIONS_NOT_READY" ? 2 : 1;
263
+ process.exitCode = envelope.error?.code === "INTEGRATIONS_NOT_READY" ? 2 : 1;
265
264
  }
266
265
  }
267
266
  function renderIntegrationsStatus(status) {
@@ -23,6 +23,32 @@ function pickDeployment(value) {
23
23
  ...(value.failure ? { failure: { ...(value.failure.message ? { message: value.failure.message } : {}), ...(value.failure.retryable !== undefined ? { retryable: value.failure.retryable } : {}), ...(value.failure.remediationHint ? { remediationHint: value.failure.remediationHint } : {}) } } : {})
24
24
  };
25
25
  }
26
+ /**
27
+ * The refusal reason source intake stored on the operation. Read the same way
28
+ * `pickDeployment` reads a deployment failure: keep the server's own code,
29
+ * wording and remediation, drop anything blank, and report nothing at all when
30
+ * the server supplied nothing — so a caller can tell "no detail" apart from
31
+ * "detail that happened to be empty".
32
+ */
33
+ export function pickSourceFailure(status) {
34
+ const failure = status.operation?.source?.failure;
35
+ if (!failure || typeof failure !== "object")
36
+ return undefined;
37
+ const code = text(failure.code);
38
+ const message = text(failure.message);
39
+ const remediationHint = text(failure.remediationHint);
40
+ if (!code && !message && !remediationHint)
41
+ return undefined;
42
+ return {
43
+ ...(code ? { code } : {}),
44
+ ...(failure.retryable !== undefined ? { retryable: failure.retryable } : {}),
45
+ ...(message ? { message } : {}),
46
+ ...(remediationHint ? { remediationHint } : {})
47
+ };
48
+ }
49
+ function text(value) {
50
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
51
+ }
26
52
  function pickProduction(value) {
27
53
  return {
28
54
  state: value.state,
@@ -2,7 +2,26 @@ export function safeError(error) {
2
2
  const code = error instanceof CliError ? error.code : "CLI_FAILED";
3
3
  const original = error instanceof Error ? error.message : "";
4
4
  const raw = error instanceof CliError ? error.message : friendlyMessage(original);
5
- return { code, message: raw.replace(/https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi, "").replace(/\s+/g, " ").trim().slice(0, 240) || "Harbour could not complete the request." };
5
+ // The hint is a separate field rather than more sentence: it is often as long
6
+ // as the message, and appending it would push the reason itself past the cap.
7
+ 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 } : {}) };
9
+ }
10
+ 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);
12
+ }
13
+ /**
14
+ * What a failed command reports: the JSON envelope on stdout and the line on
15
+ * stderr. One place, so the human line and the envelope always carry the same
16
+ * reason and the same fix.
17
+ */
18
+ export function failureEnvelope(error, cliVersion) {
19
+ const operationRef = error instanceof CliError ? error.operationRef : undefined;
20
+ return { schema: "harbour.cli-result/1.0", cliVersion, status: "FAILED", operationStarted: Boolean(operationRef), ...(operationRef ? { operationRef } : {}), error: safeError(error) };
21
+ }
22
+ export function renderFailure(envelope) {
23
+ const error = envelope.error;
24
+ return `${error?.message ?? "Harbour could not complete the request."}${error?.remediationHint ? ` ${error.remediationHint}` : ""}\n`;
6
25
  }
7
26
  /** Human-readable stdout for runs without `--json`: the operation reference and
8
27
  * whatever link or next step Harbour reported, nothing internal. */
@@ -58,10 +77,12 @@ export function renderSummary(envelope) {
58
77
  export class CliError extends Error {
59
78
  code;
60
79
  operationRef;
61
- constructor(code, message, operationRef) {
80
+ remediationHint;
81
+ constructor(code, message, operationRef, remediationHint) {
62
82
  super(message);
63
83
  this.code = code;
64
84
  this.operationRef = operationRef;
85
+ this.remediationHint = remediationHint;
65
86
  }
66
87
  }
67
88
  function friendlyMessage(message) {
@@ -6,7 +6,7 @@ import { createSourceManifest } from "../../../src/source-intake.js";
6
6
  import { structured } from "./remote-mcp-client.js";
7
7
  import { archiveForManifest, putMultipart } from "./upload.js";
8
8
  import { CliError } from "./output.js";
9
- import { getAppSetup, outcomeFor, readLine, waitForSettled } from "./operations.js";
9
+ import { getAppSetup, outcomeFor, pickSourceFailure, readLine, waitForSettled } from "./operations.js";
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertPreviewIntegrationsReady } from "./integrations.js";
@@ -134,10 +134,22 @@ async function waitForIntake(client, operationRef) {
134
134
  if (status.operation?.stage === "awaiting-approval" || status.sourceSave?.status === "QUEUED")
135
135
  return;
136
136
  if (status.operation?.stage === "failed")
137
- throw new CliError("PACKAGE_REJECTED", "Harbour could not safely accept this app package.", operationRef);
137
+ throw intakeRejection(status, operationRef);
138
138
  }
139
139
  throw new CliError("INSPECTION_TIMEOUT", "Harbour is still inspecting the app package. Resume this operation safely.", operationRef);
140
140
  }
141
+ /**
142
+ * Intake does not merely refuse a package; it records why — a stable code, one
143
+ * plain-English sentence, and the commands that fix it — on the operation. The
144
+ * stage alone was all this used to read, so every refusal reached the maker as
145
+ * the same sentence and the reason stayed on the server. Say what the server
146
+ * said, and keep the generic line only for an operation that failed without
147
+ * any detail at all.
148
+ */
149
+ 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);
152
+ }
141
153
  async function execute(client, operationRef, appId, graph, approval) {
142
154
  const args = { operationId: operationRef, appId, surface: "codex", graph, action: "save_baseline", mode: "EXECUTE" };
143
155
  if (approval)
@@ -28,6 +28,8 @@ export async function initKit(root, bundle, options = {}) {
28
28
  await writeFile(absolute, content);
29
29
  result.created.push(path);
30
30
  };
31
+ // The starter (its source, schema and retained checks) is created only when there is
32
+ // no app here yet; kit infrastructure is written on every path, including `--upgrade`.
31
33
  const appFiles = emptyDir ? starterFiles(bundle) : {};
32
34
  for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
33
35
  await write(path, content);
@@ -115,6 +117,12 @@ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
115
117
  4. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
116
118
  5. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
117
119
  `;
120
+ /**
121
+ * Kit infrastructure: structural files every kit app needs, on every path —
122
+ * `init` in an empty directory, `init` over an existing Vite + React app, and
123
+ * `--upgrade`. Nothing here is example content, because `write` only fills a
124
+ * gap: an app that deleted a file it does not want must not have it recreated.
125
+ */
118
126
  function kitFiles() {
119
127
  return {
120
128
  // No connection. The starter calls no company system, and a connection the
@@ -125,7 +133,20 @@ function kitFiles() {
125
133
  // development kit" block of AGENTS.md / CLAUDE.md and in src/App.tsx. They
126
134
  // are not in this file: it is read with JSON.parse here and again by the
127
135
  // pipeline's source intake, so it cannot carry comments.
128
- ".harbour/integrations.json": `${JSON.stringify({ schema: "harbour.app-integrations/2.0", connections: {} }, null, 2)}\n`,
136
+ ".harbour/integrations.json": `${JSON.stringify({ schema: "harbour.app-integrations/2.0", connections: {} }, null, 2)}\n`
137
+ };
138
+ }
139
+ /**
140
+ * The starter app itself: written only when `init` is creating one in an empty
141
+ * directory. This includes the notes migration and the three retained checks —
142
+ * they are the starter's own schema and journeys, not kit infrastructure, and
143
+ * they exist so a fresh starter passes the pipeline's `flow` gate (one journey
144
+ * per capability its UI declares, plus the cross-user denial). Writing them into
145
+ * an app that has its own schema recreates a `notes` table it never had, and the
146
+ * checks that query it then fail `flow` on the next deploy.
147
+ */
148
+ function starterFiles(bundle) {
149
+ return {
129
150
  "migrations/0001_notes.sql": MIGRATION,
130
151
  // One retained check per capability the starter's UI uses: the pipeline's
131
152
  // flow gate refuses an app whose checks never exercise a declared capability.
@@ -133,11 +154,7 @@ function kitFiles() {
133
154
  ".harbour/checks/files-journey.mjs": FILES_JOURNEY_CHECK,
134
155
  // The pipeline's cross-user denial, run locally: the last gate class that
135
156
  // used to exist only in CodeBuild.
136
- ".harbour/checks/notes-cross-user.mjs": CROSS_USER_CHECK
137
- };
138
- }
139
- function starterFiles(bundle) {
140
- return {
157
+ ".harbour/checks/notes-cross-user.mjs": CROSS_USER_CHECK,
141
158
  "package.json": `${JSON.stringify({
142
159
  name: "harbour-app", private: true, version: "0.1.0", type: "module",
143
160
  scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.22";
1
+ export const CLI_VERSION = "0.1.23";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {