@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.
- package/README.md +2 -2
- package/dist/packages/harbour-cli/src/agent-setup.js +55 -18
- package/dist/packages/harbour-cli/src/check.js +9 -10
- package/dist/packages/harbour-cli/src/cli.js +19 -21
- package/dist/packages/harbour-cli/src/dev.js +2 -3
- package/dist/packages/harbour-cli/src/jobs.js +49 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +7 -7
- package/dist/packages/harbour-cli/src/kit.js +2 -2
- package/dist/packages/harbour-cli/src/local-runtime.js +121 -102
- package/dist/packages/harbour-cli/src/productionise.js +160 -65
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +1 -0
- package/dist/packages/harbour-cli/src/starter.js +18 -28
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +5 -2
- package/dist/packages/harbour-cli/src/docker-networks.js +0 -126
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ You describe the app in plain English inside Claude Code or Codex; the agent ins
|
|
|
10
10
|
npx -y @fourier-labs/harbour agent-setup
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit
|
|
13
|
+
Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit by installing the `isomorph` skill for each (`~/.claude/skills/isomorph/SKILL.md`, `~/.codex/skills/isomorph/SKILL.md`) and never touches your other skills or instructions. A skill is read only when the task matches it — a folder with `.harbour/`, an app you ask for on Isomorph — so the agent works exactly as before on everything else. (Releases up to 0.1.33 put the guide in your global Codex instructions, `~/.codex/AGENTS.md`, where every Codex session read it; running the line again takes that block out and leaves the rest of the file as it was.)
|
|
14
14
|
|
|
15
15
|
That `npx` line runs the current release, but `harbour init`, `dev` and `check` afterwards run whatever `harbour` is installed on this machine. So `agent-setup` also compares the two and says, in its output and in `result.cli` of `--json` (`state`, `upgradeRequired`, `remediation`), when the installed CLI is missing or behind — a stale one carries an old kit bundle and builds an app the deployment pipeline refuses. The fix is always `npm i -g @fourier-labs/harbour`.
|
|
16
16
|
|
|
@@ -27,7 +27,7 @@ The only step you do yourself is the company sign-in: when the agent runs `harbo
|
|
|
27
27
|
## Commands
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
harbour agent-setup install the
|
|
30
|
+
harbour agent-setup install the `isomorph` skill for Claude Code and Codex; idempotent; reports a missing or stale installed CLI
|
|
31
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)
|
|
32
32
|
harbour dev --app-root <path> [--reset] run the app locally on one loopback origin
|
|
33
33
|
harbour stop --app-root <path> stop local services, keep data
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -15,26 +15,39 @@ export const MANAGED_END = "<!-- harbour:kit:end -->";
|
|
|
15
15
|
* the kit names the package the same way a person typing it from memory would (LLD §15.5).
|
|
16
16
|
*/
|
|
17
17
|
export const CLI_INSTALL_COMMAND = "npm i -g @fourier-labs/harbour";
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Where the two agents read the user-level guide from; both honour the tools' own
|
|
20
|
+
* override variables. Both are skills — a file each tool lists by name and description
|
|
21
|
+
* and reads in full only when the task matches — so the guide costs nothing in every
|
|
22
|
+
* other session. `legacyCodexAgents` is Codex's global AGENTS.md, which releases up to
|
|
23
|
+
* 0.1.33 wrote the guide into; Codex reads that file in every session, in every
|
|
24
|
+
* folder, so the kit's rules for a non-developer's Isomorph app ("never paste logs",
|
|
25
|
+
* "end every report with three lines") were applied to all of a person's other work.
|
|
26
|
+
*/
|
|
19
27
|
export function agentPaths(env = process.env) {
|
|
20
28
|
const home = env.HARBOUR_AGENT_HOME?.trim() || homedir();
|
|
29
|
+
const codexHome = env.CODEX_HOME?.trim() || join(home, ".codex");
|
|
21
30
|
return {
|
|
22
31
|
claudeSkill: join(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), "skills", "isomorph", "SKILL.md"),
|
|
23
|
-
|
|
32
|
+
codexSkill: join(codexHome, "skills", "isomorph", "SKILL.md"),
|
|
33
|
+
legacyCodexAgents: join(codexHome, "AGENTS.md")
|
|
24
34
|
};
|
|
25
35
|
}
|
|
26
36
|
/**
|
|
27
|
-
* Installs the user-level Isomorph
|
|
28
|
-
* by the kit) and
|
|
29
|
-
*
|
|
30
|
-
* reports which `harbour` the agent's next
|
|
31
|
-
*
|
|
37
|
+
* Installs the user-level Isomorph skill for Claude Code and Codex (one file each,
|
|
38
|
+
* wholly owned by the kit) and takes the guide back out of Codex's global AGENTS.md
|
|
39
|
+
* where an older release put it, keeping everything else in that file. Idempotent:
|
|
40
|
+
* unchanged files are reported as kept. Also reports which `harbour` the agent's next
|
|
41
|
+
* command will run (`result.cli`), because this command is routinely reached through
|
|
42
|
+
* `npx` while everything after it is not.
|
|
32
43
|
*/
|
|
33
44
|
export async function agentSetup(env = process.env, lookups = {}) {
|
|
34
45
|
const paths = agentPaths(env);
|
|
35
|
-
const result = { created: [], updated: [], kept: [], cli: await checkCliVersion(lookups) };
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const result = { created: [], updated: [], kept: [], removed: [], cli: await checkCliVersion(lookups) };
|
|
47
|
+
for (const skill of [paths.claudeSkill, paths.codexSkill])
|
|
48
|
+
result[await upsertManagedBlock(skill, `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}`, {})].push(skill);
|
|
49
|
+
if (await removeManagedBlock(paths.legacyCodexAgents, { start: MANAGED_START, end: MANAGED_END }))
|
|
50
|
+
result.removed.push(paths.legacyCodexAgents);
|
|
38
51
|
return result;
|
|
39
52
|
}
|
|
40
53
|
/** Compares the globally resolvable `harbour` with the package this process runs from. */
|
|
@@ -137,14 +150,38 @@ export async function upsertManagedBlock(file, block, options) {
|
|
|
137
150
|
await writeFile(file, next);
|
|
138
151
|
return "updated";
|
|
139
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Takes the marker-fenced block out of `file`, keeping everything before and after it;
|
|
155
|
+
* a file that held nothing else is deleted. Returns whether there was a block to remove.
|
|
156
|
+
*/
|
|
157
|
+
export async function removeManagedBlock(file, options) {
|
|
158
|
+
const current = await readFile(file, "utf8").catch(() => undefined);
|
|
159
|
+
if (current === undefined || !current.includes(options.start) || !current.includes(options.end))
|
|
160
|
+
return false;
|
|
161
|
+
const remainder = current.replace(new RegExp(`\\n*${escape(options.start)}[\\s\\S]*?${escape(options.end)}\\n*`), "\n\n").replace(/^\n+/, "").replace(/\n*$/, "\n");
|
|
162
|
+
if (remainder.trim())
|
|
163
|
+
await writeFile(file, remainder);
|
|
164
|
+
else
|
|
165
|
+
await rm(file);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
140
168
|
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
169
|
+
/**
|
|
170
|
+
* Both agents show a skill to the model as this name and description and read the body
|
|
171
|
+
* only for a task that matches, so the description is the whole gate: it names the
|
|
172
|
+
* three signs of Isomorph work and rules everything else out, because "run it" or
|
|
173
|
+
* "ship it" is said about every kind of project.
|
|
174
|
+
*/
|
|
175
|
+
export const SKILL_DESCRIPTION = `Build, run, check and ship a company app on Isomorph (Harbour) from plain English. Use only when the folder has a .harbour/ directory, when the person names Isomorph or Harbour, or when they ask for a new app for their team in an empty folder; there, "run it", "check it", "I need Slack/Gmail/company data", "ship it" and "make it live" mean this skill. Do not use for any other project, script, question or coding task — nothing in it applies outside an Isomorph app.`;
|
|
141
176
|
const SKILL_FRONTMATTER = `---
|
|
142
177
|
name: isomorph
|
|
143
|
-
description:
|
|
178
|
+
description: ${SKILL_DESCRIPTION}
|
|
144
179
|
---`;
|
|
145
|
-
/** One guide, shared by the Claude Code
|
|
180
|
+
/** One guide, shared by the Claude Code and Codex skills. Written for an agent working with a non-developer, and only for that. */
|
|
146
181
|
export const AGENT_GUIDE = `# Isomorph app kit
|
|
147
182
|
|
|
183
|
+
This guide is for one job: an app that runs on Isomorph (Harbour). It applies when the folder has a \`.harbour/\` directory, when the person names Isomorph or Harbour, or when they ask for a new app for their team and the folder holds no other project. Anything else — another project, a script, a question, ordinary coding — is not this job: ignore every rule below and work as you always do. Never turn an existing project into an Isomorph app unless the person asks for that by name.
|
|
184
|
+
|
|
148
185
|
The person you are working with may not be a developer. They say what they want in plain English; you build it with the Harbour kit and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Turn every failure into one sentence about what happened and one about what happens next. Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
|
|
149
186
|
|
|
150
187
|
## Getting ready (do this yourself, once per machine and folder)
|
|
@@ -157,14 +194,14 @@ The person you are working with may not be a developer. They say what they want
|
|
|
157
194
|
|
|
158
195
|
- "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. Do this unasked as soon as the first check is green — they should always have the link. Locally they are a fixture user; no company sign-in is needed.
|
|
159
196
|
- "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Failures in the app's code are yours to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
|
|
160
|
-
- "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`harbour check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. In development a Send
|
|
197
|
+
- "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`harbour check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically. In development a Send sends a real email or Slack message: reuse explicit authorization for that bounded test, or ask once if none exists; IT access approval alone is not permission to send. Never ask again for the same authorized test. Before a real integration test, run \`harbour integrations status --app-root . --json\`; explain pending IT approval or missing personal consent before pressing Send, and test the ready parts independently. If you have no browser, say that the button itself is untested.
|
|
161
198
|
- "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. Never guess a connection, channel, view or mailbox name — a guessed one is refused before IT's queue ever sees it, so nothing appears for IT to approve; use the exact names the person or IT gave you, and if you have none, that is the one question to ask before declaring anything. Before asking, run \`harbour integrations catalog --app-root . --json\` — it lists the company's connections by the exact identifier \`.harbour/integrations.json\` uses, the operations IT allows on each and the approved channels, views and mailboxes per environment — and declare only identifiers and approved names it lists. 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\`. Request the preview grant in the same turn (\`--environment preview\`) so shipping does not wait on a second IT decision. 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\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the channel, view or mailbox is not on the connection yet: IT adds it in the Harbour console under Controls & integrations → API integrations → the provider tile → Configure → Channels (a warehouse view: Controls & integrations → Databases → the source → Views for the environment), and then you run the same request command again. Say "IT has to add <name> to the Slack connection first; the app works without it until then" and nothing more. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
|
|
162
199
|
- "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (through \`ai()\` in \`src/harbour.client.ts\`) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
|
|
163
|
-
- "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. 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.
|
|
200
|
+
- "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. \`harbour productionise --app-root . --wait --json\` gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they name, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. For kit apps, describe \`TRANSFORMING\` as building and checking the app; it does not mean a transformation AI is running. The CLI saves \`operationRef\` in \`.harbour/local/productionise.json\`; repeating \`productionise\` continues that operation. 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.
|
|
164
201
|
- "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.
|
|
165
202
|
- "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.
|
|
166
203
|
|
|
167
|
-
When a command refuses, the refusal names its own reason and its own fix: change that one thing, then run it again.
|
|
204
|
+
When a command refuses, the refusal names its own reason and its own fix: change that one thing, then run it again. A failed app needs its reported fix; a wait timeout means work is still running, so continue the saved operation — and never start a second deploy of an app while one is running, because concurrent deploys of one app cancel each other.
|
|
168
205
|
|
|
169
206
|
## Building the app
|
|
170
207
|
|
|
@@ -172,7 +209,7 @@ When a command refuses, the refusal names its own reason and its own fix: change
|
|
|
172
209
|
- Authentication is Harbour SSO: no login forms, no roles or ids trusted from the browser; row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\`. Every route needs a signed-in person; no public routes.
|
|
173
210
|
- Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`harbour dev\` and \`harbour check\` apply them.
|
|
174
211
|
- Know the operation's input bounds before writing a call: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (the connector's \`VIEW_READ_MAX_LIMIT\`); anything larger is refused with \`INPUT_INVALID\`, so page instead of asking for more.
|
|
175
|
-
- A Slack message or an email (\`slack.message.post\`, \`gmail.message.send\`) is sent only
|
|
212
|
+
- A Slack message or an email (\`slack.message.post\`, \`gmail.message.send\`) is sent only from an explicit Send control (pressed by the person, or by you for an explicitly authorized test), with a fresh UUID \`idempotencyKey\` per press (reused only to retry that press). Never send from an effect, a timer, a queue or during checks. Consent (\`harbour.integrations.connect\`) exists only for user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`); app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call it — a connect for them is refused as unapproved user access and puts nothing in IT's queue. Missing consent never falls back to another account.
|
|
176
213
|
- A Slack message is posted either as the app (\`"identity": "app"\` — the company's one Slack bot, Isomorph AI, under the name IT approved: declare \`"presentation": { "displayName": "<app name>", "iconEmoji": ":sandwich:" }\` on the connection and IT sees "posts as" before approving; leave it out to post as Isomorph AI itself) or as the person (\`"identity": "user"\` — their own Slack account, after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again) — never pretend one is the other. The declaration in \`.harbour/integrations.json\` is the mode the app requests access for; a post names the approved mode it runs under with \`mode: "app"\` or \`mode: "user"\` on the execute call — optional while the app is approved for one mode, required once IT approved both (\`MODE_REQUIRED\`), and a mode IT has not approved is refused with \`MODE_NOT_GRANTED\`, never swapped for the other. An app-mode post always ends with "Posted by <app> on Isomorph". A post is refused with \`RESOURCE_NOT_APPROVED\` until the bot is in the channel: say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first".
|
|
177
214
|
- No secrets, tokens, \`.env\` values or fetched company content in source. \`.harbour/local/\` is never committed; \`.harbour/integrations.json\` and \`.harbour/kit.lock.json\` are.
|
|
178
215
|
|
|
@@ -180,5 +217,5 @@ When a command refuses, the refusal names its own reason and its own fix: change
|
|
|
180
217
|
|
|
181
218
|
- Plain words, short: "Your app is running at <link>.", "All 6 checks passed.", "One check failed: votes were not being saved — fixed, checking again.", "IT has to approve Slack; the app works without it until then."
|
|
182
219
|
- Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.
|
|
183
|
-
-
|
|
220
|
+
- Keep app reports natural and brief: say what you actually verified, any remaining blocker and who can resolve it, and any material scope decision you made. Do not force headings or repeat unchanged status. A platform check is not proof that the main task worked in the browser.
|
|
184
221
|
- End a turn with at most one question, and only when a decision is genuinely theirs to make and you cannot go on without it. Never offer to do something this guide already tells you to do unasked — do it and report what happened.`;
|
|
@@ -33,7 +33,7 @@ export async function runChecks(root, options) {
|
|
|
33
33
|
const sdk = await ensureSdk(root, bundle, options.env ?? process.env, run, output).catch(error => { output(error instanceof Error ? error.message : String(error)); return "missing"; });
|
|
34
34
|
if (sdk === "missing")
|
|
35
35
|
output("The kit SDK is not installed: set HARBOUR_KIT_SDK_TARBALL to the bundle's @harbour/app-sdk tarball (or use a bundle with sdk.url).");
|
|
36
|
-
const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}) });
|
|
36
|
+
const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}) });
|
|
37
37
|
for (const check of gate.checks)
|
|
38
38
|
record(check);
|
|
39
39
|
let integrations = "not tested";
|
|
@@ -91,9 +91,8 @@ function renderCheck(check) {
|
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
93
|
* Runs the pipeline's kit gate in this app's local session: the pinned
|
|
94
|
-
* gateway
|
|
95
|
-
*
|
|
96
|
-
* PostgreSQL, the session bucket). The gate's control API drives two things
|
|
94
|
+
* native gateway's `gate` subcommand as a one-shot process using the local
|
|
95
|
+
* session's scratch database and files. The gate's control API drives two things
|
|
97
96
|
* from here: the `checks` phase, where the gate hands over the app's tables
|
|
98
97
|
* as its replayed database holds them and `.harbour/checks/` is regenerated
|
|
99
98
|
* from them (retained-checks.ts) and handed back as the set to run; and the
|
|
@@ -104,7 +103,7 @@ function renderCheck(check) {
|
|
|
104
103
|
export async function runKitGate(root, options) {
|
|
105
104
|
const { run, bundle, output } = options;
|
|
106
105
|
const fetchImpl = options.fetch ?? fetch;
|
|
107
|
-
const runtime = new LocalRuntime(root, run);
|
|
106
|
+
const runtime = options.runtime ?? new LocalRuntime(root, run);
|
|
108
107
|
const started = await ensureSession(root, runtime, bundle, output);
|
|
109
108
|
const port = await freePort();
|
|
110
109
|
const base = `http://127.0.0.1:${port}`;
|
|
@@ -118,7 +117,7 @@ export async function runKitGate(root, options) {
|
|
|
118
117
|
} };
|
|
119
118
|
const post = (path, body) => request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
120
119
|
// Everything after the session is up runs under one `finally`: a gate
|
|
121
|
-
//
|
|
120
|
+
// gate process that fails to start is exactly the case that could leave a
|
|
122
121
|
// session this run had started still running.
|
|
123
122
|
let container;
|
|
124
123
|
try {
|
|
@@ -217,19 +216,19 @@ async function ensureSession(root, runtime, bundle, output) {
|
|
|
217
216
|
* `productionise` pre-flight for a kit app: the same gate the pipeline runs,
|
|
218
217
|
* here, before any operation exists. A failed check is refused with the
|
|
219
218
|
* pipeline's own wording (kit.check-failed: <check>); a local runtime that
|
|
220
|
-
* cannot start
|
|
219
|
+
* cannot start is reported and skipped — the pipeline's gate
|
|
221
220
|
* still runs.
|
|
222
221
|
*/
|
|
223
|
-
export async function preflightKitGate(root, bundle, output, run = runCommand, fetchImpl) {
|
|
222
|
+
export async function preflightKitGate(root, bundle, output, run = runCommand, fetchImpl, runtime) {
|
|
224
223
|
const lock = await readKitLock(root).catch(() => undefined);
|
|
225
224
|
if (!lock)
|
|
226
225
|
return "skipped";
|
|
227
226
|
let report;
|
|
228
227
|
try {
|
|
229
|
-
report = await runKitGate(root, { run, bundle, output, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
228
|
+
report = await runKitGate(root, { run, bundle, output, ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(runtime ? { runtime } : {}) });
|
|
230
229
|
}
|
|
231
230
|
catch (error) {
|
|
232
|
-
if (error instanceof CliError && ["LOCAL_RUNTIME_FAILED", "KIT_IMAGES_UNAVAILABLE"].includes(error.code)) {
|
|
231
|
+
if (error instanceof CliError && ["LOCAL_RUNTIME_FAILED", "KIT_IMAGES_UNAVAILABLE", "KIT_RUNTIME_UNAVAILABLE"].includes(error.code)) {
|
|
233
232
|
output(`The kit gate was not run locally (${error.message}); the pipeline's gate will run in CodeBuild.`);
|
|
234
233
|
return "skipped";
|
|
235
234
|
}
|
|
@@ -12,9 +12,9 @@ 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";
|
|
16
15
|
import { runChecks } from "./check.js";
|
|
17
16
|
import { assertAiReady, GovernanceClient, integrationsCatalog, integrationsStatus, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
17
|
+
import { runJob } from "./jobs.js";
|
|
18
18
|
const args = process.argv.slice(2);
|
|
19
19
|
const command = args[0];
|
|
20
20
|
const connectUrl = args[1];
|
|
@@ -31,14 +31,13 @@ const valueStdin = args.includes("--value-stdin");
|
|
|
31
31
|
const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
32
32
|
const wait = args.includes("--wait");
|
|
33
33
|
const reset = args.includes("--reset");
|
|
34
|
-
const stopAll = args.includes("--all");
|
|
35
|
-
const force = args.includes("--force");
|
|
36
34
|
const upgrade = args.includes("--upgrade");
|
|
37
35
|
const testIntegrations = args.includes("--integrations");
|
|
38
36
|
const reason = optionValue("--reason");
|
|
39
37
|
const environment = optionValue("--environment");
|
|
40
38
|
const operations = optionValue("--operations");
|
|
41
39
|
const expiresAt = optionValue("--expires-at");
|
|
40
|
+
const scheduledAt = optionValue("--scheduled-at");
|
|
42
41
|
/** Seconds `productionise`, `status --wait`, `retry` and `promote` follow the operation before reporting it as still running (default: 30 minutes). */
|
|
43
42
|
const maxWaitSeconds = args.includes("--max-wait") ? Number(optionValue("--max-wait")) : undefined;
|
|
44
43
|
const waitOptions = maxWaitSeconds ? { maxWaitMs: maxWaitSeconds * 1000 } : {};
|
|
@@ -72,12 +71,12 @@ const usage = [
|
|
|
72
71
|
" harbour secrets list --operation <reference> [--json]",
|
|
73
72
|
" harbour secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
|
|
74
73
|
" harbour secrets dismiss --operation <reference> --name <NAME>",
|
|
75
|
-
" harbour agent-setup [--json] install the plain-English Isomorph
|
|
74
|
+
" harbour agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
|
|
76
75
|
" harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle (also runs agent-setup)",
|
|
77
76
|
" harbour dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
|
|
78
|
-
" harbour stop --app-root <path> stop this app's local services
|
|
79
|
-
" 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",
|
|
77
|
+
" harbour stop --app-root <path> stop this app's local services, keeping its database and files",
|
|
80
78
|
" 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)",
|
|
79
|
+
" harbour jobs run <name> --app-root <path> [--scheduled-at <UTC>] [--json] run one scheduled job now against the local services",
|
|
81
80
|
" harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
|
|
82
81
|
" harbour integrations status --app-root <path> [--json]",
|
|
83
82
|
" harbour integrations catalog --app-root <path> [--json] the company's connections as .harbour/integrations.json names them: identifiers, allowed operations, approved channels/views/mailboxes per environment (no app needed)",
|
|
@@ -89,7 +88,7 @@ const usage = [
|
|
|
89
88
|
""
|
|
90
89
|
].join("\n");
|
|
91
90
|
const OPERATION_COMMANDS = ["status", "retry", "promote", "setup", "profile", "audience", "secrets"];
|
|
92
|
-
const LOCAL_COMMANDS = ["init", "dev", "stop", "check"];
|
|
91
|
+
const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
|
|
93
92
|
const progress = (message) => { process.stderr.write(`${message}\n`); };
|
|
94
93
|
/** Envelope for commands that start no Harbour operation (local kit commands, integrations). */
|
|
95
94
|
const summaryEnvelope = (result) => ({ schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
|
|
@@ -102,10 +101,12 @@ else if (!command || command === "help" || command === "--help" || args.includes
|
|
|
102
101
|
}
|
|
103
102
|
else if (command === "agent-setup") {
|
|
104
103
|
const result = await agentSetup(process.env);
|
|
105
|
-
for (const state of ["created", "updated", "kept"])
|
|
104
|
+
for (const state of ["created", "updated", "kept", "removed"])
|
|
106
105
|
for (const path of result[state])
|
|
107
106
|
progress(`${state.padEnd(7)} ${path}`);
|
|
108
|
-
|
|
107
|
+
if (result.removed.length)
|
|
108
|
+
progress("The guide is no longer in your global Codex instructions, where every session read it; Codex now has it as a skill, like Claude Code.");
|
|
109
|
+
progress("Claude Code and Codex: the `isomorph` skill is installed. Each agent uses it only for an Isomorph app (a folder with .harbour/, or an app you ask it to build) and works as usual everywhere else. Say what you want to build; the agent installs and runs the kit itself.");
|
|
109
110
|
// Which `harbour` the next command will run. This command is usually reached through
|
|
110
111
|
// `npx …@stable`, which tells you nothing about the CLI that does the actual work.
|
|
111
112
|
if (result.cli)
|
|
@@ -117,14 +118,15 @@ else if (!["connect", "login", "logout", "productionise", "integrations", ...LOC
|
|
|
117
118
|
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
118
119
|
|| (["productionise", "status", "retry", "promote"].includes(command) && optionError)
|
|
119
120
|
|| (command === "productionise" && !root)
|
|
120
|
-
|| (LOCAL_COMMANDS.includes(command) && !root
|
|
121
|
+
|| (LOCAL_COMMANDS.includes(command) && !root)
|
|
122
|
+
|| (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
|
|
121
123
|
|| (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
|
|
122
124
|
|| (OPERATION_COMMANDS.includes(command) && !operationRef)
|
|
123
125
|
|| (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
|
|
124
126
|
if (optionError)
|
|
125
127
|
process.stderr.write(`${optionError}\n`);
|
|
126
128
|
process.stderr.write(usage);
|
|
127
|
-
process.
|
|
129
|
+
process.exit(2);
|
|
128
130
|
}
|
|
129
131
|
else {
|
|
130
132
|
try {
|
|
@@ -133,15 +135,6 @@ else {
|
|
|
133
135
|
process.stdout.write(`Harbour is connected for ${saved.tenantId}.\n`);
|
|
134
136
|
process.exitCode = 0;
|
|
135
137
|
}
|
|
136
|
-
else if (command === "stop" && stopAll) {
|
|
137
|
-
// Every Harbour project on this machine, no app root needed: the complete remedy for a full Docker address pool.
|
|
138
|
-
const result = await stopAllHarbourProjects(runCommand, { force });
|
|
139
|
-
for (const project of result.stopped)
|
|
140
|
-
progress(`Brought down ${project.root ?? project.project} (data kept).`);
|
|
141
|
-
for (const project of result.running)
|
|
142
|
-
progress(`Left ${project.root ?? project.project} running; \`harbour stop --app-root ${project.root ?? "<path>"}\` or \`--force\` stops it.`);
|
|
143
|
-
emit(summaryEnvelope({ stopped: result.stopped.map(project => project.root ?? project.project), running: result.running.map(project => project.root ?? project.project), volumesRetained: true }));
|
|
144
|
-
}
|
|
145
138
|
else if (LOCAL_COMMANDS.includes(command)) {
|
|
146
139
|
// Local commands run before the company config/login requirement: the base app needs neither.
|
|
147
140
|
const bundle = EMBEDDED_KIT_BUNDLE;
|
|
@@ -200,6 +193,11 @@ else {
|
|
|
200
193
|
else
|
|
201
194
|
emit(summaryEnvelope(report));
|
|
202
195
|
}
|
|
196
|
+
else if (command === "jobs") {
|
|
197
|
+
const result = await runJob(target, args[2], scheduledAt, runCommand);
|
|
198
|
+
progress(`Job ${result.name} completed for ${result.scheduledAt}.`);
|
|
199
|
+
emit(summaryEnvelope(result));
|
|
200
|
+
}
|
|
203
201
|
else {
|
|
204
202
|
const started = await startDev(target, { bundle, output: progress, reset, company: config ? { apiUrl: config.apiUrl, tenantId: config.tenantId, accessToken: companyToken, account: async () => { const token = await companyToken(); return token ? connectedAccount(config.mcpUrl, config.tenantId, token) : undefined; } } : undefined });
|
|
205
203
|
let stopping = false;
|
|
@@ -290,7 +288,7 @@ else {
|
|
|
290
288
|
if (json || command === "productionise")
|
|
291
289
|
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
292
290
|
// Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
|
|
293
|
-
process.
|
|
291
|
+
process.exit(envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1);
|
|
294
292
|
}
|
|
295
293
|
}
|
|
296
294
|
function renderIntegrationsStatus(status) {
|
|
@@ -5,9 +5,8 @@ import { GovernanceClient, ensureLinkedApp } from "./integrations.js";
|
|
|
5
5
|
import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, releaseDevLock, runCommand } from "./local-runtime.js";
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
7
|
/**
|
|
8
|
-
* Starts the
|
|
9
|
-
*
|
|
10
|
-
* `docker compose down` without `-v` (volumes retained, network freed) and is what Ctrl-C calls.
|
|
8
|
+
* Starts the kit-managed Postgres and App Gateway, applies migrations, then
|
|
9
|
+
* starts Vite and the loopback origin. Stopping retains the app's local data.
|
|
11
10
|
*/
|
|
12
11
|
export async function startDev(root, options) {
|
|
13
12
|
const run = options.run ?? runCommand;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { CliError } from "./output.js";
|
|
5
|
+
import { parseSessionEnv, readDevLock, runCommand } from "./local-runtime.js";
|
|
6
|
+
import { kitPaths } from "./kit.js";
|
|
7
|
+
const jobName = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
8
|
+
const scheduleDeclaration = /^export\s+const\s+schedule\s*=\s*["']([^"']+)["']\s*;?\s*$/m;
|
|
9
|
+
export async function discoverJobs(root) {
|
|
10
|
+
const directory = join(root, "jobs");
|
|
11
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
12
|
+
const jobs = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (!entry.isFile() || !entry.name.endsWith(".ts"))
|
|
15
|
+
continue;
|
|
16
|
+
const name = entry.name.slice(0, -3);
|
|
17
|
+
if (!jobName.test(name))
|
|
18
|
+
throw new CliError("JOB_INVALID", `jobs/${entry.name}: the file name must be a lowercase logical name.`);
|
|
19
|
+
const path = join(directory, entry.name);
|
|
20
|
+
const source = await readFile(path, "utf8");
|
|
21
|
+
const schedule = source.match(scheduleDeclaration)?.[1];
|
|
22
|
+
if (!schedule)
|
|
23
|
+
throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one literal schedule, for example export const schedule = \"0 9 * * 1-5\".`);
|
|
24
|
+
if (!/^export\s+default\s+/m.test(source))
|
|
25
|
+
throw new CliError("JOB_INVALID", `jobs/${entry.name}: export one default job handler.`);
|
|
26
|
+
jobs.push({ name, schedule, path });
|
|
27
|
+
}
|
|
28
|
+
return jobs.sort((a, b) => a.name.localeCompare(b.name));
|
|
29
|
+
}
|
|
30
|
+
export async function runJob(root, name, scheduledAt, run = runCommand) {
|
|
31
|
+
if (!jobName.test(name))
|
|
32
|
+
throw new CliError("JOB_INVALID", "A valid job name is required.");
|
|
33
|
+
const job = (await discoverJobs(root)).find(candidate => candidate.name === name);
|
|
34
|
+
if (!job)
|
|
35
|
+
throw new CliError("JOB_NOT_FOUND", `No job named ${name} exists under jobs/.`);
|
|
36
|
+
const timestamp = scheduledAt ?? new Date().toISOString();
|
|
37
|
+
if (Number.isNaN(Date.parse(timestamp)))
|
|
38
|
+
throw new CliError("JOB_INVALID", "--scheduled-at must be an ISO 8601 timestamp.");
|
|
39
|
+
const session = await readFile(join(kitPaths(root).state, "session.env"), "utf8").catch(() => "");
|
|
40
|
+
const lock = await readDevLock(root);
|
|
41
|
+
if (!session || !lock)
|
|
42
|
+
throw new CliError("DEV_NOT_RUNNING", "Start `harbour dev` before running a job so it uses the same local data and identity boundary.");
|
|
43
|
+
const env = parseSessionEnv(session);
|
|
44
|
+
const runner = `const module = await import(${JSON.stringify(pathToFileURL(job.path).href)}); if (typeof module.default !== "function") throw new Error("job has no default handler"); await module.default({scheduledAt: process.env.HARBOUR_SCHEDULED_AT});`;
|
|
45
|
+
const result = await run(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", runner], { cwd: root, env: { ...env, HARBOUR_GATEWAY_URL: `http://127.0.0.1:${lock.ports.gateway}`, HARBOUR_SCHEDULED_AT: timestamp } });
|
|
46
|
+
if (result.code !== 0)
|
|
47
|
+
throw new CliError("JOB_FAILED", `Job ${name} failed: ${result.stderr.trim().split("\n").at(-1) ?? "process exited non-zero"}`);
|
|
48
|
+
return { name, schedule: job.schedule, scheduledAt: timestamp };
|
|
49
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "harbour.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.1.
|
|
3
|
+
"kitVersion": "0.1.34",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@harbour/app-sdk",
|
|
6
|
-
"version": "1.1.
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
6
|
+
"version": "1.1.1",
|
|
7
|
+
"tarballSha256": "49bb5c10f0b1825c2051add7b0975c06a64a0468b50e5feec091281952c2db57",
|
|
8
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:49bb5c10f0b1825c2051add7b0975c06a64a0468b50e5feec091281952c2db57"
|
|
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:2d134fb8d4a10d4c25d22cb0df4da31ae91c9168d97cd99856d4677b41885077",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:b7f896aff3c88ecdd883a4f22b83741d2ad9c13fed6b21adabdff126ffff1f35"
|
|
13
13
|
},
|
|
14
14
|
"brief": {
|
|
15
|
-
"fingerprint": "
|
|
15
|
+
"fingerprint": "77cc3e0d6bc83c09b5c3ccbfebde644cc4143d77517a224d27f3e8a1aa541814"
|
|
16
16
|
},
|
|
17
17
|
"declarationSchema": "harbour.app-integrations/2.0"
|
|
18
18
|
};
|
|
@@ -108,9 +108,9 @@ export function appRoot(rootArg) {
|
|
|
108
108
|
export function kitPaths(root) {
|
|
109
109
|
const harbour = join(root, ".harbour");
|
|
110
110
|
const local = join(harbour, "local");
|
|
111
|
-
return { harbour, local, declaration: join(harbour, "integrations.json"), lock: join(harbour, "kit.lock.json"), checks: join(harbour, "checks"),
|
|
111
|
+
return { harbour, local, declaration: join(harbour, "integrations.json"), lock: join(harbour, "kit.lock.json"), checks: join(harbour, "checks"), gatewayConfig: join(local, "app-gateway.json"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json") };
|
|
112
112
|
}
|
|
113
|
-
/** Stable per-project identity for
|
|
113
|
+
/** Stable per-project identity for the kit-managed local state. */
|
|
114
114
|
export function projectName(root, suffix = "") {
|
|
115
115
|
return `harbour-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}${suffix}`;
|
|
116
116
|
}
|