@isomorph.ai/cli 0.2.2 → 0.3.0-rc.1
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 +4 -4
- package/dist/packages/harbour-cli/src/agent-setup.js +34 -35
- package/dist/packages/harbour-cli/src/app-schema.js +5 -1
- package/dist/packages/harbour-cli/src/cli.js +31 -14
- package/dist/packages/harbour-cli/src/config.js +76 -36
- package/dist/packages/harbour-cli/src/guide.js +120 -0
- package/dist/packages/harbour-cli/src/integrations.js +6 -1
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +10 -10
- package/dist/packages/harbour-cli/src/retained-checks.js +3 -3
- package/dist/packages/harbour-cli/src/starter.js +73 -180
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
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 @isomorph.ai/cli agent-setup
|
|
11
11
|
```
|
|
12
12
|
|
|
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 `.isomorph/`, an app you ask for on Isomorph — so the agent works exactly as before on everything else. (Releases before 0.1.34 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.)
|
|
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`, with the kit's rules beside it as `core.md`, `integrations.md`, `ai.md` and `jobs.md`) and never touches your other skills or instructions. A skill is read only when the task matches it — a folder with `.isomorph/`, an app you ask for on Isomorph — so the agent works exactly as before on everything else. (Releases before 0.1.34 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 `isomorph init`, `dev` and `check` afterwards run whatever `isomorph` 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 @isomorph.ai/cli`.
|
|
16
16
|
|
|
@@ -35,7 +35,7 @@ isomorph check --app-root <path> [--integrations] [--json] declaration, types,
|
|
|
35
35
|
isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them, with approved channels/views per environment
|
|
36
36
|
isomorph integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`isomorph dev` and `isomorph productionise` file it for you)
|
|
37
37
|
isomorph integrations status --app-root <path> [--json]
|
|
38
|
-
isomorph connect <work-email-
|
|
38
|
+
isomorph connect <work-email | company-start-url> once per company; then isomorph login | logout
|
|
39
39
|
isomorph productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
|
|
40
40
|
isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference>
|
|
41
41
|
```
|
|
@@ -46,10 +46,10 @@ isomorph status | retry | promote | setup | profile | audience | secrets … --o
|
|
|
46
46
|
|
|
47
47
|
## Apps set up by the old `harbour` CLI
|
|
48
48
|
|
|
49
|
-
Before 0.2.0 the CLI was published as `@fourier-labs/harbour` with the command `harbour`, and it kept the kit's files under `.harbour/`. That layout is not read any more — not by this CLI and not by the deployment pipeline. Run `isomorph init --upgrade --app-root .` once in each such app: it renames `.harbour/` to `.isomorph/` (git records renames), moves the two committed schema ids with it, rewrites the managed block in CLAUDE.md / AGENTS.md,
|
|
49
|
+
Before 0.2.0 the CLI was published as `@fourier-labs/harbour` with the command `harbour`, and it kept the kit's files under `.harbour/`. That layout is not read any more — not by this CLI and not by the deployment pipeline. Run `isomorph init --upgrade --app-root .` once in each such app: it renames `.harbour/` to `.isomorph/` (git records renames), moves the two committed schema ids with it, rewrites the managed block in CLAUDE.md / AGENTS.md, deletes the project skill older CLIs wrote (`.claude/skills/harbour-kit/SKILL.md` up to 0.1.x, `.claude/skills/isomorph-kit/SKILL.md` up to 0.2.2 — when it is the kit's, by the frontmatter name and the opening line every release wrote; one whose body you rewrote is kept, and reported as kept; the rules now live beside the user-level skill and no project skill replaces it), deletes the checks the old kit generated (the next `isomorph check` regenerates them) and rewrites the SDK package name, its exported type names and the retired `HARBOUR_*` environment names (`HARBOUR_APP_URL` → `ISOMORPH_APP_URL` and the rest) in `package.json`, `vite.config.*`, `src/`, `jobs/` and the checks you wrote yourself. Run it again after updating the CLI: an app already moved by 0.2.0 loses its old skill and old environment names the same way. Every other command refuses an un-upgraded app and names that command. The old CLI can stay installed; it no longer deploys.
|
|
50
50
|
|
|
51
51
|
## Development
|
|
52
52
|
|
|
53
53
|
Built from the [governance control plane](https://github.com/Fourier-Labs-AI/harbour-governance-control-plane) repository: `npm ci`, `npm run build --prefix packages/harbour-cli`, tests in `tests/cli-kit.test.ts` and `tests/cli-packaging-contract.test.ts`. Releases are tagged `cli-v<version>` by the release automation the moment `packages/harbour-cli/package.json` changes on `main`, so bump to a candidate first (`X.Y.Z-rc.1`, published under the `candidate` dist-tag through npm trusted publishing) and promote it with the `CLI release automation` workflow's `candidate` input; a final version on `main` without an accepted candidate is refused by the release job. 0.2.0 and 0.2.1 (the rename) were published by hand from their merge commits before the trusted publisher existed for `@isomorph.ai/cli`, which is why their `cli-v` release runs show red.
|
|
54
54
|
|
|
55
|
-
Connect with `isomorph connect name@getlokal.com`: the CLI
|
|
55
|
+
Connect with `isomorph connect name@getlokal.com`: the CLI asks the platform which company admits the email's domain (`https://platform.isomorph.ai/start/getlokal.com.json` — the same policies that admit the person at sign-in) and saves that company's profile. Only the domain is sent, not the email. When no company admits the domain, or more than one does, the CLI prints the platform's sentence (ask IT, or run the printed `isomorph connect <link>`) and saves nothing; a company's console page (`platform.isomorph.ai/t/<company>`) is accepted as the link. `isomorph login` then prints the company it signed in to and what IT has set up there. Company sign-in and access checks still apply.
|
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { CLI_VERSION } from "./version.js";
|
|
8
|
+
import { GUIDE_MODULES } from "./guide.js";
|
|
8
9
|
import { continueCommand } from "./operations.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
export const MANAGED_START = "<!-- isomorph:kit:start -->";
|
|
@@ -19,7 +20,8 @@ export const CLI_INSTALL_COMMAND = "npm i -g @isomorph.ai/cli";
|
|
|
19
20
|
* Where the two agents read the user-level guide from; both honour the tools' own
|
|
20
21
|
* override variables. Both are skills — a file each tool lists by name and description
|
|
21
22
|
* and reads in full only when the task matches — so the guide costs nothing in every
|
|
22
|
-
* other session. `
|
|
23
|
+
* other session. The rule modules (`guide.ts`) are written beside each skill file, so
|
|
24
|
+
* "core.md beside this skill" resolves for both agents. `legacyCodexAgents` is Codex's global AGENTS.md, which releases up to
|
|
23
25
|
* 0.1.33 wrote the guide into; Codex reads that file in every session, in every
|
|
24
26
|
* folder, so the kit's rules for a non-developer's Isomorph app ("never paste logs",
|
|
25
27
|
* "end every report with three lines") were applied to all of a person's other work.
|
|
@@ -34,9 +36,10 @@ export function agentPaths(env = process.env) {
|
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
|
-
* Installs the user-level Isomorph skill for Claude Code and Codex
|
|
38
|
-
*
|
|
39
|
-
*
|
|
39
|
+
* Installs the user-level Isomorph skill for Claude Code and Codex — `SKILL.md` (the
|
|
40
|
+
* workflow) and the four rule modules beside it, every file wholly owned by the kit —
|
|
41
|
+
* and takes the guide back out of Codex's global AGENTS.md where an older release put
|
|
42
|
+
* it, keeping everything else in that file. Idempotent:
|
|
40
43
|
* unchanged files are reported as kept. Also reports which `isomorph` the agent's next
|
|
41
44
|
* command will run (`result.cli`), because this command is routinely reached through
|
|
42
45
|
* `npx` while everything after it is not.
|
|
@@ -45,11 +48,16 @@ export async function agentSetup(env = process.env, lookups = {}) {
|
|
|
45
48
|
const paths = agentPaths(env);
|
|
46
49
|
const result = { created: [], updated: [], kept: [], removed: [], cli: await checkCliVersion(lookups) };
|
|
47
50
|
for (const skill of [paths.claudeSkill, paths.codexSkill])
|
|
48
|
-
|
|
51
|
+
for (const file of skillFiles(skill))
|
|
52
|
+
result[await upsertManagedBlock(file.path, file.text, {})].push(file.path);
|
|
49
53
|
if (await removeManagedBlock(paths.legacyCodexAgents, { start: MANAGED_START, end: MANAGED_END }))
|
|
50
54
|
result.removed.push(paths.legacyCodexAgents);
|
|
51
55
|
return result;
|
|
52
56
|
}
|
|
57
|
+
/** What `agent-setup` writes into one skill directory: `SKILL.md` (the workflow) and one `<module>.md` per rule module beside it. */
|
|
58
|
+
export function skillFiles(skill) {
|
|
59
|
+
return [{ path: skill, text: `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}` }, ...Object.entries(GUIDE_MODULES).map(([name, text]) => ({ path: join(dirname(skill), `${name}.md`), text }))];
|
|
60
|
+
}
|
|
53
61
|
/** Compares the globally resolvable `isomorph` with the package this process runs from. */
|
|
54
62
|
export async function checkCliVersion(lookups = {}) {
|
|
55
63
|
const running = await (lookups.runningCliVersion ?? runningPackageVersion)();
|
|
@@ -190,46 +198,37 @@ const SKILL_FRONTMATTER = `---
|
|
|
190
198
|
name: isomorph
|
|
191
199
|
description: ${SKILL_DESCRIPTION}
|
|
192
200
|
---`;
|
|
193
|
-
/** One guide, shared by the Claude Code and Codex skills
|
|
201
|
+
/** One guide, shared by the Claude Code and Codex skills: the workflow only, written for an agent working with a non-developer. Every rule lives in a module (`guide.ts`) beside it and is pointed at, not restated. */
|
|
194
202
|
export const AGENT_GUIDE = `# Isomorph development kit
|
|
195
203
|
|
|
196
|
-
This guide is for one job: an app that runs on Isomorph
|
|
204
|
+
This guide is for one job: an app that runs on Isomorph — a folder with \`.isomorph/\`, or a new app the person asks for on Isomorph. Anything else 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.
|
|
205
|
+
|
|
206
|
+
The person may not be a developer. They say what they want in plain English; you build it and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
|
|
197
207
|
|
|
198
|
-
The
|
|
208
|
+
The kit's rules are the files beside this one: \`core.md\` before the first edit; \`integrations.md\`, \`ai.md\` and \`jobs.md\` before the work each names.
|
|
199
209
|
|
|
200
|
-
## Getting ready (
|
|
210
|
+
## Getting ready (once per machine and folder)
|
|
201
211
|
|
|
202
|
-
1. CLI, before \`isomorph init\` or anything else:
|
|
203
|
-
2. Folder: if
|
|
204
|
-
3. Sign-in, needed only for company systems and shipping: run \`isomorph 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
|
|
212
|
+
1. CLI, before \`isomorph init\` or anything else: the installed CLI must match the current release. \`isomorph agent-setup\` prints both versions and says when the installed one is missing or older (\`--json\`: \`cli.upgradeRequired\`, \`cli.remediation\`); otherwise compare \`isomorph --version\` with \`npx -y @isomorph.ai/cli --version\`. If it is missing or older, run \`npm i -g @isomorph.ai/cli\` and confirm \`isomorph --version\` now matches: every later command runs the installed CLI, and a stale one builds an app the deployment pipeline refuses (\`kit_bundle_incompatible\`). Node 22+, no Docker.
|
|
213
|
+
2. Folder: if it has no \`.isomorph/\` directory, run \`isomorph init --app-root .\` — an empty folder gets a small starter app, an existing Vite + React app gets the kit files and nothing overwritten.
|
|
214
|
+
3. Sign-in, needed only for company systems and shipping: run \`isomorph 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. It then prints the company and what IT has set up. If it says the company is not connected yet, ask for their work email and run \`isomorph connect <work-email>\` — Isomorph looks the company up from the email's domain. Only if that fails use a link: if it answers that more than one company admits the domain, ask which one and run the command it printed; if it says no company admits the domain, their IT admin has to add it or give them a link (their Isomorph page \`https://platform.isomorph.ai/t/<company>\`, or the setup link), and then run \`isomorph connect <link>\`.
|
|
205
215
|
|
|
206
216
|
## What they say → what you do
|
|
207
217
|
|
|
208
|
-
- "run it", "show me", "let me try it" → start \`isomorph dev --app-root .\` in the background (
|
|
209
|
-
- "check it", "is it ok?", "is it ready?" → with dev running, \`isomorph check --app-root . --json\`, then read \`.isomorph/local/check-report.json\`. Failures in the app's code are yours
|
|
210
|
-
- "does it work?", and before you report anything as working → open the dev link in your own browser
|
|
211
|
-
- "I need Slack / Gmail / the warehouse / company data" →
|
|
212
|
-
- "summarise", "draft", "explain", "AI" →
|
|
213
|
-
- "ship it", "put it online", "let my team try it" →
|
|
214
|
-
- "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph promote --operation <ref> --json\`, show the exact app name, description and production audience again and ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed.
|
|
215
|
-
- "stop it" → \`isomorph stop --app-root .\` (
|
|
216
|
-
- "every day at 2pm", "run this on a schedule", "send this automatically" →
|
|
218
|
+
- "run it", "show me", "let me try it" → start \`isomorph dev --app-root .\` in the background (the first start downloads the native runtime; a minute or two). Wait for \`Isomorph dev is running: http://127.0.0.1:<port>\` and give them that link — unasked as soon as the first check is green. Locally they are a fixture user; no company sign-in is needed.
|
|
219
|
+
- "check it", "is it ok?", "is it ready?" → with dev running, \`isomorph check --app-root . --json\`, then read \`.isomorph/local/check-report.json\`. Failures in the app's code are yours: fix, then check again until it is clean — after every change and before every ship, without being asked and without offering them as a choice.
|
|
220
|
+
- "does it work?", and before you report anything as working → open the dev link in your own browser, press the control you built or changed, and read what the app shows. A green \`isomorph check\` is not that proof: fixtures answer AI and company systems, so their refusals appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically; a Send in development is real: reuse explicit authorization for that bounded test, or ask once if none exists (IT approval alone is not permission to send). If you have no browser, say that the button itself is untested.
|
|
221
|
+
- "I need Slack / Gmail / the warehouse / company data" → read \`integrations.md\` beside this skill first; then, in the same turn, read the catalog, declare only what the app calls, submit the access request yourself, and say READY or PENDING in one line.
|
|
222
|
+
- "summarise", "draft", "explain", "AI" → read \`ai.md\` beside this skill first; then one call behind a control they press.
|
|
223
|
+
- "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked, and fix everything it finds: the same gates run again in the cloud, minutes per attempt. Before sharing or deploying the private preview, show the exact proposed app name, description and audience that will be passed to Isomorph (say "only you" when the audience is empty) and ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. Then \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\` (omit \`--emails\` for "only you"). It records the confirmed setup before uploading or deploying the app, files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve, or with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without them until then. \`result.deployment.protectedUrl\` is the private preview, for them and the confirmed audience after company sign-in. If your tool cuts it off, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened; \`TRANSFORMING\` means building and checking.
|
|
224
|
+
- "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph promote --operation <ref> --json\`, show the exact app name, description and production audience again and ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Report the production link, or that an operator approval is pending.
|
|
225
|
+
- "stop it" → \`isomorph stop --app-root .\` (data kept); \`isomorph dev --reset --app-root .\` deletes local data, only when they ask to start over.
|
|
226
|
+
- "every day at 2pm", "run this on a schedule", "send this automatically" → read \`jobs.md\` beside this skill first; then write the job, run it once with \`isomorph jobs run\`, and say the sentence it gives you.
|
|
217
227
|
|
|
218
228
|
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.
|
|
219
229
|
|
|
220
|
-
## Building the app
|
|
221
|
-
|
|
222
|
-
- Identity, data and files go through \`@isomorph.ai/app-sdk\` only: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`. Company systems go only through \`isomorph.integrations.execute\` with declared operations. AI goes through \`isomorph.ai\`; never add an OpenAI/Anthropic key or SDK. Never open a database, bucket or company URL from browser code, and never add another backend, auth library or deployment config: the kit is the whole path.
|
|
223
|
-
- Authentication is Isomorph 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.
|
|
224
|
-
- Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`isomorph dev\` and \`isomorph check\` apply them.
|
|
225
|
-
- 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.
|
|
226
|
-
- In browser code, 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 browser timer or during checks. A declared \`jobs/*.ts\` handler is the only scheduled-send path: it acts as the app, so only app-mode operations such as \`slack.message.post\` may run there; user-mode Slack and Gmail need a person and cannot. A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so replay does not silently repost. Local job runs and \`isomorph check\` use fixtures and send nothing. Consent (\`isomorph.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. Whenever the app uses a user-identity operation, build its consent flow into the app during the original implementation without asking whether to add it: the operation's control calls \`isomorph.integrations.connect\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state, and then lets the person continue the operation. Consent starts only from that person's control, never on page load. Missing consent never falls back to another account.
|
|
227
|
-
- 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 \`.isomorph/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".
|
|
228
|
-
- No secrets, tokens, \`.env\` values or fetched company content in source. \`.isomorph/local/\` is never committed; \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\` are.
|
|
229
|
-
|
|
230
230
|
## Talking to the person
|
|
231
231
|
|
|
232
|
-
- Plain words, short: "Your app is running at <link>.", "
|
|
233
|
-
-
|
|
234
|
-
-
|
|
235
|
-
- 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.`;
|
|
232
|
+
- Plain words, short: "Your app is running at <link>.", "IT has to approve Slack; the app works without it until then." Say what happens next and roughly how long it takes; report only what you observed, and say when something is unknown.
|
|
233
|
+
- Keep reports natural and brief: what you verified, any remaining blocker and who can resolve it, any material scope decision. No forced headings, no repeated status. A platform check is not proof that the main task worked in the browser.
|
|
234
|
+
- End a turn with at most one question, and only when a decision is genuinely theirs 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.`;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* A policy that decides rows by the signed-in identity, and the column it decides them on.
|
|
5
|
+
* `IDENTITY` is rule `core.rls-owner-scoped` (guide.ts): any policy text naming the
|
|
6
|
+
* identity setting makes the table private, so a shared-read table keeps it out of SELECT.
|
|
7
|
+
*/
|
|
4
8
|
const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
|
|
5
9
|
const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
|
|
6
10
|
/**
|
|
@@ -5,7 +5,7 @@ import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchS
|
|
|
5
5
|
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
6
6
|
import { CLI_VERSION } from "./version.js";
|
|
7
7
|
import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
|
|
8
|
-
import { connect, loadConfig, resolveConfig } from "./config.js";
|
|
8
|
+
import { companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
|
|
9
9
|
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
10
10
|
import { appRoot, assertKitCurrent, readKitLock, requestResourceName } from "./kit.js";
|
|
11
11
|
import { initKit } from "./starter.js";
|
|
@@ -13,7 +13,7 @@ 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";
|
|
16
|
-
import { assertAiReady, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
16
|
+
import { assertAiReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
17
17
|
import { runJob } from "./jobs.js";
|
|
18
18
|
const args = process.argv.slice(2);
|
|
19
19
|
const command = args[0];
|
|
@@ -60,7 +60,7 @@ for (let index = 0; index < args.length; index += 1) {
|
|
|
60
60
|
const explicitToken = process.env.ISOMORPH_TOKEN?.trim() ?? "";
|
|
61
61
|
const usage = [
|
|
62
62
|
"Usage:",
|
|
63
|
-
" isomorph connect <work-email-
|
|
63
|
+
" isomorph connect <work-email | company-start-url>",
|
|
64
64
|
" isomorph login | logout",
|
|
65
65
|
" isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--no-wait] [--max-wait <seconds>] [--json]",
|
|
66
66
|
" isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
|
|
@@ -81,7 +81,7 @@ const usage = [
|
|
|
81
81
|
" isomorph integrations request <connection> --app-root <path> [--reason <text>] [--operations a,b] [--json] one request per connection, for every environment at once: IT approves it once (isomorph dev and productionise file it for you)",
|
|
82
82
|
" isomorph integrations status --app-root <path> [--json]",
|
|
83
83
|
" isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
|
|
84
|
-
"Run `isomorph connect <work-email
|
|
84
|
+
"Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
|
|
85
85
|
"productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
|
|
86
86
|
`--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
|
|
87
87
|
"For kit apps, productionise first files the access request for every connection in .isomorph/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
|
|
@@ -93,7 +93,10 @@ const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
|
|
|
93
93
|
const progress = (message) => { process.stderr.write(`${message}\n`); };
|
|
94
94
|
/** Envelope for commands that start no Isomorph operation (local kit commands, integrations). */
|
|
95
95
|
const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
|
|
96
|
-
|
|
96
|
+
/** `line` is the human form for a command whose result is a sentence, not a summary. */
|
|
97
|
+
const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : line ?? renderSummary(value)); process.exitCode = 0; };
|
|
98
|
+
/** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
|
|
99
|
+
const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
|
|
97
100
|
if (command === "--version" || command === "version") {
|
|
98
101
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
99
102
|
}
|
|
@@ -133,8 +136,7 @@ else {
|
|
|
133
136
|
try {
|
|
134
137
|
if (command === "connect") {
|
|
135
138
|
const saved = await connect(connectUrl);
|
|
136
|
-
|
|
137
|
-
process.exitCode = 0;
|
|
139
|
+
emit(summaryEnvelope({ tenantId: saved.tenantId, displayName: saved.displayName }), `Connected to ${companyLabel(saved)}.\n`);
|
|
138
140
|
}
|
|
139
141
|
else if (LOCAL_COMMANDS.includes(command)) {
|
|
140
142
|
// Local commands run before the company config/login requirement: the base app needs neither.
|
|
@@ -158,7 +160,7 @@ else {
|
|
|
158
160
|
const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
|
|
159
161
|
if (sdk === "missing")
|
|
160
162
|
progress(`The kit SDK was not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${bundle.sdk.package} tarball (or use a bundle with sdk.url), then rerun \`isomorph init\`.`);
|
|
161
|
-
progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`.
|
|
163
|
+
progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Both agents read the Isomorph block in CLAUDE.md / AGENTS.md and the `isomorph` skill installed by agent-setup." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
|
|
162
164
|
if (upgrade) {
|
|
163
165
|
const report = await runChecks(target, { run: runCommand, bundle, output: progress });
|
|
164
166
|
emit(summaryEnvelope({ ...result, report }));
|
|
@@ -183,7 +185,7 @@ else {
|
|
|
183
185
|
let governance;
|
|
184
186
|
if (testIntegrations) {
|
|
185
187
|
if (!config)
|
|
186
|
-
throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `isomorph connect <work-email
|
|
188
|
+
throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `isomorph connect <work-email>`.");
|
|
187
189
|
const token = await companyToken();
|
|
188
190
|
if (!token)
|
|
189
191
|
throw new CliError("AUTH_REQUIRED", "`--integrations` needs an Isomorph sign-in: run `isomorph login`.");
|
|
@@ -215,13 +217,13 @@ else {
|
|
|
215
217
|
else {
|
|
216
218
|
const config = resolveConfig(process.env, await loadConfig());
|
|
217
219
|
if (!config)
|
|
218
|
-
throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email
|
|
220
|
+
throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email>`.");
|
|
219
221
|
const url = config.mcpUrl;
|
|
220
222
|
const tenant = config.tenantId;
|
|
221
223
|
if (command === "login") {
|
|
222
224
|
await login(url, tenant, progress);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
+
const signedIn = await signedInDetails(config);
|
|
226
|
+
emit(summaryEnvelope({ tenantId: tenant, displayName: config.displayName, ...signedIn }), signedIn ? `Signed in to ${companyLabel(config)} as ${signedIn.account}. ${companySystemsLine(signedIn.connections)}\n` : "Isomorph sign-in complete.\n");
|
|
225
227
|
}
|
|
226
228
|
else if (command === "logout") {
|
|
227
229
|
await logout(url, tenant);
|
|
@@ -296,8 +298,7 @@ else {
|
|
|
296
298
|
process.stderr.write(renderFailure(envelope));
|
|
297
299
|
if (json || command === "productionise")
|
|
298
300
|
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
299
|
-
|
|
300
|
-
process.exit(envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1);
|
|
301
|
+
process.exit(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1);
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
/** One line per (connection, identity): the three lanes are one approval, so they are read together. */
|
|
@@ -314,6 +315,22 @@ function renderIntegrationsStatus(status) {
|
|
|
314
315
|
function renderRequest(result) {
|
|
315
316
|
return [`App ${result.appId}: ${result.connection}`, ...result.requests.map(item => ` ${IDENTITY_WORDS[item.identityMode]} — ${item.operations.join(", ")} on ${item.resources.map(requestResourceName).join(", ")}${item.presentation ? ` (posts as "${item.presentation.displayName}")` : ""}: ${item.state} — ${item.summary}`)].join("\n");
|
|
316
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Who signed in and what IT has set up, for the line after `login`; undefined
|
|
320
|
+
* when either cannot be read — the sign-in itself stands either way.
|
|
321
|
+
*/
|
|
322
|
+
async function signedInDetails(config) {
|
|
323
|
+
try {
|
|
324
|
+
const token = await refreshStoredToken(config.mcpUrl, config.tenantId);
|
|
325
|
+
const account = token && await connectedAccount(config.mcpUrl, config.tenantId, token);
|
|
326
|
+
if (!account)
|
|
327
|
+
return undefined;
|
|
328
|
+
return { account, ...await integrationsCatalog(new GovernanceClient(config.apiUrl, token, config.tenantId)) };
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
317
334
|
function optionValue(flag) {
|
|
318
335
|
const index = args.indexOf(flag);
|
|
319
336
|
const value = index >= 0 ? args[index + 1] : undefined;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { transportFailure } from "./integrations.js";
|
|
5
|
+
import { CliError } from "./output.js";
|
|
4
6
|
export function configPath(env = process.env) {
|
|
5
7
|
return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "config.json");
|
|
6
8
|
}
|
|
@@ -12,7 +14,7 @@ export async function loadConfig(path = configPath()) {
|
|
|
12
14
|
catch (error) {
|
|
13
15
|
if (isMissing(error))
|
|
14
16
|
return undefined;
|
|
15
|
-
throw new Error("Isomorph could not read its saved connection. Run `isomorph connect <
|
|
17
|
+
throw new Error("Isomorph could not read its saved connection. Run `isomorph connect <work-email>` again.");
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
export async function saveConfig(config, path = configPath()) {
|
|
@@ -23,54 +25,91 @@ export async function saveConfig(config, path = configPath()) {
|
|
|
23
25
|
await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
24
26
|
await chmod(path, 0o600);
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
const EMAIL = /^[^\s@]+@([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)$/i;
|
|
29
|
+
/** The platform's refusals of a start lookup, as the CLI codes them; any other refusal is an outage. */
|
|
30
|
+
const REFUSALS = { tenant_unknown: "NOT_A_MEMBER", tenant_ambiguous: "TENANT_AMBIGUOUS" };
|
|
31
|
+
/**
|
|
32
|
+
* Connects to the company a work email belongs to, or to a start link.
|
|
33
|
+
*
|
|
34
|
+
* An email is never guessed at: the platform answers `/start/<domain>.json`
|
|
35
|
+
* from the same policies that admit the person at sign-in — the one company
|
|
36
|
+
* that admits the domain, or a 400 whose `error_description` already names
|
|
37
|
+
* the fix (ask IT, or run the printed command). Before 0.3.0 the CLI took the
|
|
38
|
+
* first domain label as the tenant id, so `fourierlabs.ai` looked for a company
|
|
39
|
+
* `fourierlabs` that does not exist (2026-09-14).
|
|
40
|
+
*/
|
|
41
|
+
export async function connect(startUrl, path = configPath(), fetchImpl = fetch) {
|
|
27
42
|
const input = startUrl.trim();
|
|
28
|
-
const
|
|
29
|
-
const
|
|
43
|
+
const domain = EMAIL.exec(input)?.[1]?.toLowerCase();
|
|
44
|
+
const url = domain ? new URL(`${platformOrigin()}/start/${domain}.json`) : startLink(input);
|
|
45
|
+
const response = await fetchImpl(url, { headers: { accept: "application/json" }, redirect: "error" })
|
|
46
|
+
.catch((error) => { throw new CliError("PLATFORM_UNREACHABLE", `Isomorph at ${url.host} could not be reached: ${transportFailure(error)}.`); });
|
|
47
|
+
const body = await response.json().catch(() => undefined);
|
|
48
|
+
if (!response.ok && isRecord(body) && typeof body.error_description === "string") {
|
|
49
|
+
// The server's sentence already names the fix. Its links are printed without
|
|
50
|
+
// their scheme: the failure printer strips every full URL (output.ts), and
|
|
51
|
+
// `connect` accepts a link without one. `tenants` (with the full links) rides
|
|
52
|
+
// along as the envelope's result for --json readers.
|
|
53
|
+
throw new CliError(REFUSALS[String(body.error)] ?? "PLATFORM_UNREACHABLE", body.error_description.replace(/https?:\/\//g, ""), undefined, undefined, Array.isArray(body.tenants) ? { tenants: body.tenants } : undefined);
|
|
54
|
+
}
|
|
55
|
+
// The console is a single-page app: any path it does not know is answered with its own page, status 200.
|
|
56
|
+
if (!domain && !isRecord(body) && /text\/html/i.test(response.headers.get("content-type") ?? ""))
|
|
57
|
+
throw new CliError("NOT_A_START_LINK", "That is the Isomorph console, not a connect link. Use your work email (`isomorph connect <email>`) or your company page (platform.isomorph.ai/t/<company>).");
|
|
58
|
+
if (!response.ok || !isRecord(body))
|
|
59
|
+
throw new CliError("PLATFORM_UNREACHABLE", `Isomorph answered ${response.status} instead of a company connection profile. Try again in a minute; if it keeps failing, Isomorph is down.`);
|
|
60
|
+
if (body.schema !== "harbour.tenant-start/1.0")
|
|
61
|
+
throw new Error("This is not an Isomorph connection profile.");
|
|
62
|
+
const config = {
|
|
63
|
+
schema: "isomorph.cli-config/1.0",
|
|
64
|
+
tenantId: String(body.tenantId ?? ""),
|
|
65
|
+
...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}),
|
|
66
|
+
mcpUrl: String(body.mcpUrl ?? ""),
|
|
67
|
+
...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
|
|
68
|
+
...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
|
|
69
|
+
};
|
|
70
|
+
if (!validConfig(config))
|
|
71
|
+
throw new Error("This is not the expected Isomorph connection profile.");
|
|
72
|
+
await saveConfig(config, path);
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
function platformOrigin() {
|
|
76
|
+
return process.env.ISOMORPH_PLATFORM_URL?.trim() || "https://platform.isomorph.ai";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The link as the person typed it, made fetchable: the console's company page
|
|
80
|
+
* (`/t/<company>`) is a start link too, a link without its scheme (as a browser
|
|
81
|
+
* bar or a printed refusal shows it) is one too, and the platform only answers
|
|
82
|
+
* over https — the plain `http://` form used to fail on the redirect.
|
|
83
|
+
*/
|
|
84
|
+
function startLink(input) {
|
|
30
85
|
let url;
|
|
31
86
|
try {
|
|
32
|
-
url = new URL(
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
throw new Error("Enter your work email or the company setup link from your Isomorph start page: `isomorph connect <work-email-or-start-url>`.");
|
|
87
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:/i.test(input) ? input : `https://${input}`);
|
|
36
88
|
}
|
|
89
|
+
catch { /* not a link at all */ }
|
|
90
|
+
if (!url?.hostname.includes("."))
|
|
91
|
+
throw new Error("Enter your work email or the company setup link from your Isomorph start page: `isomorph connect <work-email | company-start-url>`.");
|
|
37
92
|
if (!/^https?:$/.test(url.protocol))
|
|
38
93
|
throw new Error("The Isomorph connection URL must be an HTTPS URL.");
|
|
39
|
-
|
|
94
|
+
url.protocol = "https:";
|
|
95
|
+
const consolePage = /^\/t\/([a-z0-9][a-z0-9_-]*)\/?$/i.exec(url.pathname);
|
|
96
|
+
if (consolePage)
|
|
97
|
+
url.pathname = `/start/${consolePage[1].toLowerCase()}`;
|
|
98
|
+
if (!url.pathname.endsWith(".json"))
|
|
40
99
|
url.pathname = `${url.pathname.replace(/\/$/, "")}.json`;
|
|
41
|
-
|
|
42
|
-
let config;
|
|
43
|
-
try {
|
|
44
|
-
const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error" });
|
|
45
|
-
if (!response.ok)
|
|
46
|
-
throw new Error("Isomorph could not read this company connection profile.");
|
|
47
|
-
const body = await response.json();
|
|
48
|
-
if (!isRecord(body) || body.schema !== "harbour.tenant-start/1.0")
|
|
49
|
-
throw new Error("This is not an Isomorph connection profile.");
|
|
50
|
-
config = {
|
|
51
|
-
schema: "isomorph.cli-config/1.0",
|
|
52
|
-
tenantId: String(body.tenantId ?? ""),
|
|
53
|
-
mcpUrl: String(body.mcpUrl ?? ""),
|
|
54
|
-
...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
|
|
55
|
-
...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
|
|
56
|
-
};
|
|
57
|
-
if (!validConfig(config) || (guessedTenant && config.tenantId !== guessedTenant))
|
|
58
|
-
throw new Error("This is not the expected Isomorph connection profile.");
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
if (guessedTenant)
|
|
62
|
-
throw new Error("Isomorph could not find your company from that email. Ask for the company setup link from your Isomorph start page, then run `isomorph connect <company-start-url>`.");
|
|
63
|
-
throw error;
|
|
64
|
-
}
|
|
65
|
-
await saveConfig(config, path);
|
|
66
|
-
return config;
|
|
100
|
+
return url;
|
|
67
101
|
}
|
|
68
102
|
export function resolveConfig(env, saved) {
|
|
69
103
|
const mcpUrl = env.ISOMORPH_MCP_URL?.trim() || saved?.mcpUrl;
|
|
70
104
|
const tenantId = env.ISOMORPH_TENANT?.trim() || saved?.tenantId;
|
|
71
105
|
if (!mcpUrl || !tenantId)
|
|
72
106
|
return undefined;
|
|
73
|
-
|
|
107
|
+
// The saved name belongs to the saved tenant; an ISOMORPH_TENANT override names another company.
|
|
108
|
+
return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
|
|
109
|
+
}
|
|
110
|
+
/** The company as the CLI names it: `Fourier (fourier)`, or the id twice when the profile carried no name. */
|
|
111
|
+
export function companyLabel(config) {
|
|
112
|
+
return `${config.displayName ?? config.tenantId} (${config.tenantId})`;
|
|
74
113
|
}
|
|
75
114
|
/** `https://host/stage/mcp` -> `https://host/stage`: the dev routes live beside the MCP endpoint. */
|
|
76
115
|
export function apiUrlFromMcpUrl(mcpUrl) {
|
|
@@ -84,6 +123,7 @@ function validConfig(value) {
|
|
|
84
123
|
&& value.schema === "isomorph.cli-config/1.0"
|
|
85
124
|
&& typeof value.tenantId === "string"
|
|
86
125
|
&& value.tenantId.length > 0
|
|
126
|
+
&& (value.displayName === undefined || typeof value.displayName === "string")
|
|
87
127
|
&& typeof value.mcpUrl === "string"
|
|
88
128
|
&& /^https?:\/\//.test(value.mcpUrl)
|
|
89
129
|
&& (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's rulebook, in four modules. `isomorph agent-setup` writes each one as
|
|
3
|
+
* `<name>.md` beside the user-level `isomorph` skill (Claude Code and Codex),
|
|
4
|
+
* so the rules track the installed CLI the way the skill does; every other surface
|
|
5
|
+
* (the skill itself, the managed block in CLAUDE.md / AGENTS.md, the starter's
|
|
6
|
+
* README and source comments, the gate's refusals) points here instead of restating
|
|
7
|
+
* a rule. `core.md` is read before the first edit of any app; the other three are
|
|
8
|
+
* loaded only by an app that declares the thing they govern. Budgets and the
|
|
9
|
+
* no-duplication rule are enforced by tests/cli-guide-budget.test.ts.
|
|
10
|
+
*/
|
|
11
|
+
export const GUIDE_MODULES = {
|
|
12
|
+
core: `# Isomorph kit — core rules
|
|
13
|
+
|
|
14
|
+
## The SDK is the only door
|
|
15
|
+
|
|
16
|
+
\`@isomorph.ai/app-sdk\`, on the client exported by \`src/isomorph.client.ts\`, is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md) and \`isomorph.ai.chat\` (ai.md). Never open a database, bucket or company URL from browser code; never add another backend, auth library, deployment config, provider key or SDK. No secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\`, never \`.isomorph/local/\`.
|
|
17
|
+
|
|
18
|
+
## Identity
|
|
19
|
+
|
|
20
|
+
Isomorph SSO signs everyone in: no login forms, no roles or ids trusted from the browser, no public routes or wildcard exceptions. \`isomorph.identity.current()\` answers \`{ id, email }\` only — no display name; derive one from the email.
|
|
21
|
+
|
|
22
|
+
## Data (\`core.rls-owner-scoped\`)
|
|
23
|
+
|
|
24
|
+
Schema changes are SQL files in \`migrations/\`, applied by \`isomorph dev\` and \`isomorph check\`. Every table: \`ENABLE ROW LEVEL SECURITY\`, a policy, and \`GRANT\` of every verb a policy allows to \`harbour_app_gateway\` and no other role; the database gate names whatever breaks this. Row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\` (set by the gateway per request, with \`harbour.user_email\`), never in the browser. Two patterns:
|
|
25
|
+
|
|
26
|
+
- **Private table** — every policy scoped to \`current_setting('harbour.user_id', true)\`; the gate then asserts a second signed-in person cannot read, update or delete another person's row.
|
|
27
|
+
- **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to the identity setting.
|
|
28
|
+
|
|
29
|
+
The gate treats a table as private when any of its policies mentions \`harbour.user_id\` (or \`harbour.user_email\`), so a shared-read table must keep that setting out of its SELECT policy, or its cross-user read check fails.
|
|
30
|
+
|
|
31
|
+
## Files
|
|
32
|
+
|
|
33
|
+
\`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
|
|
34
|
+
|
|
35
|
+
## Realtime
|
|
36
|
+
|
|
37
|
+
\`isomorph.realtime.channel(name).on("postgres_changes", { event: "*" | "INSERT" | "UPDATE" | "DELETE", schema: "public", table }, payload => …).subscribe()\`; the payload carries \`eventType\`, \`new\` and \`old\`.
|
|
38
|
+
|
|
39
|
+
## Retained checks (\`core.journey-fk\`)
|
|
40
|
+
|
|
41
|
+
\`.isomorph/checks/\` holds one journey per capability the app's code uses plus a cross-user denial per private table. \`isomorph check\` generates them from \`migrations/\` and \`src/\` and deletes the ones the app no longer needs, so run it in the same edit that changes the app; never write or remove these files by hand. A generated check starts with \`// isomorph:generated\` and a digest of its body; edit one and it is yours — kept, no longer updated, never deleted — so delete it yourself in the same edit that removes the feature it covers. The pairing is two-way: the \`flow\` gate refuses a capability no check exercises and a check exercising something the code no longer does (\`flow.check-failed\`) — the most common reason a first deploy is refused. What cannot be generated (\`actions\`, \`realtime\`, a table with no migration) is named for you to write. No journey is generated for a table whose row needs a parent (a foreign key): write that table's journey yourself.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
- \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite behind one loopback origin; company calls use the \`isomorph login\` account.
|
|
46
|
+
- \`isomorph check --app-root . --json\` — types, build, then the pipeline's own kit gate locally (declaration, database gate, write probe with cross-user denial, journeys, operation coverage); report in \`.isomorph/local/check-report.json\`.
|
|
47
|
+
- \`isomorph productionise --app-root . …\` — saves and deploys the private preview; \`isomorph promote\` after the person has tried it.
|
|
48
|
+
|
|
49
|
+
## Error codes
|
|
50
|
+
|
|
51
|
+
- \`CONFIG_REQUIRED\`: no company connected — \`isomorph connect <work-email>\`.
|
|
52
|
+
- \`AUTH_REQUIRED\`: sign in — \`isomorph login\`.
|
|
53
|
+
- \`NOT_A_MEMBER\` / \`TENANT_AMBIGUOUS\`: no company, or more than one, admits that email domain — use the command it printed or IT's link.
|
|
54
|
+
- \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports and run it again on this exact code.
|
|
55
|
+
- \`APP_NOT_FOUND\`: the app is linked to another company — connect back to it with its start link, or clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.
|
|
56
|
+
- \`INTEGRATIONS_NOT_READY\`: IT has not approved a declared connection yet (integrations.md).
|
|
57
|
+
- \`AI_NOT_READY\`: IT has not enabled the company's AI setup yet (ai.md).`,
|
|
58
|
+
integrations: `# Company systems (Slack, Gmail, warehouse)
|
|
59
|
+
|
|
60
|
+
## Declare
|
|
61
|
+
|
|
62
|
+
Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare a connection only when the app really calls it, by the identifier the catalog lists, with only the operations it calls — each declared connection blocks the deploy until IT grants it, and an undeclared one cannot be requested (\`CONNECTION_NOT_DECLARED\`). Strict JSON, no comments.
|
|
63
|
+
|
|
64
|
+
\`\`\`json
|
|
65
|
+
"company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
|
|
66
|
+
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
## Catalog
|
|
70
|
+
|
|
71
|
+
Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if the object or column is absent, report that catalog gap instead of substituting a plausible name. A resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`: inspect the returned row keys; do not invent a schema or ask IT for one.
|
|
72
|
+
|
|
73
|
+
## Request
|
|
74
|
+
|
|
75
|
+
One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` files it now — submit the request yourself in the same turn, and never tell the person to ask IT before you have submitted the request.
|
|
76
|
+
|
|
77
|
+
## Status
|
|
78
|
+
|
|
79
|
+
READY: use it now. PENDING: say "IT has to approve this; the app works without it until then" and check later with \`isomorph integrations status --app-root . --json\`. Before a real integration test, read that status; show pending IT approval and missing consent separately, and test ready destinations independently. Retained checks that call \`isomorph.integrations.execute\` get the gate's fixture, locally and in the pipeline alike (nothing sent or read; the report says so); only \`isomorph check --integrations\` (reads) exercises real access.
|
|
80
|
+
|
|
81
|
+
## Call shape
|
|
82
|
+
|
|
83
|
+
On the exported client (\`src/isomorph.client.ts\`), always this exact shape and never a wrapper function (nor a generic string/unknown wrapper): the kit gate derives what the app uses from the direct call.
|
|
84
|
+
|
|
85
|
+
\`\`\`ts
|
|
86
|
+
const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
87
|
+
operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
88
|
+
});
|
|
89
|
+
\`\`\`
|
|
90
|
+
|
|
91
|
+
Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (\`VIEW_READ_MAX_LIMIT\`); larger is refused with \`INPUT_INVALID\`, so page instead.
|
|
92
|
+
|
|
93
|
+
## Slack modes
|
|
94
|
+
|
|
95
|
+
\`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's \`presentation.displayName\` (optional \`iconEmoji\`), which IT approves — leave it out to post as Isomorph AI itself; every app-mode post ends "Posted by <app> on Isomorph". \`"identity": "user"\` posts as the person after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` — offer Connect again. Never pretend one is the other. The declaration is the mode requested; the call names the approved mode it runs under (\`mode: "app"\` or \`"user"\`): optional with one approved mode, required with both (\`MODE_REQUIRED\`), and an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel — say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first". On a request: the resource is not on the connection yet — IT adds it in the console under Controls & integrations, then you run the same request again.
|
|
96
|
+
|
|
97
|
+
## Consent
|
|
98
|
+
|
|
99
|
+
Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts only from that person's control, never on page load; missing consent never falls back to another account. App-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect — it is refused as unapproved user access and puts nothing in IT's queue.
|
|
100
|
+
|
|
101
|
+
## Sends
|
|
102
|
+
|
|
103
|
+
A send (\`slack.message.post\`, \`gmail.message.send\`) runs 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 it); never from an effect, a browser timer or a check. After a partial send, retry only the failed destination with its original key. Scheduled sends: jobs.md.`,
|
|
104
|
+
ai: `# Governed AI
|
|
105
|
+
|
|
106
|
+
AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. 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 not enabled an AI provider yet: say so in one line and keep the app working without AI.
|
|
107
|
+
|
|
108
|
+
\`\`\`ts
|
|
109
|
+
const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
The starter calls no AI; add the one call when the person asks. \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it (and deletes it when the call goes): locally the journey makes a real governed call through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
|
|
113
|
+
jobs: `# Scheduled jobs
|
|
114
|
+
|
|
115
|
+
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data and safe fixtures, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
|
|
116
|
+
|
|
117
|
+
The handler acts as the app, so only app-mode operations (\`slack.message.post\` declared \`"identity": "app"\`) may run there; user-mode Slack and Gmail need a person and cannot. A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
|
|
118
|
+
|
|
119
|
+
Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."`
|
|
120
|
+
};
|
|
@@ -55,7 +55,7 @@ export class GovernanceClient {
|
|
|
55
55
|
* `fetch failed` and nothing else, so what was wrong — and that nothing about the
|
|
56
56
|
* app was — could not be told from the output.
|
|
57
57
|
*/
|
|
58
|
-
function transportFailure(error) {
|
|
58
|
+
export function transportFailure(error) {
|
|
59
59
|
const cause = (error instanceof Error ? error.cause ?? error : error);
|
|
60
60
|
const words = (failure) => { const { message } = (failure ?? {}); return typeof message === "string" ? message.trim() : ""; };
|
|
61
61
|
const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
|
|
@@ -359,6 +359,11 @@ export async function integrationsCatalog(client) {
|
|
|
359
359
|
}
|
|
360
360
|
const PROVIDER_WORDS = { slack: "Slack", gmail: "Gmail" };
|
|
361
361
|
const INSTALLATION_WORDS = { installed: "bot installed", not_installed: "bot not installed yet: IT installs it in the Isomorph console", reconnect_required: "bot needs reconnecting: IT does that in the Isomorph console" };
|
|
362
|
+
/** What IT has set up, for the line after `login`: `IT has set up: Company Slack (bot installed), Gmail.` — or that nothing is, and that the app works without it. */
|
|
363
|
+
export function companySystemsLine(connections) {
|
|
364
|
+
const systems = connections.map(entry => `${entry.displayName}${entry.installation && INSTALLATION_WORDS[entry.installation] ? ` (${INSTALLATION_WORDS[entry.installation]})` : ""}`).join(", ");
|
|
365
|
+
return systems ? `IT has set up: ${systems}.` : "IT has not set up any company systems here yet (Slack, Gmail, warehouse); the app works without them.";
|
|
366
|
+
}
|
|
362
367
|
/** One block per connection: the identifier to declare, what it is, the allowed operations with their identity, and the approved names per environment. */
|
|
363
368
|
export function renderIntegrationsCatalog(result) {
|
|
364
369
|
if (!result.connections.length)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.
|
|
3
|
+
"kitVersion": "0.3.0",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
6
|
"version": "1.2.0",
|
|
@@ -8,25 +8,25 @@ export const PUBLISHED_KIT_BUNDLE = {
|
|
|
8
8
|
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49"
|
|
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:cdb494a98ddcab21a42b9cb8089d077007da1d4f178458cef94b3aa9ad6e1a11",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:34d5e175a6346636a25fe8c6f9b730e93d67ef1e3361b0349c31f2a2da3322a8"
|
|
13
13
|
},
|
|
14
14
|
"nativeRuntime": {
|
|
15
15
|
"darwinArm64": {
|
|
16
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
17
|
-
"sha256": "
|
|
16
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077",
|
|
17
|
+
"sha256": "a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077"
|
|
18
18
|
},
|
|
19
19
|
"linuxX64": {
|
|
20
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
21
|
-
"sha256": "
|
|
20
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc",
|
|
21
|
+
"sha256": "43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc"
|
|
22
22
|
},
|
|
23
23
|
"windowsX64": {
|
|
24
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd",
|
|
25
|
+
"sha256": "540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
29
|
-
"fingerprint": "
|
|
29
|
+
"fingerprint": "e1bd2932cd6b9f521734877d6b225ea609408bf12b5ff2d84461e585baa10e80"
|
|
30
30
|
},
|
|
31
31
|
"declarationSchema": "isomorph.app-integrations/2.0"
|
|
32
32
|
};
|
|
@@ -140,7 +140,7 @@ export async function planRetainedChecks(root, schema, declared) {
|
|
|
140
140
|
continue;
|
|
141
141
|
const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : capability === "ai" ? aiJourney(files) : undefined;
|
|
142
142
|
if (!generic) {
|
|
143
|
-
plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Isomorph writes journeys for data, files, telemetry and ai only — write \`.isomorph/checks/${capability}-journey.mjs\` yourself`);
|
|
143
|
+
plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Isomorph writes journeys for data, files, telemetry and ai only — write \`.isomorph/checks/${capability}-journey.mjs\` yourself${capability === "realtime" ? " (the filter and payload shape: core.md in the isomorph skill)" : ""}`);
|
|
144
144
|
continue;
|
|
145
145
|
}
|
|
146
146
|
add(capability, `${capability}-journey.mjs`, generic);
|
|
@@ -254,7 +254,7 @@ function crossUserCheck(table, schema, row, sources) {
|
|
|
254
254
|
"",
|
|
255
255
|
"// 1. SELECT by key as the second person: no row.",
|
|
256
256
|
`const { data: seen } = await other.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
|
|
257
|
-
`assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users");`,
|
|
257
|
+
`assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users — rule core.rls-owner-scoped (core.md in the isomorph skill)");`,
|
|
258
258
|
"",
|
|
259
259
|
"// 2. UPDATE and 3. DELETE by key as the second person: refused (403) or no row affected.",
|
|
260
260
|
"const denied = async (verb, operation) => {",
|
|
@@ -331,7 +331,7 @@ function rowShape(schema, needsUpdate) {
|
|
|
331
331
|
const required = writable.filter(column => column.notNull && !column.hasDefault);
|
|
332
332
|
const unwritable = required.find(column => column.references) ?? required.find(column => !fillExpression(column));
|
|
333
333
|
if (unwritable)
|
|
334
|
-
return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent" : "has a type Isomorph cannot invent a value for"}` };
|
|
334
|
+
return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent — rule core.journey-fk (core.md in the isomorph skill)" : "has a type Isomorph cannot invent a value for"}` };
|
|
335
335
|
const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
|
|
336
336
|
if (!marker)
|
|
337
337
|
return { blocked: "it has no free text or number column a check could write a unique value into and find the row by" };
|
|
@@ -9,8 +9,9 @@ export { MANAGED_END, MANAGED_START };
|
|
|
9
9
|
/**
|
|
10
10
|
* Creates the starter in an empty directory, or adds the missing kit files to
|
|
11
11
|
* an existing Vite + React app. User files are never overwritten: a path that
|
|
12
|
-
* exists is reported as kept. Instruction files get a managed block
|
|
13
|
-
* the user-level agent guide is installed so the
|
|
12
|
+
* exists is reported as kept. Instruction files get a managed block that points at the
|
|
13
|
+
* rules, and the user-level agent guide (the rules themselves) is installed so the
|
|
14
|
+
* agents know the kit from any folder.
|
|
14
15
|
*/
|
|
15
16
|
export async function initKit(root, bundle, options = {}) {
|
|
16
17
|
await mkdir(root, { recursive: true });
|
|
@@ -47,7 +48,6 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
47
48
|
await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
|
|
48
49
|
for (const file of ["CLAUDE.md", "AGENTS.md"])
|
|
49
50
|
await appendManaged(root, file, managedBlock(), result, "\n\n", MANAGED_START, MANAGED_END);
|
|
50
|
-
await write(".claude/skills/isomorph-kit/SKILL.md", SKILL_FILE);
|
|
51
51
|
const previous = await readKitLock(root);
|
|
52
52
|
if (!previous) {
|
|
53
53
|
await writeKitLock(root, newKitLock(bundle, options.tenantId ?? ""));
|
|
@@ -76,9 +76,16 @@ const LEGACY = {
|
|
|
76
76
|
end: "<!-- harbour:kit:end -->",
|
|
77
77
|
generatedPrefix: "// harbour:generated sha256:",
|
|
78
78
|
gitignoreLine: ".harbour/local/",
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The project skills older CLIs wrote — `harbour-kit` up to 0.1.x, `isomorph-kit` up
|
|
81
|
+
* to 0.2.2 — each under one frontmatter name and one opening line, with a body that
|
|
82
|
+
* changed from release to release. Since 0.3.0 the rules live beside the user-level
|
|
83
|
+
* skill and no project skill is written.
|
|
84
|
+
*/
|
|
85
|
+
skills: [
|
|
86
|
+
{ path: ".claude/skills/harbour-kit/SKILL.md", frontmatter: "name: harbour-kit", opening: 'Follow the "Harbour development kit" block' },
|
|
87
|
+
{ path: ".claude/skills/isomorph-kit/SKILL.md", frontmatter: "name: isomorph-kit", opening: 'Follow the "Isomorph development kit" block' }
|
|
88
|
+
],
|
|
82
89
|
sdkPackage: "@harbour/app-sdk",
|
|
83
90
|
/** The SDK's exported type names, renamed with the package. */
|
|
84
91
|
typeNames: /\bHarbour(User|ErrorCategory|Error|Client|Result|CompatSource)\b/g,
|
|
@@ -144,32 +151,35 @@ async function migrateLegacyKit(root, bundle, result) {
|
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
153
|
/**
|
|
147
|
-
* Deletes the project
|
|
148
|
-
* layout migration moved under 0.2.0 still has
|
|
149
|
-
* is the kit's by shape, not by
|
|
150
|
-
*
|
|
151
|
-
* author rewrote is kept, and
|
|
154
|
+
* Deletes the project skills older CLIs wrote, on every `--upgrade` — an app the
|
|
155
|
+
* layout migration moved under 0.2.0 still has the old one, and every app set up
|
|
156
|
+
* before 0.3.0 has the `isomorph-kit` one. A file is the kit's by shape, not by
|
|
157
|
+
* bytes: each release wrote a different body under the same frontmatter name and
|
|
158
|
+
* the same opening line, so one whose body an author rewrote is kept, and
|
|
159
|
+
* reported, as theirs.
|
|
152
160
|
*/
|
|
153
161
|
async function retireLegacySkill(root, result) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
for (const skill of LEGACY.skills) {
|
|
163
|
+
const path = join(root, skill.path);
|
|
164
|
+
const text = await readFile(path, "utf8").catch(() => undefined);
|
|
165
|
+
if (text === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
if (!isLegacyKitSkill(text, skill)) {
|
|
168
|
+
result.kept.push(skill.path);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
await rm(path);
|
|
172
|
+
await rmdir(dirname(path)).catch(() => undefined);
|
|
173
|
+
result.updated.push(skill.path);
|
|
161
174
|
}
|
|
162
|
-
await rm(path);
|
|
163
|
-
await rmdir(dirname(path)).catch(() => undefined);
|
|
164
|
-
result.updated.push(LEGACY.skill);
|
|
165
175
|
}
|
|
166
|
-
/** Frontmatter naming
|
|
167
|
-
function isLegacyKitSkill(text) {
|
|
176
|
+
/** Frontmatter naming the kit's skill, and the opening every release wrote as the body's first line. */
|
|
177
|
+
function isLegacyKitSkill(text, skill) {
|
|
168
178
|
const lines = text.split(/\r?\n/);
|
|
169
179
|
const close = lines.indexOf("---", 1);
|
|
170
180
|
if (lines[0] !== "---" || close < 0)
|
|
171
181
|
return false;
|
|
172
|
-
return lines.slice(1, close).includes(
|
|
182
|
+
return lines.slice(1, close).includes(skill.frontmatter) && (lines.slice(close + 1).find(line => line.trim()) ?? "").startsWith(skill.opening);
|
|
173
183
|
}
|
|
174
184
|
/**
|
|
175
185
|
* Rewrites what the old kit named in the app's own files — `package.json`,
|
|
@@ -236,48 +246,26 @@ const exists = (path) => stat(path).then(() => true, () => false);
|
|
|
236
246
|
const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
|
|
237
247
|
// ---- Templates -----------------------------------------------------------------
|
|
238
248
|
const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/"].join("\n");
|
|
239
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* The per-app block: it says where the rules are and when to read each, and nothing
|
|
251
|
+
* else — the rules themselves live in the modules `agent-setup` writes beside the
|
|
252
|
+
* `isomorph` skill (`guide.ts`), so this block never drifts from them.
|
|
253
|
+
*/
|
|
240
254
|
export function managedBlock() {
|
|
241
255
|
return [
|
|
242
256
|
MANAGED_START,
|
|
243
257
|
"## Isomorph development kit",
|
|
244
258
|
"",
|
|
245
|
-
"This app runs on Isomorph.
|
|
259
|
+
"This app runs on Isomorph. Its rules live in the `isomorph` skill (Claude Code: `~/.claude/skills/isomorph/`, Codex: `~/.codex/skills/isomorph/`); if that skill is not installed, run `npx -y @isomorph.ai/cli agent-setup`.",
|
|
246
260
|
"",
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
"
|
|
251
|
-
"
|
|
252
|
-
"- 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.",
|
|
253
|
-
"- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `isomorph.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.",
|
|
254
|
-
"- A retained check that calls `isomorph.integrations.execute` is answered, under `isomorph check` and in the deployment pipeline alike, by the gate's fixture: the contract's result shape for a connection, operation and resource the app declared (one canned Slack message, one canned Gmail thread, a warehouse view with no rows), a refusal with the platform's own code for anything undeclared, and nothing is ever sent or read. The passed check says so in the report; real access is exercised only by `isomorph check --integrations` (reads) and the preview's own smoke test.",
|
|
255
|
-
"- AI goes through `isomorph.ai` only — `isomorph.ai.chat({ messages, maxTokens })` on the client exported by `src/isomorph.client.ts`, never through a wrapper function (the gate reads only the direct call) — behind an explicit control the person presses (never on load, in an effect or a timer). Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key and IT sees every call. The starter calls no AI; add the one call when the person asks for it (README.md has the \"Summarise my notes\" example) and `isomorph check` writes `.isomorph/checks/ai-journey.mjs` for it. A refusal with code `AI_NOT_ENABLED` means IT has not enabled an AI provider yet; the app must still work without AI.",
|
|
256
|
-
"- Authentication is owned by Isomorph 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)`.",
|
|
257
|
-
"- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
|
|
258
|
-
"- No secrets, tokens, `.env` values or fetched company content in source. `.isomorph/local/` is ignored and never committed; `.isomorph/integrations.json` and `.isomorph/kit.lock.json` are committed.",
|
|
259
|
-
"- Schema changes are SQL files in `migrations/`, applied by `isomorph dev` and `isomorph check`. Every table: ENABLE ROW LEVEL SECURITY + a policy; GRANT every verb a policy allows to `harbour_app_gateway` and to no other role — `isomorph check` runs the pipeline's database gate and names any table/policy/grant that breaks this, with the fix.",
|
|
260
|
-
"- `.isomorph/checks/` holds the app's retained journeys: one per capability the app's own code uses (`isomorph.data.*`, `isomorph.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `isomorph 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: `isomorph 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: `isomorph check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `isomorph check` and the deployment pipeline replay `.isomorph/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.",
|
|
261
|
-
"- A generated check starts with a `// isomorph:generated` line carrying a digest of its own body; that is how `isomorph check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Isomorph 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 `isomorph.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `isomorph check` rewrites and deletes to match — `.isomorph/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.",
|
|
262
|
-
"- Commands: `isomorph dev --app-root .` (local runtime), `isomorph 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), `isomorph integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `isomorph integrations request <connection> --reason <text> --app-root .`, `isomorph integrations status --app-root .`, `isomorph productionise --app-root .`. Company calls in `dev` use the account from `isomorph login`; the local fixture user is only the app's identity.",
|
|
263
|
-
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/isomorph-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 `isomorph agent-setup` (Claude Code: `~/.claude/skills/isomorph`, Codex: `~/.codex/skills/isomorph`); use it for every request about this app.",
|
|
261
|
+
"1. Read `core.md` beside the skill's `SKILL.md` before the first edit.",
|
|
262
|
+
"2. Before you add a key under `\"connections\"` in `.isomorph/integrations.json`, read `integrations.md` there.",
|
|
263
|
+
"3. Before you call `isomorph.ai.*`, read `ai.md`; before you create `jobs/`, read `jobs.md`.",
|
|
264
|
+
"",
|
|
265
|
+
"Commands: `isomorph dev --app-root .` runs the app locally; `isomorph check --app-root . --json` runs the gates the deployment pipeline runs, before every hand-off; `isomorph productionise --app-root .` deploys the private preview.",
|
|
264
266
|
MANAGED_END
|
|
265
267
|
].join("\n");
|
|
266
268
|
}
|
|
267
|
-
const SKILL_FILE = `---
|
|
268
|
-
name: isomorph-kit
|
|
269
|
-
description: Build, run and check this Isomorph app with the Isomorph CLI (dev, check, integrations, productionise).
|
|
270
|
-
---
|
|
271
|
-
|
|
272
|
-
Follow the "Isomorph development kit" block in CLAUDE.md / AGENTS.md. Workflow:
|
|
273
|
-
|
|
274
|
-
1. \`isomorph dev --app-root .\` starts Postgres, storage and one Isomorph gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
|
|
275
|
-
2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.isomorph/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 5). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
|
|
276
|
-
3. \`.isomorph/checks/\` is generated, not written by hand: \`isomorph check\` reads \`migrations/\` and \`src/\` and writes one retained journey per capability the app's own code uses (plus a cross-user denial per owner-scoped table), deleting the ones the app no longer needs — so run it in the same edit that changes the app instead of adding or deleting these files yourself. It reports anything it cannot generate (\`actions\`, \`realtime\`, a table with no migration) for you to write. Edit a generated check and it becomes yours: Isomorph keeps it, stops managing it and never removes it, so deleting it when the feature goes is then your job. \`isomorph check\` and the deployment pipeline replay these checks against a real App Gateway and refuse the app (\`flow.check-failed\`) in both directions — a capability no check exercises, and a check that exercises something the code no longer does.
|
|
277
|
-
4. \`isomorph check --app-root .\` before every hand-off; read \`.isomorph/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only); a retained check that calls \`isomorph.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`isomorph.ai.chat\`) is exercised for real through the development route while \`isomorph dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
|
|
278
|
-
5. \`isomorph integrations request <connection> --reason "<why>" --app-root .\` asks IT for access now — one request per connection, approved once for every environment; \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you. Pending is not ready.
|
|
279
|
-
6. \`isomorph productionise --app-root .\` saves and deploys the preview; \`isomorph promote\` after the person has tested it.
|
|
280
|
-
`;
|
|
281
269
|
/**
|
|
282
270
|
* Kit infrastructure: structural files every kit app needs, on every path —
|
|
283
271
|
* `init` in an empty directory, `init` over an existing Vite + React app, and
|
|
@@ -289,11 +277,11 @@ function kitFiles() {
|
|
|
289
277
|
// No connection. The starter calls no company system, and a connection the
|
|
290
278
|
// app does not call is a deploy that never happens: `isomorph productionise`
|
|
291
279
|
// files the access request for every declared connection and refuses to
|
|
292
|
-
// start until IT has approved it. One is added when the app really calls it —
|
|
293
|
-
// worked Slack and warehouse examples are in
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
280
|
+
// start until IT has approved it. One is added when the app really calls it —
|
|
281
|
+
// the steps and the worked Slack and warehouse examples are in integrations.md
|
|
282
|
+
// beside the `isomorph` skill (guide.ts). They are not in this file: it is read
|
|
283
|
+
// with JSON.parse here and again by the pipeline's source intake, so it cannot
|
|
284
|
+
// carry comments.
|
|
297
285
|
".isomorph/integrations.json": `${JSON.stringify(emptyDeclaration(), null, 2)}\n`
|
|
298
286
|
};
|
|
299
287
|
}
|
|
@@ -335,76 +323,22 @@ function starterFiles(bundle) {
|
|
|
335
323
|
"README.md": STARTER_README
|
|
336
324
|
};
|
|
337
325
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
Created by \`isomorph init\`. Run \`isomorph dev --app-root .\` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.
|
|
341
|
-
|
|
342
|
-
\`.isomorph/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.
|
|
343
|
-
|
|
344
|
-
The directory is empty until the first \`isomorph check\`, which writes those three: it replays \`migrations/\` into a disposable database, reads this app's own tables back out of it, and generates a journey for each table and capability \`src/App.tsx\` uses. Every later \`isomorph check\` keeps them in step — adding a journey when the app starts using a table or capability, removing one when the app stops. Each begins with a \`// isomorph:generated\` line carrying a digest of its own body — edit a check and Isomorph keeps your version, stops updating it and never removes it, and it is yours to maintain from then on.
|
|
345
|
-
|
|
346
|
-
The checks and the code are one pair, and \`isomorph check\` and the deployment pipeline's \`flow\` gate enforce the pair in both directions against a real App Gateway. Both directions refuse the deploy:
|
|
326
|
+
/** The starter's README, for the people who open the folder: what it is, the three commands, and where the rules are. Rule text lives in the `isomorph` skill only. */
|
|
327
|
+
export const STARTER_README = `# Isomorph app
|
|
347
328
|
|
|
348
|
-
|
|
349
|
-
- **A check with no capability.** They refuse an app whose retained checks no longer pass against its own code, so a feature you delete or replace means deleting or rewriting its check in the same edit. Running \`isomorph check\` is that edit for a generated check: replace the \`notes\` table with your own and it rewrites both notes checks for the new table; remove the "Private files" section — the only code here that calls \`isomorph.files.*\` — and it deletes \`.isomorph/checks/files-journey.mjs\` for you. A check you have edited is not Isomorph's to remove, so \`rm .isomorph/checks/files-journey.mjs\` right then yourself; left behind, it exercises a capability the app no longer has and the deploy is refused with \`flow.check-failed: files-journey.mjs: exit status 1\`.
|
|
329
|
+
Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in, data, files and company systems come from the platform through \`@isomorph.ai/app-sdk\`, so the app has no backend of its own.
|
|
350
330
|
|
|
351
|
-
##
|
|
331
|
+
## Three commands
|
|
352
332
|
|
|
353
|
-
|
|
333
|
+
- \`isomorph dev --app-root .\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
|
|
334
|
+
- \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); read \`.isomorph/local/check-report.json\` afterwards.
|
|
335
|
+
- \`isomorph productionise --app-root .\` — deploys a private preview for the people you name; \`isomorph promote\` makes it live for everyone once they have tried it.
|
|
354
336
|
|
|
355
|
-
|
|
356
|
-
import { isomorph, errorCode } from "./isomorph.client";
|
|
337
|
+
## Where the rules are
|
|
357
338
|
|
|
358
|
-
|
|
359
|
-
try {
|
|
360
|
-
const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
361
|
-
setSummary(reply.content);
|
|
362
|
-
} catch (error) {
|
|
363
|
-
setError(errorCode(error) === "AI_NOT_ENABLED" ? "AI is not enabled for this company yet; ask IT to connect a provider." : "The summary could not be produced.");
|
|
364
|
-
}
|
|
365
|
-
};
|
|
366
|
-
// <button type="button" onClick={() => void summarise()}>Summarise my notes</button>
|
|
367
|
-
\`\`\`
|
|
368
|
-
|
|
369
|
-
That one call is the declaration: \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it and the deployment registers the \`ai\` capability. Locally the journey makes a real governed call through the Isomorph development route with your \`isomorph login\` (\`AI_NOT_ENABLED\` means IT has not enabled a provider yet); in the pipeline it is answered by a canned completion and reported as not tested. Remove the call and the next \`isomorph check\` deletes the journey.
|
|
370
|
-
|
|
371
|
-
## Adding a company system (Slack, Gmail, a warehouse view)
|
|
372
|
-
|
|
373
|
-
\`.isomorph/integrations.json\` ships with no connections:
|
|
374
|
-
|
|
375
|
-
\`\`\`json
|
|
376
|
-
{ "schema": "isomorph.app-integrations/2.0", "connections": {} }
|
|
377
|
-
\`\`\`
|
|
378
|
-
|
|
379
|
-
That is deliberate. Every declared connection blocks the deploy until IT grants it — \`isomorph 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.
|
|
380
|
-
|
|
381
|
-
Add one in two steps, when the app really calls it:
|
|
382
|
-
|
|
383
|
-
1. Declare the connection and only the operations the app calls, under \`connections\`. Slack:
|
|
384
|
-
|
|
385
|
-
\`\`\`json
|
|
386
|
-
"company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote", "iconEmoji": ":sandwich:" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
387
|
-
\`\`\`
|
|
388
|
-
|
|
389
|
-
\`slack.message.post\` is posted either as the app (\`"identity": "app"\`: the company's Slack bot, Isomorph AI, under the \`presentation\` name IT approves — leave \`presentation\` out to post as Isomorph AI itself; every app-mode post ends with "Posted by <app> on Isomorph") 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 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 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. Either way the bot must be in the channel: someone runs \`/invite @Isomorph AI\` there once, or the post is refused with \`RESOURCE_NOT_APPROVED\`.
|
|
390
|
-
|
|
391
|
-
A warehouse view:
|
|
392
|
-
|
|
393
|
-
\`\`\`json
|
|
394
|
-
"sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
395
|
-
\`\`\`
|
|
396
|
-
|
|
397
|
-
2. Ask IT for access: one request per connection, and IT approves it once for every environment (development, preview and production together). \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<why>" --app-root .\` files it now. \`isomorph integrations status --app-root .\` says where each connection stands in every environment; PENDING is not ready, and \`isomorph productionise\` will not deploy until IT has approved every declared connection.
|
|
398
|
-
|
|
399
|
-
Then call it from the app on the client exported by \`src/isomorph.client.ts\` — always this exact shape, never a wrapper function, because the kit gate derives what the app uses from it:
|
|
400
|
-
|
|
401
|
-
\`\`\`ts
|
|
402
|
-
const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
403
|
-
operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
404
|
-
});
|
|
405
|
-
\`\`\`
|
|
339
|
+
The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` installs for Claude Code (\`~/.claude/skills/isomorph/\`) and Codex (\`~/.codex/skills/isomorph/\`): \`core.md\` (identity, data, files, realtime, retained checks, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md says when to read each, and \`init --upgrade\` keeps that block current.
|
|
406
340
|
|
|
407
|
-
|
|
341
|
+
\`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start.
|
|
408
342
|
`;
|
|
409
343
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
410
344
|
import react from "@vitejs/plugin-react";
|
|
@@ -429,24 +363,9 @@ const ISOMORPH_CLIENT = `import { createClient } from "@isomorph.ai/app-sdk";
|
|
|
429
363
|
// the same calls work locally (isomorph dev) and in preview/production.
|
|
430
364
|
export const isomorph = createClient();
|
|
431
365
|
|
|
432
|
-
//
|
|
433
|
-
// isomorph.integrations.execute
|
|
434
|
-
//
|
|
435
|
-
// what the app uses from exactly these calls; a wrapper would hide them.
|
|
436
|
-
//
|
|
437
|
-
// Gmail sends plain text as the signed-in person, after IT approval and personal consent.
|
|
438
|
-
// Inside an authorized Send handler (use the catalog's actual connection and mailbox):
|
|
439
|
-
// await isomorph.integrations.execute("company-gmail", {
|
|
440
|
-
// operation: "gmail.message.send", resource: "inbox",
|
|
441
|
-
// input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
|
|
442
|
-
// idempotencyKey: crypto.randomUUID()
|
|
443
|
-
// });
|
|
444
|
-
//
|
|
445
|
-
// Governed AI through the platform's LLM gateway: the app never holds a provider key and
|
|
446
|
-
// IT sees every call. The starter calls no AI; when the person asks for it, add the one
|
|
447
|
-
// call — isomorph.ai.chat({ messages, maxTokens }) — behind an explicit control they press,
|
|
448
|
-
// never on load. README.md has the "Summarise my notes" example. An IsomorphError whose
|
|
449
|
-
// errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
|
|
366
|
+
// Call the SDK's namespaces on this client directly (isomorph.data.from, isomorph.files.*,
|
|
367
|
+
// isomorph.integrations.execute, isomorph.ai.chat). Rules: core.md in the isomorph skill;
|
|
368
|
+
// integrations.md and ai.md for company systems and AI.
|
|
450
369
|
|
|
451
370
|
export function errorCode(error: unknown): string {
|
|
452
371
|
const details = (error as { details?: { code?: string } } | undefined)?.details;
|
|
@@ -491,32 +410,9 @@ export function App() {
|
|
|
491
410
|
const remove = async (note: Note) => { await isomorph.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
|
|
492
411
|
const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); }; // .isomorph/checks/files-journey.mjs
|
|
493
412
|
|
|
494
|
-
// Company systems are not part of the starter: .isomorph/integrations.json declares
|
|
495
|
-
// nothing, so nothing
|
|
496
|
-
//
|
|
497
|
-
// 1. Declare the connection and only the operations this app calls, under
|
|
498
|
-
// "connections" in .isomorph/integrations.json — every declared connection blocks
|
|
499
|
-
// the deploy until IT grants it:
|
|
500
|
-
// "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
501
|
-
// "company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
|
|
502
|
-
// (slack.message.post "identity": "app" posts as the company's Slack bot, Isomorph AI, under the
|
|
503
|
-
// IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
|
|
504
|
-
// A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
|
|
505
|
-
// 2. Request access — one request per connection, which IT approves once for every
|
|
506
|
-
// environment; isomorph dev and isomorph productionise file it for you, or file it now with
|
|
507
|
-
// isomorph integrations request sales-warehouse --reason "<why>" --app-root . — then uncomment
|
|
508
|
-
// (the isomorph client is already imported from "./isomorph.client"):
|
|
509
|
-
// const report = await isomorph.integrations.execute("sales-warehouse", {
|
|
510
|
-
// operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
511
|
-
// });
|
|
512
|
-
// 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
|
|
513
|
-
// fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
|
|
514
|
-
//
|
|
515
|
-
// Governed AI is not part of the starter either. When the person asks for it, add ONE
|
|
516
|
-
// call isomorph.ai.chat on the client from "./isomorph.client" behind a control they press (README.md
|
|
517
|
-
// "Adding AI"), never an OpenAI/Anthropic key or SDK; isomorph check then writes
|
|
518
|
-
// .isomorph/checks/ai-journey.mjs for it, and deletes it again if the call goes:
|
|
519
|
-
// const summary = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
413
|
+
// Company systems and AI are not part of the starter: .isomorph/integrations.json declares
|
|
414
|
+
// nothing and no isomorph.ai call exists, so nothing here waits on IT. Rules for adding
|
|
415
|
+
// either: integrations.md and ai.md in the isomorph skill.
|
|
520
416
|
|
|
521
417
|
return (
|
|
522
418
|
<main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
|
|
@@ -524,10 +420,9 @@ export function App() {
|
|
|
524
420
|
<p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
|
|
525
421
|
{error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
|
|
526
422
|
|
|
527
|
-
{/* Notes — paired with .isomorph/checks/notes-journey.mjs
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
\`isomorph check\`: it rewrites both checks for the new table in that same edit. */}
|
|
423
|
+
{/* Notes — paired with .isomorph/checks/notes-journey.mjs and notes-cross-user.mjs, generated
|
|
424
|
+
from this section and migrations/0001_notes.sql; replace the table and \`isomorph check\`
|
|
425
|
+
rewrites both. Rules: core.md in the isomorph skill (data). */}
|
|
531
426
|
<section>
|
|
532
427
|
<h2>Notes</h2>
|
|
533
428
|
<form onSubmit={event => { event.preventDefault(); void addNote(); }}>
|
|
@@ -544,12 +439,10 @@ export function App() {
|
|
|
544
439
|
</ul>
|
|
545
440
|
</section>
|
|
546
441
|
|
|
547
|
-
{/* Private files — the only code
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
App Gateway and refuses an app whose checks exercise a capability its code no longer
|
|
552
|
-
has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
|
|
442
|
+
{/* Private files — the only code here that calls isomorph.files.*, and so the only reason
|
|
443
|
+
.isomorph/checks/files-journey.mjs exists: delete this section and \`isomorph check\` deletes
|
|
444
|
+
that check too (one you have edited is yours to delete, in the same edit). Rules: core.md
|
|
445
|
+
in the isomorph skill (retained checks). */}
|
|
553
446
|
<section>
|
|
554
447
|
<h2>Private files</h2>
|
|
555
448
|
<input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.3.0-rc.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.1",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"harbour": {
|
|
38
38
|
"kitBundle": {
|
|
39
39
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
40
|
-
"version": "0.
|
|
40
|
+
"version": "0.3.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|