@fourier-labs/harbour 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/harbour-cli/src/check.js +1 -1
- package/dist/packages/harbour-cli/src/cli.js +15 -2
- package/dist/packages/harbour-cli/src/dev.js +2 -2
- package/dist/packages/harbour-cli/src/docker-networks.js +126 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +4 -4
- package/dist/packages/harbour-cli/src/local-runtime.js +28 -8
- package/dist/packages/harbour-cli/src/starter.js +3 -2
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
|
@@ -182,7 +182,7 @@ async function ensureSession(root, runtime, bundle, output) {
|
|
|
182
182
|
await runtime.writeFiles(bundle, ports);
|
|
183
183
|
output("harbour dev is not running; starting the local Harbour services for the gate (stopped again afterwards).");
|
|
184
184
|
await runtime.pull(bundle);
|
|
185
|
-
await runtime.up();
|
|
185
|
+
await runtime.up(output);
|
|
186
186
|
await runtime.sessionEnv();
|
|
187
187
|
return true;
|
|
188
188
|
}
|
|
@@ -12,6 +12,7 @@ import { initKit } from "./starter.js";
|
|
|
12
12
|
import { agentPaths, agentSetup, cliVersionLines } from "./agent-setup.js";
|
|
13
13
|
import { startDev } from "./dev.js";
|
|
14
14
|
import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
15
|
+
import { stopAllHarbourProjects } from "./docker-networks.js";
|
|
15
16
|
import { runChecks } from "./check.js";
|
|
16
17
|
import { GovernanceClient, integrationsStatus, requestIntegrations } from "./integrations.js";
|
|
17
18
|
const args = process.argv.slice(2);
|
|
@@ -30,6 +31,8 @@ const valueStdin = args.includes("--value-stdin");
|
|
|
30
31
|
const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
31
32
|
const wait = args.includes("--wait");
|
|
32
33
|
const reset = args.includes("--reset");
|
|
34
|
+
const stopAll = args.includes("--all");
|
|
35
|
+
const force = args.includes("--force");
|
|
33
36
|
const upgrade = args.includes("--upgrade");
|
|
34
37
|
const testIntegrations = args.includes("--integrations");
|
|
35
38
|
const reason = optionValue("--reason");
|
|
@@ -67,7 +70,8 @@ const usage = [
|
|
|
67
70
|
" harbour agent-setup [--json] install the plain-English Isomorph guide for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/AGENTS.md)",
|
|
68
71
|
" harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle (also runs agent-setup)",
|
|
69
72
|
" harbour dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
|
|
70
|
-
" harbour stop --app-root <path> stop this app's local services, keeping data",
|
|
73
|
+
" harbour stop --app-root <path> stop this app's local services (containers and network removed), keeping data",
|
|
74
|
+
" harbour stop --all [--force] bring down every stopped Harbour app's local services on this machine (frees Docker's network pool); --force includes running ones",
|
|
71
75
|
" harbour check --app-root <path> [--integrations] [--json] types, build, then the pipeline's kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, journeys, operation coverage (+ authorised real reads)",
|
|
72
76
|
" harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
|
|
73
77
|
" harbour integrations status --app-root <path> [--json]",
|
|
@@ -105,7 +109,7 @@ else if (command === "agent-setup") {
|
|
|
105
109
|
else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
106
110
|
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
107
111
|
|| (command === "productionise" && (optionError || !root))
|
|
108
|
-
|| (LOCAL_COMMANDS.includes(command) && !root)
|
|
112
|
+
|| (LOCAL_COMMANDS.includes(command) && !root && !(command === "stop" && stopAll))
|
|
109
113
|
|| (command === "integrations" && (!root || !subcommand || !["request", "status"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
|
|
110
114
|
|| (OPERATION_COMMANDS.includes(command) && !operationRef)
|
|
111
115
|
|| (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
|
|
@@ -121,6 +125,15 @@ else {
|
|
|
121
125
|
process.stdout.write(`Harbour is connected for ${saved.tenantId}.\n`);
|
|
122
126
|
process.exitCode = 0;
|
|
123
127
|
}
|
|
128
|
+
else if (command === "stop" && stopAll) {
|
|
129
|
+
// Every Harbour project on this machine, no app root needed: the complete remedy for a full Docker address pool.
|
|
130
|
+
const result = await stopAllHarbourProjects(runCommand, { force });
|
|
131
|
+
for (const project of result.stopped)
|
|
132
|
+
progress(`Brought down ${project.root ?? project.project} (data kept).`);
|
|
133
|
+
for (const project of result.running)
|
|
134
|
+
progress(`Left ${project.root ?? project.project} running; \`harbour stop --app-root ${project.root ?? "<path>"}\` or \`--force\` stops it.`);
|
|
135
|
+
emit(summaryEnvelope({ stopped: result.stopped.map(project => project.root ?? project.project), running: result.running.map(project => project.root ?? project.project), volumesRetained: true }));
|
|
136
|
+
}
|
|
124
137
|
else if (LOCAL_COMMANDS.includes(command)) {
|
|
125
138
|
// Local commands run before the company config/login requirement: the base app needs neither.
|
|
126
139
|
const bundle = EMBEDDED_KIT_BUNDLE;
|
|
@@ -7,7 +7,7 @@ import { CliError } from "./output.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Starts the project's Compose services, applies migrations, starts Vite and the
|
|
9
9
|
* loopback origin. Resolves when the runtime is up; the returned `stop` runs
|
|
10
|
-
* `docker compose
|
|
10
|
+
* `docker compose down` without `-v` (volumes retained, network freed) and is what Ctrl-C calls.
|
|
11
11
|
*/
|
|
12
12
|
export async function startDev(root, options) {
|
|
13
13
|
const run = options.run ?? runCommand;
|
|
@@ -33,7 +33,7 @@ export async function startDev(root, options) {
|
|
|
33
33
|
options.output("Pulling the kit images by digest (public registry, no login).");
|
|
34
34
|
await runtime.pull(options.bundle);
|
|
35
35
|
options.output("Starting local Harbour services (postgres, storage, one Harbour gateway with the session identities, fixtures and realtime relay).");
|
|
36
|
-
await runtime.up();
|
|
36
|
+
await runtime.up(options.output);
|
|
37
37
|
// The gateway writes the session env and the app's realtime outbox
|
|
38
38
|
// migration together once it is serving, so waiting for the session also
|
|
39
39
|
// waits for the SQL and for the gateway.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Docker's bridge address pool. Every `harbour dev` / `harbour check` project
|
|
4
|
+
* is a Compose project with its own bridge network, and the engine's default
|
|
5
|
+
* pool holds only ~31 of them (Docker Desktop and colima: 172.17-172.31/16 plus
|
|
6
|
+
* 192.168.0.0/16 cut into /20s). Once it is full, `compose up` fails to create
|
|
7
|
+
* the network before a single container starts — a "Docker could not start"
|
|
8
|
+
* that has nothing to do with the app. This module names that failure, reclaims
|
|
9
|
+
* the networks Harbour itself left behind (projects of other app roots with no
|
|
10
|
+
* running container) and, when that is not enough, says what only the person
|
|
11
|
+
* can do.
|
|
12
|
+
*/
|
|
13
|
+
/** The network label every Harbour compose project carries (compose puts it on the project's default network). */
|
|
14
|
+
export const HARBOUR_NETWORK_LABEL = "com.harbour.kit";
|
|
15
|
+
/** The engine's wording for an exhausted pool, across versions and the compose wrapper. */
|
|
16
|
+
const POOL_EXHAUSTED = /all predefined address pools have been fully subnetted|could not find an available,? non-overlapping IPv4 address pool|no available IPv4 addresses on this network'?s address pools|could not allocate an IPv4 subnet/i;
|
|
17
|
+
export function isAddressPoolExhausted(stderr) { return POOL_EXHAUSTED.test(stderr); }
|
|
18
|
+
/** The name Harbour gives its compose projects: `harbour-<12 hex>` (+ `-check`) — the recognition rule for projects created before the network label existed. */
|
|
19
|
+
const HARBOUR_PROJECT = /^harbour-[0-9a-f]{12}(?:-check)?$/;
|
|
20
|
+
/**
|
|
21
|
+
* Every compose project on this machine that Harbour created: networks that
|
|
22
|
+
* carry the Harbour label or (older CLIs) the Harbour project-name pattern.
|
|
23
|
+
* Networks without a compose project label are never listed — a network some
|
|
24
|
+
* other tool made is not Harbour's to touch.
|
|
25
|
+
*/
|
|
26
|
+
export async function listHarbourProjects(run) {
|
|
27
|
+
const networks = await run("docker", ["network", "ls", "--filter", "label=com.docker.compose.project", "--format", "{{.Name}}\t{{.Labels}}"], { quiet: true });
|
|
28
|
+
if (networks.code !== 0)
|
|
29
|
+
return [];
|
|
30
|
+
const roots = await projectRoots(run);
|
|
31
|
+
const projects = [];
|
|
32
|
+
for (const line of networks.stdout.split("\n")) {
|
|
33
|
+
const [network, labels = ""] = line.trim().split("\t");
|
|
34
|
+
if (!network)
|
|
35
|
+
continue;
|
|
36
|
+
const project = labels.split(",").map(pair => pair.split("=")).find(([key]) => key === "com.docker.compose.project")?.[1];
|
|
37
|
+
if (!project)
|
|
38
|
+
continue;
|
|
39
|
+
const harbour = labels.split(",").some(pair => pair === `${HARBOUR_NETWORK_LABEL}=1`) || HARBOUR_PROJECT.test(project);
|
|
40
|
+
if (!harbour)
|
|
41
|
+
continue;
|
|
42
|
+
const containers = await run("docker", ["ps", "-a", "--filter", `label=com.docker.compose.project=${project}`, "--format", "{{.State}}"], { quiet: true });
|
|
43
|
+
const states = containers.code === 0 ? containers.stdout.split("\n").map(state => state.trim()).filter(Boolean) : [];
|
|
44
|
+
const running = states.filter(state => state === "running" || state === "restarting" || state === "paused").length;
|
|
45
|
+
projects.push({ project, network, root: roots.get(project), running, total: states.length });
|
|
46
|
+
}
|
|
47
|
+
return projects;
|
|
48
|
+
}
|
|
49
|
+
/** Project name -> app root, from the compose files compose still remembers (`<root>/.harbour/local/<compose>.yml`). */
|
|
50
|
+
async function projectRoots(run) {
|
|
51
|
+
const roots = new Map();
|
|
52
|
+
const result = await run("docker", ["compose", "ls", "-a", "--format", "json"], { quiet: true });
|
|
53
|
+
if (result.code !== 0)
|
|
54
|
+
return roots;
|
|
55
|
+
let entries = [];
|
|
56
|
+
try {
|
|
57
|
+
entries = JSON.parse(result.stdout || "[]");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return roots;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const file = entry.ConfigFiles?.split(",")[0]?.trim();
|
|
64
|
+
if (entry.Name && file && /[\\/]\.harbour[\\/]local[\\/][^\\/]+\.yml$/.test(file))
|
|
65
|
+
roots.set(entry.Name, dirname(dirname(dirname(file))));
|
|
66
|
+
}
|
|
67
|
+
return roots;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Removes the containers and network of one Harbour project, keeping its
|
|
71
|
+
* named volumes (the app's local data) unless `volumes` is set. `compose down`
|
|
72
|
+
* needs no compose file for this — it works from the labels — and the network
|
|
73
|
+
* is removed by name afterwards in case compose found no container to anchor on.
|
|
74
|
+
*/
|
|
75
|
+
export async function downHarbourProject(run, project, volumes = false) {
|
|
76
|
+
const result = await run("docker", ["compose", "-p", project.project, "down", "--remove-orphans", ...(volumes ? ["-v"] : [])], { quiet: true });
|
|
77
|
+
const network = await run("docker", ["network", "rm", project.network], { quiet: true });
|
|
78
|
+
return result.code === 0 || network.code === 0;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Self-heal: brings down every Harbour project other than `keep` whose
|
|
82
|
+
* containers are all stopped (or gone), which frees their networks. A project
|
|
83
|
+
* with a running container is another app someone is using — it is left alone
|
|
84
|
+
* and named in the returned `running` list. Never touches non-Harbour networks.
|
|
85
|
+
*/
|
|
86
|
+
export async function reclaimStaleHarbourProjects(run, keep, output) {
|
|
87
|
+
const reclaimed = [];
|
|
88
|
+
const running = [];
|
|
89
|
+
for (const project of await listHarbourProjects(run)) {
|
|
90
|
+
if (project.project === keep)
|
|
91
|
+
continue;
|
|
92
|
+
if (project.running > 0) {
|
|
93
|
+
running.push(project);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (await downHarbourProject(run, project))
|
|
97
|
+
reclaimed.push(project);
|
|
98
|
+
}
|
|
99
|
+
if (reclaimed.length)
|
|
100
|
+
output?.(`Docker's network address pool was full; removed the stopped local services of ${reclaimed.length} other Harbour app${reclaimed.length === 1 ? "" : "s"} (their data volumes are kept) and retrying.`);
|
|
101
|
+
return { reclaimed, running };
|
|
102
|
+
}
|
|
103
|
+
/** The one sentence for a pool that is still full after the self-heal: the two remedies only the person can apply. */
|
|
104
|
+
export function poolExhaustedMessage(running) {
|
|
105
|
+
const stops = running.map(project => `\`harbour stop --app-root ${project.root ?? `<app of compose project ${project.project}>`}\``);
|
|
106
|
+
const others = stops.length ? `stop the ${stops.length === 1 ? "other Harbour app that is" : `${stops.length} other Harbour apps that are`} still running (${stops.join(", ")}) or ` : "";
|
|
107
|
+
return `Docker has no network address left for this app's local services (every subnet in its default address pool is taken, none by a stopped Harbour app): ${others}free the pool with \`docker network prune\` (removes networks no container uses) or a wider \`default-address-pools\` in Docker's daemon settings, then rerun.`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `harbour stop --all`: brings down every Harbour project on this machine whose
|
|
111
|
+
* containers are all stopped (data volumes kept); with `force`, the running
|
|
112
|
+
* ones too. The complete remedy for a full pool when no single app is at fault.
|
|
113
|
+
*/
|
|
114
|
+
export async function stopAllHarbourProjects(run, options = {}) {
|
|
115
|
+
const stopped = [];
|
|
116
|
+
const running = [];
|
|
117
|
+
for (const project of await listHarbourProjects(run)) {
|
|
118
|
+
if (project.running > 0 && !options.force) {
|
|
119
|
+
running.push(project);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (await downHarbourProject(run, project))
|
|
123
|
+
stopped.push(project);
|
|
124
|
+
}
|
|
125
|
+
return { stopped, running };
|
|
126
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "harbour.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.1.
|
|
3
|
+
"kitVersion": "0.1.29",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@harbour/app-sdk",
|
|
6
6
|
"version": "1.1.0",
|
|
@@ -8,11 +8,11 @@ export const PUBLISHED_KIT_BUNDLE = {
|
|
|
8
8
|
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:0fae5eda4278d1a13e3575efc24aa279e601f932bc8d3b3ac41e65fda147d4df",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:5d09b312cedea249e642d64931fa7cd41ad517b05dc0412a4845f7abe6da3a11"
|
|
13
13
|
},
|
|
14
14
|
"brief": {
|
|
15
|
-
"fingerprint": "
|
|
15
|
+
"fingerprint": "14e3cd99236da47263e430084e470d75f648223320f16a0d9917054d7dbc296d"
|
|
16
16
|
},
|
|
17
17
|
"declarationSchema": "harbour.app-integrations/2.0"
|
|
18
18
|
};
|
|
@@ -6,6 +6,7 @@ import { join, relative } from "node:path";
|
|
|
6
6
|
import { LOCAL_SERVICE_IMAGES } from "./kit-bundle.js";
|
|
7
7
|
import { kitPaths, projectName } from "./kit.js";
|
|
8
8
|
import { CliError } from "./output.js";
|
|
9
|
+
import { HARBOUR_NETWORK_LABEL, isAddressPoolExhausted, poolExhaustedMessage, reclaimStaleHarbourProjects } from "./docker-networks.js";
|
|
9
10
|
export const runCommand = (command, args, options = {}) => new Promise(resolve => {
|
|
10
11
|
const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
11
12
|
let stdout = "";
|
|
@@ -83,6 +84,7 @@ export function composeFile(project, bundle, ports, stateDir) {
|
|
|
83
84
|
"volumes:",
|
|
84
85
|
` postgres-data: { name: ${project}-postgres }`,
|
|
85
86
|
` minio-data: { name: ${project}-minio }`,
|
|
87
|
+
...networkLabelLines(),
|
|
86
88
|
`# Browser origin: ${originUrl}`,
|
|
87
89
|
""
|
|
88
90
|
].join("\n");
|
|
@@ -123,6 +125,8 @@ export function gatewayConfig(project, ports, stateDir) {
|
|
|
123
125
|
async function migrationNames(root) {
|
|
124
126
|
return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
|
|
125
127
|
}
|
|
128
|
+
/** Marks the project's default network as Harbour's, so a full address pool can be healed by removing only what Harbour left behind (docker-networks.ts). */
|
|
129
|
+
export function networkLabelLines() { return ["networks:", ` default: { labels: { ${HARBOUR_NETWORK_LABEL}: "1" } }`]; }
|
|
126
130
|
export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
|
|
127
131
|
function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
|
|
128
132
|
// ---- Ports and lock ------------------------------------------------------------------
|
|
@@ -209,16 +213,32 @@ export class LocalRuntime {
|
|
|
209
213
|
if (result.code !== 0)
|
|
210
214
|
throw new CliError("KIT_IMAGES_UNAVAILABLE", `Docker could not pull the kit images (${bundle.images.appGateway}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}. They are public and pinned by digest; \`docker compose\` (Compose v2) must be installed and Docker running with access to the registry.`);
|
|
211
215
|
}
|
|
212
|
-
|
|
213
|
-
|
|
216
|
+
/**
|
|
217
|
+
* `compose up`. When Docker's bridge address pool is full (each Harbour
|
|
218
|
+
* project holds one network), the stopped projects of other app roots are
|
|
219
|
+
* brought down — networks freed, data volumes kept — and `up` is retried
|
|
220
|
+
* once; a pool still full after that is reported with the remedies only the
|
|
221
|
+
* person can apply, never as a generic "could not start".
|
|
222
|
+
*/
|
|
223
|
+
async up(output) {
|
|
224
|
+
let result = await this.compose(["up", "-d", "--wait"]);
|
|
225
|
+
if (result.code !== 0 && isAddressPoolExhausted(result.stderr)) {
|
|
226
|
+
const { reclaimed, running } = await reclaimStaleHarbourProjects(this.run, this.project, output);
|
|
227
|
+
if (reclaimed.length)
|
|
228
|
+
result = await this.compose(["up", "-d", "--wait"]);
|
|
229
|
+
if (result.code !== 0 && isAddressPoolExhausted(result.stderr))
|
|
230
|
+
throw new CliError("DOCKER_NETWORK_POOL_EXHAUSTED", poolExhaustedMessage(running));
|
|
231
|
+
}
|
|
214
232
|
if (result.code !== 0)
|
|
215
233
|
throw new CliError("LOCAL_RUNTIME_FAILED", "Docker could not start the local Harbour services. Is Docker running and are the kit images available (see .harbour/kit.lock.json)?");
|
|
216
234
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
235
|
+
/**
|
|
236
|
+
* `harbour stop` and Ctrl-C: the project's containers and network go, its
|
|
237
|
+
* volumes (the app's local data) stay; safe to repeat. `compose stop` alone
|
|
238
|
+
* kept the network, and ~31 stopped apps then exhausted Docker's address
|
|
239
|
+
* pool for every later `harbour dev` and `harbour check`.
|
|
240
|
+
*/
|
|
241
|
+
async stop() { return { stopped: await this.down() }; }
|
|
222
242
|
/**
|
|
223
243
|
* Removes this project's containers and network, keeping its named volumes
|
|
224
244
|
* (the app's local data). What the kit gate uses for a session it started
|
|
@@ -226,7 +246,7 @@ export class LocalRuntime {
|
|
|
226
246
|
* and Docker's address pools run out after a dozen or so ("all predefined
|
|
227
247
|
* address pools have been fully subnetted").
|
|
228
248
|
*/
|
|
229
|
-
async down() { await this.compose(["down", "--remove-orphans"], { quiet: true }); }
|
|
249
|
+
async down() { return (await this.compose(["down", "--remove-orphans"], { quiet: true })).code === 0; }
|
|
230
250
|
/** Removes this project's containers and named volumes only. */
|
|
231
251
|
async reset(output) {
|
|
232
252
|
output(`Deleting local data for this app only: compose project ${this.project}, volumes ${this.project}-postgres and ${this.project}-minio.`);
|
|
@@ -95,6 +95,7 @@ export function managedBlock() {
|
|
|
95
95
|
"- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection with only the operations the app calls, then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
|
|
96
96
|
"- A Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect, a timer or a background queue, and never send during checks.",
|
|
97
97
|
"- Consent is a user action: call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. Missing consent never falls back to another account.",
|
|
98
|
+
"- A retained check that calls `harbour.integrations.execute` is answered, under `harbour check` and in the deployment pipeline alike, by the gate's fixture: the contract's result shape for a connection, operation and resource the app declared (one canned Slack message, one canned Gmail thread, a warehouse view with no rows), a refusal with the platform's own code for anything undeclared, and nothing is ever sent or read. The passed check says so in the report; real access is exercised only by `harbour check --integrations` (reads) and the preview's own smoke test.",
|
|
98
99
|
"- AI goes through `harbour.ai` only — `ai().chat({ messages, maxTokens })` from `src/harbour.client.ts` — behind an explicit control the person presses (never on load, in an effect or a timer). Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key and IT sees every call. The starter calls no AI; add the one call when the person asks for it (README.md has the \"Summarise my notes\" example) and `harbour check` writes `.harbour/checks/ai-journey.mjs` for it. A refusal with code `AI_NOT_ENABLED` means IT has not enabled an AI provider yet; the app must still work without AI.",
|
|
99
100
|
"- 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)`.",
|
|
100
101
|
"- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
|
|
@@ -117,7 +118,7 @@ Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
|
|
|
117
118
|
1. \`harbour dev --app-root .\` starts Postgres, storage and one Harbour gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
|
|
118
119
|
2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.harbour/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 5). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
|
|
119
120
|
3. \`.harbour/checks/\` is generated, not written by hand: \`harbour check\` reads \`migrations/\` and \`src/\` and writes one retained journey per capability the app's own code uses (plus a cross-user denial per owner-scoped table), deleting the ones the app no longer needs — so run it in the same edit that changes the app instead of adding or deleting these files yourself. It reports anything it cannot generate (\`actions\`, \`realtime\`, a table with no migration) for you to write. Edit a generated check and it becomes yours: Harbour keeps it, stops managing it and never removes it, so deleting it when the feature goes is then your job. \`harbour check\` and the deployment pipeline replay these checks against a real App Gateway and refuse the app (\`flow.check-failed\`) in both directions — a capability no check exercises, and a check that exercises something the code no longer does.
|
|
120
|
-
4. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only). A governed AI call (\`harbour.ai.chat\`) is exercised for real through the development route while \`harbour dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
|
|
121
|
+
4. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only); a retained check that calls \`harbour.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`harbour.ai.chat\`) is exercised for real through the development route while \`harbour dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
|
|
121
122
|
5. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
|
|
122
123
|
6. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
|
|
123
124
|
`;
|
|
@@ -245,7 +246,7 @@ const report = await integrations().execute<{ rows: Array<{ week: string; total:
|
|
|
245
246
|
});
|
|
246
247
|
\`\`\`
|
|
247
248
|
|
|
248
|
-
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is a user action: \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit.
|
|
249
|
+
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only when the person presses an explicit Send control, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is a user action: \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. A check that calls \`integrations().execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
|
|
249
250
|
`;
|
|
250
251
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
251
252
|
import react from "@vitejs/plugin-react";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.29";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"harbour": {
|
|
35
35
|
"kitBundle": {
|
|
36
36
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
37
|
-
"version": "0.1.
|
|
37
|
+
"version": "0.1.29"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|