@fourier-labs/harbour 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -10,7 +10,11 @@ You describe the app in plain English inside Claude Code or Codex; the agent ins
|
|
|
10
10
|
npx -y @fourier-labs/harbour@stable agent-setup
|
|
11
11
|
```
|
|
12
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.
|
|
13
|
+
Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit: it writes the `isomorph` skill for Claude Code (`~/.claude/skills/isomorph/SKILL.md`) and a fenced block in your global Codex instructions (`~/.codex/AGENTS.md`), and never touches your other skills or instructions.
|
|
14
|
+
|
|
15
|
+
That `npx` line runs the current release, but `harbour init`, `dev` and `check` afterwards run whatever `harbour` is installed on this machine. So `agent-setup` also compares the two and says, in its output and in `result.cli` of `--json` (`state`, `upgradeRequired`, `remediation`), when the installed CLI is missing or behind — a stale one carries an old kit bundle and builds an app the deployment pipeline refuses. The fix is always `npm i -g @fourier-labs/harbour@stable`.
|
|
16
|
+
|
|
17
|
+
Then, in an empty folder:
|
|
14
18
|
|
|
15
19
|
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
20
|
2. Say "run it" — the agent starts it and gives you a link to open.
|
|
@@ -23,7 +27,7 @@ The only step you do yourself is the company sign-in: when the agent runs `harbo
|
|
|
23
27
|
## Commands
|
|
24
28
|
|
|
25
29
|
```
|
|
26
|
-
harbour agent-setup install the agent guide (Claude Code skill + Codex AGENTS.md block); idempotent
|
|
30
|
+
harbour agent-setup install the agent guide (Claude Code skill + Codex AGENTS.md block); idempotent; reports a missing or stale installed CLI
|
|
27
31
|
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
32
|
harbour dev --app-root <path> [--reset] run the app locally on one loopback origin
|
|
29
33
|
harbour stop --app-root <path> stop local services, keep data
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { CLI_VERSION } from "./version.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
4
9
|
export const MANAGED_START = "<!-- harbour:kit:start -->";
|
|
5
10
|
export const MANAGED_END = "<!-- harbour:kit:end -->";
|
|
11
|
+
/** The one command that installs or upgrades the CLI; every instruction in the kit names this tag. */
|
|
12
|
+
export const CLI_INSTALL_COMMAND = "npm i -g @fourier-labs/harbour@stable";
|
|
6
13
|
/** Where the two agents read their user-level instructions from; both honour the tools' own override variables. */
|
|
7
14
|
export function agentPaths(env = process.env) {
|
|
8
15
|
const home = env.HARBOUR_AGENT_HOME?.trim() || homedir();
|
|
@@ -14,15 +21,92 @@ export function agentPaths(env = process.env) {
|
|
|
14
21
|
/**
|
|
15
22
|
* Installs the user-level Isomorph guide for Claude Code (a skill file, wholly owned
|
|
16
23
|
* 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.
|
|
24
|
+
* in that file is kept). Idempotent: unchanged files are reported as kept. Also
|
|
25
|
+
* reports which `harbour` the agent's next command will run (`result.cli`), because
|
|
26
|
+
* this command is routinely reached through `npx` while everything after it is not.
|
|
18
27
|
*/
|
|
19
|
-
export async function agentSetup(env = process.env) {
|
|
28
|
+
export async function agentSetup(env = process.env, lookups = {}) {
|
|
20
29
|
const paths = agentPaths(env);
|
|
21
|
-
const result = { created: [], updated: [], kept: [] };
|
|
30
|
+
const result = { created: [], updated: [], kept: [], cli: await checkCliVersion(lookups) };
|
|
22
31
|
result[await upsertManagedBlock(paths.claudeSkill, `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}`, {})].push(paths.claudeSkill);
|
|
23
32
|
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
33
|
return result;
|
|
25
34
|
}
|
|
35
|
+
/** Compares the globally resolvable `harbour` with the package this process runs from. */
|
|
36
|
+
export async function checkCliVersion(lookups = {}) {
|
|
37
|
+
const running = await (lookups.runningCliVersion ?? runningPackageVersion)();
|
|
38
|
+
// No `harbour` on PATH, output with no version in it, a non-zero exit or a hang all
|
|
39
|
+
// mean the same thing: there is no installed CLI whose version can be trusted.
|
|
40
|
+
const installed = parseVersion(await (lookups.installedCliVersion ?? installedCliVersion)() ?? "");
|
|
41
|
+
const consequence = "carries an old kit bundle, so the app it creates passes every local check and is then refused by the deployment pipeline (kit_bundle_incompatible)";
|
|
42
|
+
if (!installed)
|
|
43
|
+
return { running, state: "missing", upgradeRequired: true, remediation: CLI_INSTALL_COMMAND, message: `No \`harbour\` command is installed on this machine; this package is ${running}. \`harbour init\`, \`dev\` and \`check\` run the installed CLI, not this one, and a missing or stale CLI ${consequence}.` };
|
|
44
|
+
const order = compareCliVersions(installed, running);
|
|
45
|
+
if (order < 0)
|
|
46
|
+
return { running, installed, state: "stale", upgradeRequired: true, remediation: CLI_INSTALL_COMMAND, message: `The installed \`harbour\` command is ${installed}, older than this package (${running}). \`harbour init\`, \`dev\` and \`check\` run the installed CLI, not this one, and a stale CLI ${consequence}.` };
|
|
47
|
+
if (order > 0)
|
|
48
|
+
return { running, installed, state: "ahead", upgradeRequired: false, message: `The installed \`harbour\` command is ${installed}, newer than this package (${running}); the installed one is what runs.` };
|
|
49
|
+
return { running, installed, state: "current", upgradeRequired: false, message: `The installed \`harbour\` command is ${installed}, the same version as this package.` };
|
|
50
|
+
}
|
|
51
|
+
/** What `agent-setup` and `init` print about the installed CLI; an upgrade is impossible to miss in a scrolling log. */
|
|
52
|
+
export function cliVersionLines(check) {
|
|
53
|
+
if (!check.upgradeRequired)
|
|
54
|
+
return [check.message];
|
|
55
|
+
return ["", "!!! UPGRADE THE HARBOUR CLI BEFORE `harbour init` OR ANY OTHER HARBOUR COMMAND !!!", check.message, `Run: ${check.remediation}`, ""];
|
|
56
|
+
}
|
|
57
|
+
/** Numeric-core semver order; a prerelease sorts before its release. An unparseable version compares equal, so nothing is called stale on a guess. */
|
|
58
|
+
export function compareCliVersions(left, right) {
|
|
59
|
+
const parse = (value) => {
|
|
60
|
+
const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
|
|
61
|
+
return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] } : undefined;
|
|
62
|
+
};
|
|
63
|
+
const a = parse(left);
|
|
64
|
+
const b = parse(right);
|
|
65
|
+
if (!a || !b)
|
|
66
|
+
return 0;
|
|
67
|
+
for (let index = 0; index < 3; index += 1)
|
|
68
|
+
if (a.core[index] !== b.core[index])
|
|
69
|
+
return a.core[index] < b.core[index] ? -1 : 1;
|
|
70
|
+
if (a.pre === b.pre)
|
|
71
|
+
return 0;
|
|
72
|
+
if (a.pre === undefined)
|
|
73
|
+
return 1;
|
|
74
|
+
if (b.pre === undefined)
|
|
75
|
+
return -1;
|
|
76
|
+
return a.pre < b.pre ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
/** Whatever the globally resolvable `harbour` prints for `--version`; undefined when running it is not possible at all. */
|
|
79
|
+
async function installedCliVersion() {
|
|
80
|
+
try {
|
|
81
|
+
const { stdout } = await execFileAsync("harbour", ["--version"], { timeout: 20_000, windowsHide: true });
|
|
82
|
+
return stdout;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return error.stdout;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function parseVersion(text) {
|
|
89
|
+
return /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(text.trim())?.[1];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The version of the package this process runs from: the nearest package.json named
|
|
93
|
+
* `@fourier-labs/harbour`, walking up from this file. That is `packages/harbour-cli/`
|
|
94
|
+
* in the repository and the package root once published (the entry point is
|
|
95
|
+
* `dist/packages/harbour-cli/src/cli.js`), so no environment variable is involved.
|
|
96
|
+
*/
|
|
97
|
+
async function runningPackageVersion() {
|
|
98
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
99
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
100
|
+
const manifest = await readFile(join(directory, "package.json"), "utf8").then(text => JSON.parse(text), () => undefined);
|
|
101
|
+
if (manifest?.name === "@fourier-labs/harbour" && typeof manifest.version === "string")
|
|
102
|
+
return manifest.version;
|
|
103
|
+
const parent = dirname(directory);
|
|
104
|
+
if (parent === directory)
|
|
105
|
+
break;
|
|
106
|
+
directory = parent;
|
|
107
|
+
}
|
|
108
|
+
return CLI_VERSION;
|
|
109
|
+
}
|
|
26
110
|
/**
|
|
27
111
|
* Writes `block` to `file`: creates the file, replaces the text between the markers
|
|
28
112
|
* when both are present, or appends the block after the existing content. Without
|
|
@@ -60,7 +144,7 @@ The person you are working with may not be a developer. They say what they want
|
|
|
60
144
|
|
|
61
145
|
## Getting ready (do this yourself, once per machine and folder)
|
|
62
146
|
|
|
63
|
-
1. CLI:
|
|
147
|
+
1. CLI, before \`harbour init\` or anything else: compare the installed CLI with the current release and upgrade it whenever it is behind. A CLI that merely runs is not good enough — only a current one is. \`harbour agent-setup\` prints both versions and says plainly when the installed one is missing or older (its \`--json\` result carries \`cli.upgradeRequired\` and \`cli.remediation\`); without that output, compare \`harbour --version\` with \`npx -y @fourier-labs/harbour@stable --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @fourier-labs/harbour@stable\` and confirm \`harbour --version\` now matches, then continue. Every later command runs the *installed* CLI, so a stale one builds an app that passes every local check and is then refused by the deployment pipeline (\`kit_bundle_incompatible\`) minutes later, with nothing in the app to fix. The kit needs Node 22+ and Docker Desktop running; if Docker is missing, that is the one thing to tell them to install, in one line.
|
|
64
148
|
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
149
|
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
150
|
|
|
@@ -68,7 +152,7 @@ The person you are working with may not be a developer. They say what they want
|
|
|
68
152
|
|
|
69
153
|
- "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
154
|
- "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" →
|
|
155
|
+
- "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. 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
156
|
- "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
157
|
- "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
158
|
- "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.
|
|
@@ -9,7 +9,7 @@ import { connect, loadConfig, resolveConfig } from "./config.js";
|
|
|
9
9
|
import { EMBEDDED_KIT_BUNDLE } 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
|
+
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
15
|
import { runChecks } from "./check.js";
|
|
@@ -91,10 +91,15 @@ else if (!command || command === "help" || command === "--help" || args.includes
|
|
|
91
91
|
}
|
|
92
92
|
else if (command === "agent-setup") {
|
|
93
93
|
const result = await agentSetup(process.env);
|
|
94
|
-
for (const [
|
|
95
|
-
for (const path of
|
|
94
|
+
for (const state of ["created", "updated", "kept"])
|
|
95
|
+
for (const path of result[state])
|
|
96
96
|
progress(`${state.padEnd(7)} ${path}`);
|
|
97
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
|
+
// Which `harbour` the next command will run. This command is usually reached through
|
|
99
|
+
// `npx …@stable`, which tells you nothing about the CLI that does the actual work.
|
|
100
|
+
if (result.cli)
|
|
101
|
+
for (const line of cliVersionLines(result.cli))
|
|
102
|
+
progress(line);
|
|
98
103
|
emit(summaryEnvelope({ ...result, paths: agentPaths(process.env) }));
|
|
99
104
|
}
|
|
100
105
|
else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
@@ -126,6 +131,11 @@ else {
|
|
|
126
131
|
const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId, env: process.env });
|
|
127
132
|
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)`))])
|
|
128
133
|
progress(line);
|
|
134
|
+
// Silent while the installed CLI is current; loud when `init` came from a
|
|
135
|
+
// newer package than the CLI that will run `dev`, `check` and `productionise`.
|
|
136
|
+
if (result.agents.cli?.upgradeRequired)
|
|
137
|
+
for (const line of cliVersionLines(result.agents.cli))
|
|
138
|
+
progress(line);
|
|
129
139
|
// The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
|
|
130
140
|
const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
|
|
131
141
|
if (sdk === "missing")
|
|
@@ -90,6 +90,7 @@ export function managedBlock() {
|
|
|
90
90
|
"",
|
|
91
91
|
"- 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.",
|
|
92
92
|
"- 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` (app identity), `gmail.thread.list` and `gmail.message.read` (user identity, read-only, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
|
|
93
|
+
"- `.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`.",
|
|
93
94
|
"- 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.",
|
|
94
95
|
"- 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.",
|
|
95
96
|
"- 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)`.",
|
|
@@ -109,21 +110,22 @@ description: Build, run and check this Harbour app with the Harbour CLI (dev, ch
|
|
|
109
110
|
Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
|
|
110
111
|
|
|
111
112
|
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.
|
|
112
|
-
2. Edit \`src
|
|
113
|
+
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 4). 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.
|
|
113
114
|
3. \`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).
|
|
114
115
|
4. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
|
|
115
116
|
5. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
|
|
116
117
|
`;
|
|
117
118
|
function kitFiles() {
|
|
118
119
|
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
// No connection. The starter calls no company system, and a connection the
|
|
121
|
+
// app does not call is a deploy that never happens: `harbour productionise`
|
|
122
|
+
// refuses to start until every declared connection holds a preview grant
|
|
123
|
+
// from IT. One is added when the app really calls it — the two steps and the
|
|
124
|
+
// worked Slack and warehouse examples are in README.md, in the "Harbour
|
|
125
|
+
// development kit" block of AGENTS.md / CLAUDE.md and in src/App.tsx. They
|
|
126
|
+
// are not in this file: it is read with JSON.parse here and again by the
|
|
127
|
+
// pipeline's source intake, so it cannot carry comments.
|
|
128
|
+
".harbour/integrations.json": `${JSON.stringify({ schema: "harbour.app-integrations/2.0", connections: {} }, null, 2)}\n`,
|
|
127
129
|
"migrations/0001_notes.sql": MIGRATION,
|
|
128
130
|
// One retained check per capability the starter's UI uses: the pipeline's
|
|
129
131
|
// flow gate refuses an app whose checks never exercise a declared capability.
|
|
@@ -152,10 +154,51 @@ function starterFiles(bundle) {
|
|
|
152
154
|
"src/main.tsx": `import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);\n`,
|
|
153
155
|
"src/harbour.client.ts": HARBOUR_CLIENT,
|
|
154
156
|
"src/App.tsx": APP,
|
|
155
|
-
"
|
|
156
|
-
"README.md": "# Harbour app\n\nCreated by `harbour init`. Run `harbour dev --app-root .` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.\n\n`.harbour/checks/` holds one journey per capability the app uses (`notes-journey.mjs` for data, `files-journey.mjs` for files) and `notes-cross-user.mjs`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts; `harbour check` and the deployment pipeline refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.\n\n`.harbour/integrations.json` declares `company-slack` only. Declare a connection only when the app calls it: `harbour productionise` refuses to deploy until every declared connection has a preview grant (`harbour integrations request <connection> --environment preview --reason \"<why>\" --app-root .`). The warehouse example (`sales-warehouse` / `warehouse.view.read`) is in a comment in `src/App.tsx`.\n"
|
|
157
|
+
"README.md": STARTER_README
|
|
157
158
|
};
|
|
158
159
|
}
|
|
160
|
+
const STARTER_README = `# Harbour app
|
|
161
|
+
|
|
162
|
+
Created by \`harbour init\`. Run \`harbour dev --app-root .\` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.
|
|
163
|
+
|
|
164
|
+
\`.harbour/checks/\` holds one journey per capability the app uses (\`notes-journey.mjs\` for data, \`files-journey.mjs\` for files) and \`notes-cross-user.mjs\`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts; \`harbour check\` and the deployment pipeline refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.
|
|
165
|
+
|
|
166
|
+
## Adding a company system (Slack, Gmail, a warehouse view)
|
|
167
|
+
|
|
168
|
+
\`.harbour/integrations.json\` ships with no connections:
|
|
169
|
+
|
|
170
|
+
\`\`\`json
|
|
171
|
+
{ "schema": "harbour.app-integrations/2.0", "connections": {} }
|
|
172
|
+
\`\`\`
|
|
173
|
+
|
|
174
|
+
That is deliberate. Every declared connection blocks the deploy until IT grants it — \`harbour productionise\` refuses to start while one is missing — so a connection the app does not call is a deploy that never happens. The file is strict JSON and cannot hold comments, which is why this note lives here.
|
|
175
|
+
|
|
176
|
+
Add one in two steps, when the app really calls it:
|
|
177
|
+
|
|
178
|
+
1. Declare the connection and only the operations the app calls, under \`connections\`. Slack:
|
|
179
|
+
|
|
180
|
+
\`\`\`json
|
|
181
|
+
"company-slack": { "kind": "saas", "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
182
|
+
\`\`\`
|
|
183
|
+
|
|
184
|
+
A warehouse view:
|
|
185
|
+
|
|
186
|
+
\`\`\`json
|
|
187
|
+
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
188
|
+
\`\`\`
|
|
189
|
+
|
|
190
|
+
2. Ask IT for access: \`harbour integrations request <connection> --reason "<why>" --app-root .\` for local development, and the same command with \`--environment preview\` for the preview grant every declared connection needs before \`harbour productionise\` will deploy. \`harbour integrations status --app-root .\` says where each request stands; PENDING is not ready.
|
|
191
|
+
|
|
192
|
+
Then call it from the app through \`integrations()\` in \`src/harbour.client.ts\`:
|
|
193
|
+
|
|
194
|
+
\`\`\`ts
|
|
195
|
+
const report = await integrations().execute<{ rows: Array<{ week: string; total: number }> }>("sales-warehouse", {
|
|
196
|
+
operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
197
|
+
});
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
A send (\`slack.message.post\`) 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/\`.
|
|
201
|
+
`;
|
|
159
202
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
160
203
|
import react from "@vitejs/plugin-react";
|
|
161
204
|
|
|
@@ -187,7 +230,12 @@ export type Integrations = {
|
|
|
187
230
|
execute<T>(connection: string, call: IntegrationCall): Promise<T>;
|
|
188
231
|
};
|
|
189
232
|
|
|
190
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* Typed access to the SDK's integrations surface (kit bundle SDK); throws a clear
|
|
235
|
+
* error on an older SDK. The starter calls no company system, so nothing uses this
|
|
236
|
+
* yet: it is the entry point for the first one you add (declare the connection in
|
|
237
|
+
* .harbour/integrations.json, then \`harbour integrations request\` — see README.md).
|
|
238
|
+
*/
|
|
191
239
|
export function integrations(): Integrations {
|
|
192
240
|
const surface = (harbour as { integrations?: Integrations }).integrations;
|
|
193
241
|
if (!surface) throw new Error("This @harbour/app-sdk build has no integrations surface; run harbour init --upgrade.");
|
|
@@ -202,7 +250,6 @@ export function errorCode(error: unknown): string {
|
|
|
202
250
|
const APP = `import { useCallback, useEffect, useState } from "react";
|
|
203
251
|
import type { HarbourUser } from "@harbour/app-sdk";
|
|
204
252
|
import { harbour } from "./harbour.client";
|
|
205
|
-
import { SlackPanel } from "./SlackPanel";
|
|
206
253
|
|
|
207
254
|
type Note = { id: number; title: string; done: boolean; created_at: string };
|
|
208
255
|
type StoredFile = { path?: string; name?: string; size?: number };
|
|
@@ -237,13 +284,22 @@ export function App() {
|
|
|
237
284
|
const remove = async (note: Note) => { await harbour.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
|
|
238
285
|
const upload = async (file: File) => { await harbour.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
|
|
239
286
|
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
287
|
+
// Company systems are not part of the starter: .harbour/integrations.json declares
|
|
288
|
+
// nothing, so nothing about this app waits on IT. Add one only when the app really
|
|
289
|
+
// calls it, in two steps (README.md has the same two steps in full).
|
|
290
|
+
// 1. Declare the connection and only the operations this app calls, under
|
|
291
|
+
// "connections" in .harbour/integrations.json — every declared connection blocks
|
|
292
|
+
// the deploy until IT grants it:
|
|
293
|
+
// "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
294
|
+
// "company-slack": { "kind": "saas", "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
295
|
+
// 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
|
|
296
|
+
// and again with --environment preview before harbour productionise), then import
|
|
297
|
+
// { integrations } from "./harbour.client" and uncomment:
|
|
244
298
|
// const report = await integrations().execute<{ rows: Array<{ week: string; total: number }> }>("sales-warehouse", {
|
|
245
299
|
// operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
246
300
|
// });
|
|
301
|
+
// A Slack send runs only when the person presses an explicit Send control, with a
|
|
302
|
+
// fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
|
|
247
303
|
|
|
248
304
|
return (
|
|
249
305
|
<main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
|
|
@@ -272,83 +328,10 @@ export function App() {
|
|
|
272
328
|
<input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
|
|
273
329
|
<ul>{files.map((file, index) => <li key={index}>{file.path ?? file.name}{file.size !== undefined ? \` (\${file.size} bytes)\` : ""}</li>)}</ul>
|
|
274
330
|
</section>
|
|
275
|
-
|
|
276
|
-
<SlackPanel connection="company-slack" channel="team-updates" />
|
|
277
331
|
</main>
|
|
278
332
|
);
|
|
279
333
|
}
|
|
280
334
|
`;
|
|
281
|
-
const SLACK_PANEL = `import { useState } from "react";
|
|
282
|
-
import { errorCode, integrations, type ConnectResult } from "./harbour.client";
|
|
283
|
-
|
|
284
|
-
type Message = { id: string; text: string; authorId: string; createdAt: string };
|
|
285
|
-
type SendState = { kind: "idle" } | { kind: "sending" } | { kind: "sent"; messageId: string } | { kind: "unknown" } | { kind: "rate_limited"; retryAfterSeconds?: number } | { kind: "failed"; code: string };
|
|
286
|
-
const SEND_KEY = "harbour.slack.sendActionId";
|
|
287
|
-
|
|
288
|
-
/** One channel: connect (consent), load history, draft, explicit Send with a stable idempotency key. */
|
|
289
|
-
export function SlackPanel({ connection, channel }: { connection: string; channel: string }) {
|
|
290
|
-
const [link, setLink] = useState<ConnectResult | { status: "reconnect_required" } | { status: "unknown" }>({ status: "unknown" });
|
|
291
|
-
const [history, setHistory] = useState<Message[]>([]);
|
|
292
|
-
const [draft, setDraft] = useState("");
|
|
293
|
-
const [send, setSend] = useState<SendState>({ kind: "idle" });
|
|
294
|
-
const [note, setNote] = useState<string | null>(null);
|
|
295
|
-
|
|
296
|
-
const connect = async () => {
|
|
297
|
-
try { setLink(await integrations().connect(connection)); setNote(null); }
|
|
298
|
-
catch (error) { setNote(\`Connect failed: \${errorCode(error)}\`); }
|
|
299
|
-
};
|
|
300
|
-
const loadHistory = async () => {
|
|
301
|
-
try {
|
|
302
|
-
const result = await integrations().execute<{ messages: Message[]; hasMore: boolean }>(connection, { operation: "slack.channel.history", resource: channel, input: { limit: 15 } });
|
|
303
|
-
setHistory(result.messages); setNote(null);
|
|
304
|
-
} catch (error) {
|
|
305
|
-
const code = errorCode(error);
|
|
306
|
-
if (code === "USER_CONNECTION_REQUIRED") setLink({ status: "unknown" });
|
|
307
|
-
else if (code === "USER_RECONNECT_REQUIRED") setLink({ status: "reconnect_required" });
|
|
308
|
-
setNote(\`History unavailable: \${code}\`);
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
// The key is created when the person presses Send and survives retries of that
|
|
313
|
-
// same action (component state + sessionStorage); a new draft gets a new key.
|
|
314
|
-
const sendDraft = async () => {
|
|
315
|
-
if (!draft.trim() || send.kind === "sending") return;
|
|
316
|
-
let key = sessionStorage.getItem(SEND_KEY);
|
|
317
|
-
if (!key) { key = crypto.randomUUID(); sessionStorage.setItem(SEND_KEY, key); }
|
|
318
|
-
setSend({ kind: "sending" });
|
|
319
|
-
try {
|
|
320
|
-
const result = await integrations().execute<{ messageId: string }>(connection, { operation: "slack.message.post", resource: channel, input: { text: draft }, idempotencyKey: key });
|
|
321
|
-
sessionStorage.removeItem(SEND_KEY);
|
|
322
|
-
setSend({ kind: "sent", messageId: result.messageId }); setDraft("");
|
|
323
|
-
} catch (error) {
|
|
324
|
-
const code = errorCode(error);
|
|
325
|
-
const retryAfterSeconds = (error as { details?: { retryAfterSeconds?: number } })?.details?.retryAfterSeconds;
|
|
326
|
-
if (code === "SEND_OUTCOME_UNKNOWN") setSend({ kind: "unknown" });
|
|
327
|
-
else if (code === "PROVIDER_RATE_LIMITED" || code === "RATE_LIMITED") setSend({ kind: "rate_limited", retryAfterSeconds });
|
|
328
|
-
else setSend({ kind: "failed", code });
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
return (
|
|
333
|
-
<section>
|
|
334
|
-
<h2>Slack: #{channel}</h2>
|
|
335
|
-
{link.status === "connected" && <p>Connected as {link.accountLabel}.</p>}
|
|
336
|
-
{link.status === "consent_required" && <p>Slack needs your consent: <a href={link.authorizationUrl}>connect your Slack account</a>.</p>}
|
|
337
|
-
{link.status === "reconnect_required" && <p>Your Slack connection expired. <button type="button" onClick={() => void connect()}>Reconnect</button></p>}
|
|
338
|
-
{link.status === "unknown" && <button type="button" onClick={() => void connect()}>Connect Slack</button>}
|
|
339
|
-
<p><button type="button" onClick={() => void loadHistory()}>Load history</button></p>
|
|
340
|
-
<ul>{history.map(message => <li key={message.id}><code>{message.authorId}</code> {message.text}</li>)}</ul>
|
|
341
|
-
<textarea value={draft} onChange={event => { setDraft(event.target.value); if (send.kind !== "sending") { sessionStorage.removeItem(SEND_KEY); setSend({ kind: "idle" }); } }} placeholder="Draft a message (nothing is sent until you press Send)" rows={3} style={{ width: "100%" }} />
|
|
342
|
-
<p><button type="button" onClick={() => void sendDraft()} disabled={send.kind === "sending" || !draft.trim()}>Send</button></p>
|
|
343
|
-
{send.kind === "sent" && <p>Sent (message {send.messageId}).</p>}
|
|
344
|
-
{send.kind === "unknown" && <p>Harbour could not confirm whether the message was sent. Retry sends the same action, not a duplicate. <button type="button" onClick={() => void sendDraft()}>Retry</button></p>}
|
|
345
|
-
{send.kind === "rate_limited" && <p>Slack is rate limiting; try again{send.retryAfterSeconds ? \` in \${send.retryAfterSeconds}s\` : " shortly"}.</p>}
|
|
346
|
-
{send.kind === "failed" && <p>Send failed: {send.code}.</p>}
|
|
347
|
-
{note && <p>{note}</p>}
|
|
348
|
-
</section>
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
`;
|
|
352
335
|
const MIGRATION = `-- Notes table for the starter. Ownership comes from the gateway's transaction-local
|
|
353
336
|
-- settings (harbour.user_id / harbour.user_email); the browser never supplies it.
|
|
354
337
|
CREATE TABLE IF NOT EXISTS notes (
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.22";
|