@fourier-labs/harbour 0.1.33 → 0.1.34

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.
@@ -17,7 +17,7 @@ export async function initKit(root, bundle, options = {}) {
17
17
  const emptyDir = !existingPackage && !(await exists(join(root, "src")));
18
18
  if (existingPackage && !isSupportedApp(existingPackage))
19
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).");
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: [] } };
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: [], removed: [] } };
21
21
  const write = async (path, content) => {
22
22
  const absolute = join(root, path);
23
23
  if (await exists(absolute)) {
@@ -92,7 +92,7 @@ export function managedBlock() {
92
92
  "",
93
93
  "- Identity, data and files go through `@harbour/app-sdk` only: `harbour.identity.current()`, `harbour.data.from(table)`, `harbour.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
94
94
  "- Company systems (Slack, Gmail, warehouse views) are reached only through `harbour.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.harbour/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (declared per app: `app` posts as the company's Slack bot, Isomorph AI, under the name IT approved; `user` posts as the signed-in person after their consent), `gmail.thread.list`, `gmail.message.read` and `gmail.message.send` (user identity — reads and one plain-text send as the signed-in person, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
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 — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — 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`.",
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 — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — 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. Before a real integration test, read `harbour integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. 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
  "- A Slack message is posted either as the app (the company's Slack bot, Isomorph AI, under the IT-approved name: declare `\"presentation\": { \"displayName\": \"<app name>\" }` on the Slack connection, optional `iconEmoji`; every app-mode post ends with \"Posted by <app> on Isomorph\") or as the person (`\"identity\": \"user\"` on `slack.message.post`, their own consent; an older consent answers `USER_RECONNECT_REQUIRED` — offer Connect again) — never pretend one is the other. The declaration is the mode you request access for; when the app is approved for both, every post says which with `mode: \"app\"` or `mode: \"user\"` on the call (`MODE_REQUIRED` otherwise), and a mode IT has not approved is refused with `MODE_NOT_GRANTED`, never swapped. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
98
98
  "- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. App-identity operations (`slack.message.post` declared `\"identity\": \"app\"`, `warehouse.view.read`) never call connect — it is refused as unapproved user access. Missing consent never falls back to another account.",
@@ -105,7 +105,7 @@ export function managedBlock() {
105
105
  "- `.harbour/checks/` holds the app's retained journeys: one per capability the app's own code uses (`harbour.data.*`, `harbour.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `harbour check` generates them from `migrations/` and the app's own source and deletes the ones the app no longer needs, so the way to keep them right is to run it in the same edit that changes the app — not to write or remove these files by hand. The pairing is two-way and the `flow` gate refuses the deploy in both directions. Start using a capability and it needs its own retained check: `harbour check` writes it, except for the ones it reports it cannot generate (`actions`, `realtime`), which you write yourself. Stop using one — a deleted section, a dropped table, a feature the app no longer has — and its retained check must be deleted in that same edit: `harbour check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `harbour check` and the deployment pipeline replay `.harbour/checks/` against a real App Gateway and refuse the app (`flow.check-failed: the candidate's own retained checks no longer pass`) when a check exercises something the code no longer does.",
106
106
  "- A generated check starts with a `// harbour:generated` line carrying a digest of its own body; that is how `harbour check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Harbour keeps your version, stops updating it and never deletes it, and keeping it honest is then your job. The starter's pairing is: `notes-journey.mjs` + `notes-cross-user.mjs` with the `notes` table and the Notes section of `src/App.tsx`; `files-journey.mjs` with the \"Private files\" section, the only code that calls `harbour.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `harbour check` rewrites and deletes to match — `.harbour/checks/files-journey.mjs` goes with that section, and you delete it by hand in that same edit only if you have edited it. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
107
107
  "- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `harbour integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `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.",
108
- "- 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`.",
108
+ "- 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 the user-level `isomorph` skill installed by `harbour agent-setup` (Claude Code: `~/.claude/skills/isomorph`, Codex: `~/.codex/skills/isomorph`); use it for every request about this app.",
109
109
  MANAGED_END
110
110
  ].join("\n");
111
111
  }
@@ -244,12 +244,12 @@ Add one in two steps, when the app really calls it:
244
244
  Then call it from the app through \`integrations()\` in \`src/harbour.client.ts\`:
245
245
 
246
246
  \`\`\`ts
247
- const report = await integrations().execute<{ rows: Array<{ week: string; total: number }> }>("sales-warehouse", {
247
+ const report = await integrations().execute("sales-warehouse", {
248
248
  operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
249
249
  });
250
250
  \`\`\`
251
251
 
252
- 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 only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. 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.
252
+ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. Keep request construction in an app function that both the button and its retained check call, so the check exercises the actual payload. Do not bypass the SDK types with a generic string/unknown wrapper. 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.
253
253
  `;
254
254
  const VITE_CONFIG = `import { defineConfig } from "vite";
255
255
  import react from "@vitejs/plugin-react";
@@ -268,33 +268,23 @@ export default defineConfig({
268
268
  }
269
269
  });
270
270
  `;
271
- const HARBOUR_CLIENT = `import { createClient, type IntegrationRequest } from "@harbour/app-sdk";
271
+ const HARBOUR_CLIENT = `import { createClient } from "@harbour/app-sdk";
272
272
 
273
273
  // Single client for the whole app. Identity, data and files come from Harbour;
274
274
  // the same calls work locally (harbour dev) and in preview/production.
275
275
  export const harbour = createClient();
276
276
 
277
- export type ConnectResult = { status: "connected"; accountLabel: string } | { status: "consent_required"; authorizationUrl: string };
278
- // The SDK's per-operation request types carry each input's bound (e.g. slack.channel.history limit: 1 to 15);
279
- // the second member keeps an operation the SDK has not typed yet (gmail.*) callable until it is.
280
- export type IntegrationCall = IntegrationRequest | { operation: string; resource: string; input: unknown; idempotencyKey?: string };
281
- export type Integrations = {
282
- connect(connection: string): Promise<ConnectResult>;
283
- disconnect(connection: string): Promise<{ status: "disconnected" }>;
284
- execute<T>(connection: string, call: IntegrationCall): Promise<T>;
285
- };
277
+ // Keep the SDK's operation-specific inputs and inferred results intact.
278
+ export type Integrations = typeof harbour.integrations;
279
+ export function integrations(): Integrations { return harbour.integrations; }
286
280
 
287
- /**
288
- * Typed access to the SDK's integrations surface (kit bundle SDK); throws a clear
289
- * error on an older SDK. The starter calls no company system, so nothing uses this
290
- * yet: it is the entry point for the first one you add (declare the connection in
291
- * .harbour/integrations.json, then \`harbour integrations request\` — see README.md).
292
- */
293
- export function integrations(): Integrations {
294
- const surface = (harbour as { integrations?: Integrations }).integrations;
295
- if (!surface) throw new Error("This @harbour/app-sdk build has no integrations surface; run harbour init --upgrade.");
296
- return surface;
297
- }
281
+ // Gmail sends plain text as the signed-in person, after IT approval and personal consent.
282
+ // Inside an authorized Send handler (use the catalog's actual connection and mailbox):
283
+ // await integrations().execute("company-gmail", {
284
+ // operation: "gmail.message.send", resource: "inbox",
285
+ // input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
286
+ // idempotencyKey: crypto.randomUUID()
287
+ // });
298
288
 
299
289
  export type AiMessage = { role: "system" | "user" | "assistant"; content: string };
300
290
  export type AiChatResult = { content: string; model: string; finishReason: string; usage: { inputTokens: number; outputTokens: number }; traceId?: string };
@@ -373,10 +363,10 @@ export function App() {
373
363
  // 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
374
364
  // and again with --environment preview before harbour productionise), then import
375
365
  // { integrations } from "./harbour.client" and uncomment:
376
- // const report = await integrations().execute<{ rows: Array<{ week: string; total: number }> }>("sales-warehouse", {
366
+ // const report = await integrations().execute("sales-warehouse", {
377
367
  // operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
378
368
  // });
379
- // A Slack send runs only when the person presses an explicit Send control, with a
369
+ // A Slack send runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a
380
370
  // fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
381
371
  //
382
372
  // Governed AI is not part of the starter either. When the person asks for it, add ONE
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.33";
1
+ export const CLI_VERSION = "0.1.34";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,9 @@
25
25
  "engines": {
26
26
  "node": ">=22.13.0"
27
27
  },
28
+ "dependencies": {
29
+ "embedded-postgres": "18.4.0-beta.17"
30
+ },
28
31
  "scripts": {
29
32
  "prebuild": "node scripts/kit-bundle.mjs verify",
30
33
  "build": "tsc -p tsconfig.json",
@@ -34,7 +37,7 @@
34
37
  "harbour": {
35
38
  "kitBundle": {
36
39
  "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
37
- "version": "0.1.33"
40
+ "version": "0.1.34"
38
41
  }
39
42
  }
40
43
  }
@@ -1,126 +0,0 @@
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
- }