@fourier-labs/harbour 0.1.14 → 0.1.17

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 ADDED
@@ -0,0 +1,42 @@
1
+ # @fourier-labs/harbour
2
+
3
+ The Harbour CLI: sets up, runs, checks and ships an Isomorph app. It is meant to be driven by an AI coding agent (Claude Code or Codex) on your behalf; you can also use it directly.
4
+
5
+ ## Quick start (no coding needed)
6
+
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
+
9
+ ```
10
+ npx -y @fourier-labs/harbour@stable agent-setup
11
+ ```
12
+
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. Then, in an empty folder:
14
+
15
+ 1. Say what you want, for example "Build me a small app where my team can vote on lunch options and see the results live."
16
+ 2. Say "run it" — the agent starts it and gives you a link to open.
17
+ 3. Say "check it" — the agent runs the checks and tells you in plain words what passed and what it fixed.
18
+ 4. Say "I need Slack" (or Gmail, or the warehouse) — the agent asks IT for access and tells you when it is approved.
19
+ 5. Say "ship it" — the agent puts a private preview online and gives you the link; "make it live for everyone" promotes it after you have tried it.
20
+
21
+ The only step you do yourself is the company sign-in: when the agent runs `harbour login`, your browser opens and you sign in there. You need Node 22+ and Docker Desktop; the agent tells you if either is missing. Full walkthrough: [docs/vibecoding.md](../../docs/vibecoding.md).
22
+
23
+ ## Commands
24
+
25
+ ```
26
+ harbour agent-setup install the agent guide (Claude Code skill + Codex AGENTS.md block); idempotent
27
+ harbour init --app-root <path> [--upgrade] starter app in an empty folder, or kit files in a Vite + React app (also runs agent-setup)
28
+ harbour dev --app-root <path> [--reset] run the app locally on one loopback origin
29
+ harbour stop --app-root <path> stop local services, keep data
30
+ harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys; report in .harbour/local/check-report.json
31
+ harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>]
32
+ harbour integrations status --app-root <path> [--json]
33
+ harbour connect <company-start-url> once per company; then harbour login | logout
34
+ harbour productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
35
+ harbour status | retry | promote | setup | profile | audience | secrets … --operation <reference>
36
+ ```
37
+
38
+ `harbour --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
39
+
40
+ ## Development
41
+
42
+ Built from the [governance control plane](https://github.com/Fourier-Labs-AI/harbour-governance-control-plane) repository: `npm ci`, `npm run build --prefix packages/harbour-cli`, tests in `tests/cli-kit.test.ts` and `tests/cli-packaging-contract.test.ts`. Releases are tagged `cli-v<version>` (a `-rc.N` candidate first, then the stable tag).
@@ -0,0 +1,87 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const MANAGED_START = "<!-- harbour:kit:start -->";
5
+ export const MANAGED_END = "<!-- harbour:kit:end -->";
6
+ /** Where the two agents read their user-level instructions from; both honour the tools' own override variables. */
7
+ export function agentPaths(env = process.env) {
8
+ const home = env.HARBOUR_AGENT_HOME?.trim() || homedir();
9
+ return {
10
+ claudeSkill: join(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), "skills", "isomorph", "SKILL.md"),
11
+ codexAgents: join(env.CODEX_HOME?.trim() || join(home, ".codex"), "AGENTS.md")
12
+ };
13
+ }
14
+ /**
15
+ * Installs the user-level Isomorph guide for Claude Code (a skill file, wholly owned
16
+ * by the kit) and Codex (a marker-fenced block in ~/.codex/AGENTS.md, everything else
17
+ * in that file is kept). Idempotent: unchanged files are reported as kept.
18
+ */
19
+ export async function agentSetup(env = process.env) {
20
+ const paths = agentPaths(env);
21
+ const result = { created: [], updated: [], kept: [] };
22
+ result[await upsertManagedBlock(paths.claudeSkill, `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}`, {})].push(paths.claudeSkill);
23
+ result[await upsertManagedBlock(paths.codexAgents, [MANAGED_START, AGENT_GUIDE, MANAGED_END].join("\n"), { start: MANAGED_START, end: MANAGED_END, separator: "\n\n" })].push(paths.codexAgents);
24
+ return result;
25
+ }
26
+ /**
27
+ * Writes `block` to `file`: creates the file, replaces the text between the markers
28
+ * when both are present, or appends the block after the existing content. Without
29
+ * markers the whole file is the managed block and is rewritten only when it differs.
30
+ */
31
+ export async function upsertManagedBlock(file, block, options) {
32
+ const current = await readFile(file, "utf8").catch(() => undefined);
33
+ if (current === undefined) {
34
+ await mkdir(dirname(file), { recursive: true });
35
+ await writeFile(file, `${block}\n`);
36
+ return "created";
37
+ }
38
+ const { start, end } = options;
39
+ let next;
40
+ if (start && end && current.includes(start) && current.includes(end))
41
+ next = current.replace(new RegExp(`${escape(start)}[\\s\\S]*?${escape(end)}`), () => block);
42
+ else if (start && end)
43
+ next = `${current.replace(/\n*$/, "")}${options.separator ?? "\n\n"}${block}\n`;
44
+ else
45
+ next = `${block}\n`;
46
+ if (next === current)
47
+ return "kept";
48
+ await writeFile(file, next);
49
+ return "updated";
50
+ }
51
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ const SKILL_FRONTMATTER = `---
53
+ name: isomorph
54
+ description: Build, run, check and ship a company app on Isomorph (Harbour) from plain English. Use whenever someone wants an app for their team, says "run it", "check it", "I need Slack/Gmail/company data", "ship it" or "make it live", or when a folder has a .harbour/ directory.
55
+ ---`;
56
+ /** One guide, shared by the Claude Code skill and the Codex AGENTS.md block. Written for an agent working with a non-developer. */
57
+ export const AGENT_GUIDE = `# Isomorph app kit
58
+
59
+ 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.
60
+
61
+ ## Getting ready (do this yourself, once per machine and folder)
62
+
63
+ 1. CLI: if \`harbour --version\` fails, run \`npm i -g @fourier-labs/harbour@stable\`. 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.
64
+ 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.
65
+ 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.
66
+
67
+ ## What they say → what you do
68
+
69
+ - "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.
70
+ - "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.
71
+ - "I need Slack / Gmail / the warehouse / company data" → 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.
72
+ - "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.
73
+ - "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.
74
+ - "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.
75
+
76
+ ## Building the app
77
+
78
+ - 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.
79
+ - Authentication is Harbour SSO: no login forms, no roles or ids trusted from the browser; row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\`. Every route needs a signed-in person; no public routes.
80
+ - Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`harbour dev\` and \`harbour check\` apply them.
81
+ - A Slack message is sent only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press (reused only to retry that press). Never send from an effect, a timer, a queue or during checks. Consent is a user action (\`harbour.integrations.connect\`); missing consent never falls back to another account.
82
+ - No secrets, tokens, \`.env\` values or fetched company content in source. \`.harbour/local/\` is never committed; \`.harbour/integrations.json\` and \`.harbour/kit.lock.json\` are.
83
+
84
+ ## Talking to the person
85
+
86
+ - 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."
87
+ - Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.`;
@@ -1,13 +1,15 @@
1
1
  import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { loadDatabaseGate } from "./database-gate.js";
4
+ import { CliError } from "./output.js";
3
5
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
4
- import { LocalRuntime, runningOrigin } from "./local-runtime.js";
6
+ import { LOCAL, LocalRuntime, runCommand, runningOrigin } from "./local-runtime.js";
5
7
  import { CLI_VERSION } from "./version.js";
6
8
  /** Runs the kit checks and writes `.harbour/local/check-report.json`; a source edit changes sourceDigest and so invalidates the previous report. */
7
9
  export async function runChecks(root, options) {
8
10
  const { output, run, bundle } = options;
9
11
  const checks = [];
10
- const record = (name, status, detail) => { checks.push({ name, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : status === "fail" ? "FAIL" : "skip"} ${name}${detail ? ` — ${detail}` : ""}`); };
12
+ const record = (name, status, detail) => { checks.push({ name, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : status === "fail" ? "FAIL" : "skip"} ${name}${detail ? ` — ${indentLines(detail)}` : ""}`); };
11
13
  const source = await sourceDigest(root);
12
14
  const previous = await readReport(root);
13
15
  if (previous && previous.sourceDigest !== source.digest)
@@ -27,9 +29,20 @@ export async function runChecks(root, options) {
27
29
  await throwaway.up();
28
30
  const applied = await throwaway.migrate();
29
31
  record("migrations", "pass", `${applied.length} file(s) applied to a disposable database`);
32
+ // The pipeline's database gate, on the database those files just built:
33
+ // the same script the in-loop probe runs after its replay, so the defect
34
+ // CodeBuild would name is named here, in seconds.
35
+ if (!applied.length)
36
+ record("database_gate", "not_run", "no migrations/*.sql to gate");
37
+ else {
38
+ const gate = await loadDatabaseGate(bundle, run);
39
+ const violations = await throwaway.databaseGate(gate.sql);
40
+ record("database_gate", violations.length ? "fail" : "pass", violations.length ? violations.join("\n") : `row-level security, policy verbs and grants verified for ${LOCAL.gatewayRole} (gate from ${gate.source === "fixture" ? "the pinned session fixture" : "this CLI release"})`);
41
+ }
30
42
  }
31
43
  catch (error) {
32
44
  record("migrations", "fail", error instanceof Error ? error.message : String(error));
45
+ record("database_gate", "not_run", "the migrations did not replay");
33
46
  }
34
47
  finally {
35
48
  await throwaway.down();
@@ -108,6 +121,41 @@ async function testIntegrationReads(appId, client, declaration, output) {
108
121
  }
109
122
  return results;
110
123
  }
124
+ /**
125
+ * `productionise` pre-flight for a kit app with migrations: replays them on a
126
+ * throwaway database and runs the pipeline's database gate before any
127
+ * operation exists. A gate violation or a migration that will not replay is
128
+ * refused here — the pipeline would refuse it minutes later in CodeBuild. A
129
+ * local runtime that cannot start (no Docker) is reported and skipped: the
130
+ * pipeline gate still runs.
131
+ */
132
+ export async function preflightDatabaseGate(root, bundle, output, run = runCommand) {
133
+ const lock = await readKitLock(root).catch(() => undefined);
134
+ const migrations = (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql"));
135
+ if (!lock || !migrations.length)
136
+ return "skipped";
137
+ const throwaway = LocalRuntime.forCheck(root, run);
138
+ try {
139
+ await throwaway.writeCheckFiles(bundle);
140
+ try {
141
+ await throwaway.up();
142
+ }
143
+ catch (error) {
144
+ output(`The migrations were not replayed locally (${error instanceof Error ? error.message : String(error)}); the pipeline's database gate will run in CodeBuild.`);
145
+ return "skipped";
146
+ }
147
+ await throwaway.migrate();
148
+ const gate = await loadDatabaseGate(bundle, run);
149
+ const violations = await throwaway.databaseGate(gate.sql);
150
+ if (violations.length)
151
+ throw new CliError("DATABASE_GATE_FAILED", `The migrations fail the pipeline's database gate (${violations.length} violation(s)):\n${violations.map(line => ` - ${line}`).join("\n")}\nFix migrations/ and run \`harbour check\`; the pipeline would refuse this deployment as kit.check-failed: aurora.database-gate.`);
152
+ output(`Migrations replayed and the database gate passed locally (${migrations.length} file(s), gate from ${gate.source === "fixture" ? "the pinned session fixture" : "this CLI release"}).`);
153
+ return "passed";
154
+ }
155
+ finally {
156
+ await throwaway.down();
157
+ }
158
+ }
111
159
  export async function readReport(root) {
112
160
  try {
113
161
  return JSON.parse(await readFile(kitPaths(root).report, "utf8"));
@@ -117,3 +165,5 @@ export async function readReport(root) {
117
165
  }
118
166
  }
119
167
  function lastLines(text, count = 5) { return text.trim().split("\n").slice(-count).join(" | ").slice(0, 600); }
168
+ /** A multi-line detail (one gate violation per line) keeps its lines, each under the check name. */
169
+ function indentLines(detail) { return detail.split("\n").map((line, index) => index ? ` ${line}` : line).join("\n"); }
@@ -9,6 +9,7 @@ import { connect, loadConfig, resolveConfig } from "./config.js";
9
9
  import { loadKitBundle } from "./kit-bundle.js";
10
10
  import { appRoot } from "./kit.js";
11
11
  import { initKit } from "./starter.js";
12
+ import { agentPaths, agentSetup } from "./agent-setup.js";
12
13
  import { startDev } from "./dev.js";
13
14
  import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
14
15
  import { runChecks } from "./check.js";
@@ -63,15 +64,16 @@ const usage = [
63
64
  " harbour secrets list --operation <reference> [--json]",
64
65
  " harbour secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
65
66
  " harbour secrets dismiss --operation <reference> --name <NAME>",
66
- " harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle",
67
+ " harbour agent-setup [--json] install the plain-English Isomorph guide for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/AGENTS.md)",
68
+ " harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle (also runs agent-setup)",
67
69
  " harbour dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
68
70
  " harbour stop --app-root <path> stop this app's local services, keeping data",
69
- " harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys (+ authorised real reads)",
71
+ " harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations + the pipeline's database gate (RLS, policy verbs, grants to harbour_app_gateway), journeys (+ authorised real reads)",
70
72
  " harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
71
73
  " harbour integrations status --app-root <path> [--json]",
72
74
  "Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
73
75
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
74
- "For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make.",
76
+ "For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make, then replays migrations/ on a throwaway database and refuses (DATABASE_GATE_FAILED) what the pipeline's database gate would refuse.",
75
77
  "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
76
78
  ""
77
79
  ].join("\n");
@@ -87,6 +89,14 @@ if (command === "--version" || command === "version") {
87
89
  else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
88
90
  process.stdout.write(usage);
89
91
  }
92
+ else if (command === "agent-setup") {
93
+ const result = await agentSetup(process.env);
94
+ for (const [state, paths] of Object.entries(result))
95
+ for (const path of paths)
96
+ progress(`${state.padEnd(7)} ${path}`);
97
+ progress("Claude Code: the `isomorph` skill is available in every folder. Codex: the block is in your global AGENTS.md. Say what you want to build; the agent installs and runs the kit itself.");
98
+ emit(summaryEnvelope({ ...result, paths: agentPaths(process.env) }));
99
+ }
90
100
  else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
91
101
  || (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
92
102
  || (command === "productionise" && (optionError || !root))
@@ -113,8 +123,8 @@ else {
113
123
  const config = resolveConfig(process.env, await loadConfig());
114
124
  const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
115
125
  if (command === "init") {
116
- const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId });
117
- for (const line of [...result.created.map(path => `created ${path}`), ...result.updated.map(path => `updated ${path}`), ...result.kept.map(path => `kept ${path}`), ...result.bundleChanges.map(change => `bundle ${change}`)])
126
+ const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId, env: process.env });
127
+ for (const line of [...result.created.map(path => `created ${path}`), ...result.updated.map(path => `updated ${path}`), ...result.kept.map(path => `kept ${path}`), ...result.bundleChanges.map(change => `bundle ${change}`), ...["created", "updated"].flatMap(state => result.agents[state].map(path => `${state} ${path} (agent guide)`))])
118
128
  progress(line);
119
129
  // The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
120
130
  const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
@@ -0,0 +1,53 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Where the session fixture image carries the gate (data plane build/session-fixture-image/Dockerfile). */
5
+ export const DATABASE_GATE_IMAGE_PATH = "/harbour/kit/database-gate.sql";
6
+ /**
7
+ * The kit database gate: the pipeline's own assertions over a replayed
8
+ * migration set, run by the in-loop probe (harbour-deployment-data-plane
9
+ * packages/toolkit/transformbuild/kit_database_gate.sql, embedded by
10
+ * containerprobe.go) right after the migrations replay. `harbour check`
11
+ * runs the copy the pinned session fixture image ships at
12
+ * DATABASE_GATE_IMAGE_PATH; this is the verbatim copy from data plane
13
+ * 0.73.1.0 for a fixture that predates the file. It raises one exception
14
+ * naming every violation (table / policy / grant) on its own line with the fix.
15
+ */
16
+ export const DATABASE_GATE_SQL = "-- Harbour kit database gate.\n--\n-- Runs after a kit app's migrations replayed on a scratch PostgreSQL that\n-- carries the platform role harbour_app_gateway (NOLOGIN, NOBYPASSRLS): the\n-- role the App Gateway executes every application statement as. It asserts,\n-- against the catalog rather than the migration text, the contract that role\n-- lives under:\n--\n-- 1. every application table has row-level security enabled;\n-- 2. an RLS-enabled table has at least one policy that applies to the\n-- gateway role (RLS with no policy is default-deny: the app sees no rows);\n-- 3. every verb a policy allows is GRANTed to harbour_app_gateway — a policy\n-- without its grant is a feature that fails with permission-denied only\n-- for real users (`poll_votes`, 2026-09-10);\n-- 4. tables, views and sequences are granted only to their owner and to\n-- harbour_app_gateway; the platform creates no other role, so any other\n-- grantee (PUBLIC included) is a silent production no-op at best.\n--\n-- The platform's own schema harbour_runtime (the realtime outbox) is not the\n-- app's and is skipped. One script, executed by the pipeline's in-loop probe\n-- (packages/toolkit/transformbuild/containerprobe.go) and, byte for byte, by\n-- `harbour check` from /harbour/kit/database-gate.sql in the session fixture\n-- image. It raises one exception naming every violation on its own line, with\n-- the fix; a clean database returns silently.\n\\set ON_ERROR_STOP 1\n\\set VERBOSITY terse\nDO $harbour_gate$\nDECLARE\n gateway CONSTANT text := 'harbour_app_gateway';\n violations text[] := ARRAY[]::text[];\n rec record;\n verb text;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = gateway) THEN\n RAISE EXCEPTION 'harbour database gate: the platform role % does not exist on this database; the probe bootstrap must create it before the migrations replay', gateway;\n END IF;\n\n -- 1. Row-level security on every application table.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.relrowsecurity AS rls\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind IN ('r', 'p')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n ORDER BY 1, 2\n LOOP\n IF NOT rec.rls THEN\n violations := violations || format(\n 'table %I.%I: row-level security is not enabled, so every signed-in person would see every row — fix: ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY; then CREATE POLICY ... ON %I.%I USING (...) WITH CHECK (...)',\n rec.schema_name, rec.table_name, rec.schema_name, rec.table_name, rec.schema_name, rec.table_name);\n -- 2. A policy the gateway role is subject to.\n ELSIF NOT EXISTS (\n SELECT 1 FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = rec.schema_name AND c.relname = rec.table_name\n AND (p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles))\n ) THEN\n violations := violations || format(\n 'table %I.%I: row-level security is enabled but no policy applies to %s, so the app sees no rows and every write is refused — fix: CREATE POLICY %I ON %I.%I USING (owner_subject = current_setting(''harbour.user_id'', true)) WITH CHECK (owner_subject = current_setting(''harbour.user_id'', true))',\n rec.schema_name, rec.table_name, gateway, rec.table_name || '_owner', rec.schema_name, rec.table_name);\n END IF;\n END LOOP;\n\n -- 3. Every verb a policy allows is granted to the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS table_name, c.oid AS table_oid, p.polname AS policy_name,\n CASE p.polcmd WHEN 'r' THEN ARRAY['SELECT'] WHEN 'a' THEN ARRAY['INSERT'] WHEN 'w' THEN ARRAY['UPDATE'] WHEN 'd' THEN ARRAY['DELETE']\n ELSE ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE'] END AS verbs,\n p.polroles = '{0}'::oid[] OR (SELECT oid FROM pg_roles WHERE rolname = gateway) = ANY (p.polroles) AS applies,\n (SELECT string_agg(r.rolname, ', ' ORDER BY r.rolname) FROM pg_roles r WHERE r.oid = ANY (p.polroles)) AS role_names\n FROM pg_policy p\n JOIN pg_class c ON c.oid = p.polrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n ORDER BY 1, 2, 4\n LOOP\n IF NOT rec.applies THEN\n violations := violations || format(\n 'policy %I on %I.%I: applies to role(s) %s, not to %s, so the App Gateway never satisfies it — fix: recreate the policy without a TO clause (or add TO %s)',\n rec.policy_name, rec.schema_name, rec.table_name, coalesce(rec.role_names, '(none)'), gateway, gateway);\n CONTINUE;\n END IF;\n FOREACH verb IN ARRAY rec.verbs LOOP\n IF NOT has_table_privilege(gateway, rec.table_oid, verb) THEN\n violations := violations || format(\n 'policy %I on %I.%I: allows %s but %s is never GRANTed %s on it, so the feature fails with permission-denied for real users — fix: GRANT %s ON %I.%I TO %s',\n rec.policy_name, rec.schema_name, rec.table_name, verb, gateway, verb, verb, rec.schema_name, rec.table_name, gateway);\n END IF;\n END LOOP;\n END LOOP;\n\n -- 4. Grants go only to the owner and the gateway role.\n FOR rec IN\n SELECT n.nspname AS schema_name, c.relname AS object_name,\n CASE c.relkind WHEN 'S' THEN 'SEQUENCE' WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'VIEW' ELSE 'TABLE' END AS object_kind,\n coalesce(g.rolname, 'PUBLIC') AS grantee,\n string_agg(a.privilege_type, ', ' ORDER BY a.privilege_type) AS privileges\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n CROSS JOIN LATERAL aclexplode(c.relacl) a\n LEFT JOIN pg_roles g ON g.oid = a.grantee\n WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S')\n AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'harbour_runtime')\n AND n.nspname NOT LIKE 'pg\\_toast%' AND n.nspname NOT LIKE 'pg\\_temp%'\n AND a.grantee <> c.relowner\n AND coalesce(g.rolname, '') <> gateway\n GROUP BY 1, 2, 3, 4\n ORDER BY 1, 2, 4\n LOOP\n violations := violations || format(\n 'grant %s on %s %I.%I to %s: only the platform role %s may be granted, the platform never creates %s — fix: REVOKE %s ON %s %I.%I FROM %s',\n rec.privileges, lower(rec.object_kind), rec.schema_name, rec.object_name, rec.grantee, gateway, rec.grantee,\n rec.privileges, rec.object_kind, rec.schema_name, rec.object_name, rec.grantee);\n END LOOP;\n\n IF coalesce(array_length(violations, 1), 0) > 0 THEN\n RAISE EXCEPTION 'harbour database gate: % violation(s)%', array_length(violations, 1), E'\\n' || array_to_string(violations, E'\\n');\n END IF;\nEND\n$harbour_gate$;\n";
17
+ /**
18
+ * The gate script from the pinned session fixture image when the image is
19
+ * present locally and carries it (`docker create --pull never` + `docker cp`;
20
+ * the image is distroless, so there is no shell to cat it), else the vendored
21
+ * copy. Never pulls: `harbour check` must stay a local, seconds-long step.
22
+ */
23
+ export async function loadDatabaseGate(bundle, run) {
24
+ const vendored = { sql: DATABASE_GATE_SQL, source: "vendored" };
25
+ const created = await run("docker", ["create", "--pull", "never", bundle.images.sessionFixture], { quiet: true });
26
+ const id = created.code === 0 ? created.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
27
+ if (!id)
28
+ return vendored;
29
+ const dir = await mkdtemp(join(tmpdir(), "harbour-database-gate-"));
30
+ try {
31
+ const target = join(dir, "database-gate.sql");
32
+ const copied = await run("docker", ["cp", `${id}:${DATABASE_GATE_IMAGE_PATH}`, target], { quiet: true });
33
+ if (copied.code !== 0)
34
+ return vendored;
35
+ const sql = await readFile(target, "utf8").catch(() => undefined);
36
+ return sql && sql.includes("harbour database gate") ? { sql, source: "fixture" } : vendored;
37
+ }
38
+ finally {
39
+ await rm(dir, { recursive: true, force: true });
40
+ await run("docker", ["rm", "-f", id], { quiet: true });
41
+ }
42
+ }
43
+ /** The violation lines of a failed gate (`<table|policy|grant>: <problem> — fix: <statement>`), psql's framing dropped. */
44
+ export function gateViolations(stderr) {
45
+ const lines = stderr.split("\n").map(line => line.trim()).filter(Boolean);
46
+ const start = lines.findIndex(line => line.includes("harbour database gate:"));
47
+ const body = (start >= 0 ? lines.slice(start + 1) : lines).filter(line => !/^(CONTEXT:|NOTICE:|DO$)/.test(line));
48
+ if (body.length)
49
+ return body;
50
+ if (start >= 0)
51
+ return [lines[start].replace(/^.*?ERROR:\s*/, "")];
52
+ return [lines.at(-1) ?? "psql error"];
53
+ }
@@ -33,9 +33,14 @@ export async function startDev(root, options) {
33
33
  await runtime.pull(options.bundle);
34
34
  options.output("Starting local Harbour services (postgres, storage, realtime, identity fixture, app gateway).");
35
35
  await runtime.up();
36
+ // The fixture writes the session env and the app's realtime outbox
37
+ // migration together, so waiting for the session also waits for the SQL.
38
+ const session = await runtime.sessionEnv();
36
39
  const applied = await runtime.migrate();
37
40
  options.output(`Applied ${applied.length} migration file(s).`);
38
- const session = await runtime.sessionEnv();
41
+ const watched = await runtime.installRealtimeOutbox();
42
+ if (watched > 0)
43
+ options.output(`Installed the Harbour realtime outbox for ${watched} table(s) (harbour.realtime change events).`);
39
44
  const origin = `http://127.0.0.1:${ports.origin}`;
40
45
  vite = spawn("npm", ["run", "dev"], { cwd: root, env: { ...env, HARBOUR_LOCAL_ORIGIN: origin, HARBOUR_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
41
46
  vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
@@ -103,10 +103,22 @@ function pipe(request, response, port, extraHeaders = {}) {
103
103
  upstream.on("error", () => reject(response, 502, "UNAVAILABLE", "The local service is not responding."));
104
104
  request.pipe(upstream);
105
105
  }
106
+ /**
107
+ * An upgrade keeps the browser's own `Host` — this origin's — instead of the
108
+ * internal target port. A WebSocket server's same-origin defence compares
109
+ * `Origin` against `Host`: the App Gateway's `websocket.Accept` answers
110
+ * `403 request Origin "…" is not authorized for Host "…"` when they differ, so
111
+ * rewriting `Host` to the gateway's published port broke `/_harbour/realtime`
112
+ * (live updates fell back to polling) while every plain `/_harbour/*` call, which
113
+ * has no such check, kept working. Both targets listen on loopback and route by
114
+ * path, never by `Host`, and `sameOrigin` above has already rejected anything
115
+ * whose `Host`/`Origin` is not exactly this origin — forwarding the real pair is
116
+ * what lets the gateway's check do its job rather than defeating it.
117
+ */
106
118
  function tunnel(request, socket, head, port, extraHeaders) {
107
119
  const upstream = connect(port, "127.0.0.1", () => {
108
120
  const lines = [`${request.method} ${request.url} HTTP/1.1`];
109
- for (const [name, value] of Object.entries({ ...request.headers, ...extraHeaders, host: `127.0.0.1:${port}` }))
121
+ for (const [name, value] of Object.entries({ ...request.headers, ...extraHeaders }))
110
122
  if (value !== undefined)
111
123
  for (const item of Array.isArray(value) ? value : [value])
112
124
  lines.push(`${name}: ${item}`);
@@ -10,7 +10,7 @@ import { readFile } from "node:fs/promises";
10
10
  */
11
11
  export const EMBEDDED_KIT_BUNDLE = {
12
12
  schema: "harbour.kit-bundle/1.0",
13
- kitVersion: "0.1.14",
13
+ kitVersion: "0.1.16",
14
14
  sdk: {
15
15
  package: "@harbour/app-sdk",
16
16
  version: "1.0.0",
@@ -18,8 +18,8 @@ export const EMBEDDED_KIT_BUNDLE = {
18
18
  url: "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:62954161ec29148c20223316d83367b781dd9291665ea16f74a003b16e304a3b"
19
19
  },
20
20
  images: {
21
- appGateway: "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:387f51c7a824fc839044a2582fc3271acf2ff2b704d9ca6bb6d1edb06a845def",
22
- sessionFixture: "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:a64025be110e592927b5c2a1ef5312e24624208e9427e74deb3873453512b0ca",
21
+ appGateway: "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:4c8d359ee18f1fa6f96dde80379fa30a3c5a7675b6e9c2150e56d5ec41ba3e7c",
22
+ sessionFixture: "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:32cbb4f1820fd84b9e790b8559db5aae632b3deb77a7de87b916f75a4ca37ddc",
23
23
  postgres: "postgres:16-alpine",
24
24
  minio: "minio/minio:RELEASE.2025-07-23T15-54-02Z",
25
25
  nats: "nats:2.11.17-alpine"
@@ -1,8 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:net";
5
5
  import { join, relative } from "node:path";
6
+ import { gateViolations } from "./database-gate.js";
6
7
  import { kitPaths, projectName } from "./kit.js";
7
8
  import { CliError } from "./output.js";
8
9
  export const runCommand = (command, args, options = {}) => new Promise(resolve => {
@@ -35,7 +36,9 @@ export const LOCAL = {
35
36
  /**
36
37
  * Compose file for one project: every port bound to 127.0.0.1, named volumes
37
38
  * prefixed with the project name, images pinned from the bundle manifest.
38
- * The fixture writes the session env (identity token) to the shared state dir.
39
+ * The fixture writes the session env (identity token) and the platform's
40
+ * realtime outbox migration (generated from the app's migrations, staged under
41
+ * state/migrations) to the shared state dir.
39
42
  */
40
43
  export function composeFile(project, bundle, ports, stateDir) {
41
44
  const images = bundle.images;
@@ -62,7 +65,7 @@ export function composeFile(project, bundle, ports, stateDir) {
62
65
  ` ports: ["127.0.0.1:${ports.nats}:4222"]`,
63
66
  " fixture:",
64
67
  ` image: ${images.sessionFixture}`,
65
- " command: [\"--listen\", \"0.0.0.0:8080\", \"--session\", \"" + project + "\", \"--base-url\", \"http://127.0.0.1:" + ports.fixture + "\", \"--tenant\", \"" + LOCAL.tenant + "\", \"--app\", \"" + LOCAL.app + "\", \"--user-id\", \"" + LOCAL.userId + "\", \"--email\", \"" + LOCAL.email + "\", \"--metadata-file\", \"/state/session.json\", \"--env-file\", \"/state/session.env\", \"--display-directory\", \"" + stateDir + "\"]",
68
+ " command: [\"--listen\", \"0.0.0.0:8080\", \"--session\", \"" + project + "\", \"--base-url\", \"http://127.0.0.1:" + ports.fixture + "\", \"--tenant\", \"" + LOCAL.tenant + "\", \"--app\", \"" + LOCAL.app + "\", \"--user-id\", \"" + LOCAL.userId + "\", \"--email\", \"" + LOCAL.email + "\", \"--metadata-file\", \"/state/session.json\", \"--env-file\", \"/state/session.env\", \"--display-directory\", \"" + stateDir + "\", \"--migrations-dir\", \"/state/migrations\", \"--realtime-outbox-file\", \"/state/realtime-outbox.sql\"]",
66
69
  " environment:",
67
70
  ` HARBOUR_LOCAL_COMPOSE_PROJECT: ${project}`,
68
71
  ` HARBOUR_LOCAL_S3_INTERNAL_ENDPOINT: http://minio:9000`,
@@ -92,6 +95,26 @@ export function composeFile(project, bundle, ports, stateDir) {
92
95
  ` ports: ["127.0.0.1:${ports.gateway}:8080"]`,
93
96
  ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro"]`,
94
97
  " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy }, fixture: { condition: service_started } }",
98
+ // The relay that moves committed rows from harbour_runtime.event_outbox to
99
+ // JetStream (docs/local-kit.md "Outbox relay container"): the hosted
100
+ // gateway starts it per app, the static local configuration does not.
101
+ // It shares the gateway's configuration and so fetches the fixture's JWKS
102
+ // before it reaches its own mode, even though a relay verifies no token:
103
+ // it therefore waits for the fixture like the gateway does, and restarts
104
+ // when it still wins the race (the fixture opens its listener only after
105
+ // its bucket is ready, so `service_started` is not `serving`).
106
+ " outbox:",
107
+ ` image: ${images.appGateway}`,
108
+ " environment:",
109
+ " HARBOUR_APP_GATEWAY_MODE: outbox",
110
+ " HARBOUR_APP_GATEWAY_CONFIG_FILE: /config/app-gateway.json",
111
+ " HARBOUR_APP_GATEWAY_UPLOAD_KEY: " + uploadKey(project),
112
+ " HARBOUR_NATS_URL: nats://nats:4222",
113
+ " AWS_REGION: us-east-1",
114
+ " AWS_EC2_METADATA_DISABLED: \"true\"",
115
+ ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro"]`,
116
+ " restart: on-failure",
117
+ " depends_on: { postgres: { condition: service_healthy }, nats: { condition: service_started }, fixture: { condition: service_started } }",
95
118
  "volumes:",
96
119
  ` postgres-data: { name: ${project}-postgres }`,
97
120
  ` minio-data: { name: ${project}-minio }`,
@@ -122,6 +145,10 @@ export function gatewayConfig(ports) {
122
145
  }]
123
146
  };
124
147
  }
148
+ /** `migrations/*.sql` in name order — what `harbour dev` applies and what the fixture derives the outbox from. */
149
+ async function migrationNames(root) {
150
+ return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
151
+ }
125
152
  function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
126
153
  function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
127
154
  // ---- Ports and lock ------------------------------------------------------------------
@@ -197,6 +224,15 @@ export class LocalRuntime {
197
224
  await mkdir(paths.state, { recursive: true });
198
225
  await writeFile(paths.compose, composeFile(this.project, bundle, ports, paths.state));
199
226
  await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(gatewayConfig(ports), null, 2)}\n`);
227
+ // The fixture derives the realtime outbox from the app's migrations; they
228
+ // are staged into the state dir (already mounted at /state) rather than
229
+ // bind-mounting migrations/, which Docker would create as root if absent.
230
+ const staged = join(paths.state, "migrations");
231
+ await rm(staged, { recursive: true, force: true });
232
+ await mkdir(staged, { recursive: true });
233
+ for (const name of await migrationNames(this.root))
234
+ await copyFile(join(this.root, "migrations", name), join(staged, name));
235
+ await rm(join(paths.state, "realtime-outbox.sql"), { force: true });
200
236
  }
201
237
  /**
202
238
  * Pulls the pinned images before `up`, so a registry problem is named as such
@@ -230,8 +266,8 @@ export class LocalRuntime {
230
266
  /** Applies `migrations/*.sql` in name order through psql inside the postgres container, after ensuring the runtime role. */
231
267
  async migrate() {
232
268
  const dir = join(this.root, "migrations");
233
- const names = (await readdir(dir).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
234
- const psql = (sql) => this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
269
+ const names = await migrationNames(this.root);
270
+ const psql = (sql) => this.psql(sql);
235
271
  const role = await psql(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
236
272
  if (role.code !== 0)
237
273
  throw new CliError("MIGRATION_FAILED", "Could not prepare the local database role.");
@@ -242,6 +278,35 @@ export class LocalRuntime {
242
278
  }
243
279
  return names;
244
280
  }
281
+ /**
282
+ * Runs the kit database gate (one psql script, see database-gate.ts) against
283
+ * this project's database after `migrate()`. Resolves to the violation lines,
284
+ * empty when the schema satisfies the App Gateway role's contract.
285
+ */
286
+ async databaseGate(sql) {
287
+ const result = await this.psql(sql);
288
+ return result.code === 0 ? [] : gateViolations(result.stderr);
289
+ }
290
+ psql(sql) {
291
+ return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
292
+ }
293
+ /**
294
+ * Applies the platform's realtime outbox migration the session fixture
295
+ * generated for this app's tables (state/realtime-outbox.sql: the data
296
+ * plane's one generator, the same text the hosted pipeline appends to the
297
+ * app's bundle) — after the app's migrations, idempotent on every start.
298
+ * Returns the number of watched tables; 0 when the app has no tables.
299
+ */
300
+ async installRealtimeOutbox() {
301
+ const path = join(kitPaths(this.root).state, "realtime-outbox.sql");
302
+ const sql = await readFile(path, "utf8").catch(() => "");
303
+ if (!sql.trim())
304
+ return 0;
305
+ const result = await this.psql(sql);
306
+ if (result.code !== 0)
307
+ throw new CliError("MIGRATION_FAILED", `The realtime outbox migration failed: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
308
+ return (sql.match(/harbour_runtime\.watch_table\(/g) ?? []).length;
309
+ }
245
310
  /** The fixture's session env (identity token for the local app user) once the fixture has written it. */
246
311
  async sessionEnv(attempts = 60) {
247
312
  const path = join(kitPaths(this.root).state, "session.env");
@@ -11,6 +11,7 @@ import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertPreviewIntegrationsReady } from "./integrations.js";
13
13
  import { recordKitAppId } from "./kit.js";
14
+ import { preflightDatabaseGate } from "./check.js";
14
15
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
15
16
  const root = resolve(rootArg);
16
17
  output(`Harbour is checking ${basename(root)}.`);
@@ -26,6 +27,12 @@ export async function productionise(rootArg, client, output, tenantId, includePa
26
27
  // A declared connection without its preview grant would only park the
27
28
  // deployment after the save; refuse here, before any operation exists.
28
29
  const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
30
+ // A kit migration set the pipeline's database gate would refuse (a table
31
+ // without RLS, a policy verb never granted, a grant to a role the platform
32
+ // never creates) is refused here, before the save, with the same wording.
33
+ const gate = options.databaseGate === false ? undefined : options.databaseGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
34
+ if (gate)
35
+ await preflightDatabaseGate(root, gate.bundle, output, gate.run);
29
36
  try {
30
37
  await client.initialize();
31
38
  }
@@ -1,12 +1,15 @@
1
1
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { bundleDiff } from "./kit-bundle.js";
4
+ import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
4
5
  import { newKitLock, readKitLock, writeKitLock } from "./kit.js";
5
6
  import { CliError } from "./output.js";
7
+ export { MANAGED_END, MANAGED_START };
6
8
  /**
7
9
  * Creates the starter in an empty directory, or adds the missing kit files to
8
10
  * an existing Vite + React app. User files are never overwritten: a path that
9
- * exists is reported as kept. Instruction files get a managed block appended.
11
+ * exists is reported as kept. Instruction files get a managed block appended, and
12
+ * the user-level agent guide is installed so the agents know the kit from any folder.
10
13
  */
11
14
  export async function initKit(root, bundle, options = {}) {
12
15
  await mkdir(root, { recursive: true });
@@ -14,7 +17,7 @@ export async function initKit(root, bundle, options = {}) {
14
17
  const emptyDir = !existingPackage && !(await exists(join(root, "src")));
15
18
  if (existingPackage && !isSupportedApp(existingPackage))
16
19
  throw new CliError("APP_UNSUPPORTED", "harbour init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).");
17
- const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [] };
20
+ const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [] } };
18
21
  const write = async (path, content) => {
19
22
  const absolute = join(root, path);
20
23
  if (await exists(absolute)) {
@@ -56,35 +59,26 @@ function isSupportedApp(pkg) {
56
59
  }
57
60
  async function appendManaged(root, file, block, result, separator, start, end) {
58
61
  const absolute = join(root, file);
62
+ if (start && end) {
63
+ result[await upsertManagedBlock(absolute, block, { start, end, separator })].push(file);
64
+ return;
65
+ }
59
66
  const current = await readFile(absolute, "utf8").catch(() => undefined);
60
67
  if (current === undefined) {
61
68
  await writeFile(absolute, `${block}\n`);
62
69
  result.created.push(file);
63
70
  return;
64
71
  }
65
- if (start && end && current.includes(start) && current.includes(end)) {
66
- const next = current.replace(new RegExp(`${escape(start)}[\\s\\S]*?${escape(end)}`), block);
67
- if (next !== current) {
68
- await writeFile(absolute, next);
69
- result.updated.push(file);
70
- }
71
- else
72
- result.kept.push(file);
73
- return;
74
- }
75
- if (!start && block.split("\n").every(line => current.split("\n").includes(line))) {
72
+ if (block.split("\n").every(line => current.split("\n").includes(line))) {
76
73
  result.kept.push(file);
77
74
  return;
78
75
  }
79
76
  await writeFile(absolute, `${current.replace(/\n*$/, "")}${separator}${block}\n`);
80
77
  result.updated.push(file);
81
78
  }
82
- const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
79
  const exists = (path) => stat(path).then(() => true, () => false);
84
80
  const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
85
81
  // ---- Templates -----------------------------------------------------------------
86
- export const MANAGED_START = "<!-- harbour:kit:start -->";
87
- export const MANAGED_END = "<!-- harbour:kit:end -->";
88
82
  const GITIGNORE_LINES = ["node_modules/", "dist/", ".harbour/local/"].join("\n");
89
83
  /** Condensed from the pipeline briefs (browser-sdk-to-harbour, route-auth-policy, auth-local-to-harbour-sso) for a kit app. */
90
84
  export function managedBlock() {
@@ -101,9 +95,9 @@ export function managedBlock() {
101
95
  "- Authentication is owned by Harbour SSO. Do not add login forms, JWT handling, or trust a role, owner id or tenant id supplied by the browser. Row ownership is decided in SQL through `current_setting('harbour.user_id', true)` and `current_setting('harbour.user_email', true)`.",
102
96
  "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
103
97
  "- 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.",
104
- "- Schema changes are SQL files in `migrations/`, applied by `harbour dev` and `harbour check`. Grant browser-path table privileges to `harbour_app_gateway`.",
105
- "- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (declaration, types, build, migrations, 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.",
106
- "- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`.",
98
+ "- 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.",
99
+ "- 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.",
100
+ "- 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`.",
107
101
  MANAGED_END
108
102
  ].join("\n");
109
103
  }
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.14";
1
+ export const CLI_VERSION = "0.1.17";
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { lstat, readFile, readdir, stat } from "node:fs/promises";
5
5
  import { basename, dirname, join, relative } from "node:path";
6
6
  import { sha256 } from "./digest.js";
7
- import { isEnvExamplePath, isSourceIntakeExcludedPath } from "./secret-paths.js";
7
+ import { isEnvExamplePath, isKitCommittedPath, isSourceIntakeExcludedPath } from "./secret-paths.js";
8
8
  const ANALYZER_VERSION = "0.1.0";
9
9
  const MAX_FILES = 600;
10
10
  const MAX_FILE_BYTES = 256_000;
@@ -12,8 +12,9 @@ const MAX_FILE_BYTES = 256_000;
12
12
  // application files. Keeping them out also makes reruns idempotent after a
13
13
  // previous save has written a receipt into the selected workspace.
14
14
  const IGNORED_DIRS = new Set([".git", ".harbour", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
15
- /** The development kit commits exactly these two files under `.harbour`; everything else there (local state, checks evidence, generated control files) stays out of the package. */
16
- const KIT_COMMITTED_FILES = new Set([".harbour/integrations.json", ".harbour/kit.lock.json"]);
15
+ /** The development kit commits its declaration, lock and journey checks under `.harbour` (see `isKitCommittedPath`); everything else there (local state, checks evidence, generated control files) stays out of the package. */
16
+ const KIT_CONTROL_FILES = [".harbour/integrations.json", ".harbour/kit.lock.json"];
17
+ const KIT_CHECKS_DIR = ".harbour/checks";
17
18
  const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
18
19
  const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh"]);
19
20
  function normalizeIncludePath(value) {
@@ -29,7 +30,7 @@ function normalizeIncludePath(value) {
29
30
  async function collectIncludedFiles(root, includePaths, output, unknowns) {
30
31
  const normalized = [...new Set(includePaths.map(normalizeIncludePath))];
31
32
  for (const include of normalized) {
32
- if (include.split("/").some(part => IGNORED_DIRS.has(part)) && !KIT_COMMITTED_FILES.has(include))
33
+ if (include.split("/").some(part => IGNORED_DIRS.has(part)) && !isKitCommittedPath(include))
33
34
  continue;
34
35
  const absolute = include ? join(root, ...include.split("/")) : root;
35
36
  let info;
@@ -87,7 +88,20 @@ async function collectFiles(root, current, output, unknowns) {
87
88
  }
88
89
  }
89
90
  async function collectKitFiles(root, output) {
90
- for (const path of [...KIT_COMMITTED_FILES].sort()) {
91
+ const paths = [...KIT_CONTROL_FILES];
92
+ let checks = [];
93
+ try {
94
+ checks = await readdir(join(root, ...KIT_CHECKS_DIR.split("/")), { withFileTypes: true });
95
+ }
96
+ catch {
97
+ checks = [];
98
+ }
99
+ for (const entry of checks) {
100
+ const path = `${KIT_CHECKS_DIR}/${entry.name}`;
101
+ if (isKitCommittedPath(path))
102
+ paths.push(path);
103
+ }
104
+ for (const path of paths.sort()) {
91
105
  const absolute = join(root, ...path.split("/"));
92
106
  let info;
93
107
  try {
@@ -17,11 +17,17 @@ export function isSourceIntakeExcludedPath(path) {
17
17
  return /(^|\/)(?:__MACOSX)(?:\/|$)|(^|\/)\.DS_Store$/i.test(path)
18
18
  || /(^|\/)\.github\/workflows\//i.test(path);
19
19
  }
20
- /** The development kit commits exactly these two files under `.harbour`; everything else there stays local. */
20
+ /**
21
+ * The development kit commits exactly these files under `.harbour`: the
22
+ * integrations declaration, the kit lock, and the journey checks directly in
23
+ * `.harbour/checks/` as `.mjs`, `.js` or `.cjs` modules (no subdirectories, no
24
+ * other extensions — evidence and reports written next to them stay local).
25
+ * Everything else there (local state, generated control files) stays local.
26
+ */
21
27
  export function isKitCommittedPath(path) {
22
- return /(^|\/)\.harbour\/(?:integrations\.json|kit\.lock\.json)$/.test(path);
28
+ return /(^|\/)\.harbour\/(?:integrations\.json|kit\.lock\.json|checks\/[^/]+\.(?:mjs|js|cjs))$/.test(path);
23
29
  }
24
- /** Dependency and internal directories the intake never stages — `.harbour` except its two committed kit files. */
30
+ /** Dependency and internal directories the intake never stages — `.harbour` except its committed kit files. */
25
31
  export function isInternalSourcePath(path) {
26
32
  return /(^|\/)(?:node_modules|\.git)(?:\/|$)/i.test(path) || (/(^|\/)\.harbour(?:\/|$)/i.test(path) && !isKitCommittedPath(path));
27
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },