@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 +2 -2
- package/dist/packages/harbour-cli/src/agent-setup.js +7 -3
- package/dist/packages/harbour-cli/src/cli.js +4 -5
- package/dist/packages/harbour-cli/src/operations.js +26 -0
- package/dist/packages/harbour-cli/src/output.js +23 -2
- package/dist/packages/harbour-cli/src/productionise.js +14 -2
- package/dist/packages/harbour-cli/src/starter.js +23 -6
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
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 {
|
|
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
|
|
259
|
-
process.stderr.write(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.1.23";
|