@isomorph.ai/cli 0.2.0
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 +55 -0
- package/dist/packages/harbour-cli/src/agent-setup.js +235 -0
- package/dist/packages/harbour-cli/src/app-schema.js +142 -0
- package/dist/packages/harbour-cli/src/auth.js +197 -0
- package/dist/packages/harbour-cli/src/check.js +317 -0
- package/dist/packages/harbour-cli/src/cli.js +321 -0
- package/dist/packages/harbour-cli/src/config.js +97 -0
- package/dist/packages/harbour-cli/src/dev.js +125 -0
- package/dist/packages/harbour-cli/src/forwarder.js +168 -0
- package/dist/packages/harbour-cli/src/integrations.js +385 -0
- package/dist/packages/harbour-cli/src/jobs.js +53 -0
- package/dist/packages/harbour-cli/src/kit-bundle.js +41 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +32 -0
- package/dist/packages/harbour-cli/src/kit.js +149 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +526 -0
- package/dist/packages/harbour-cli/src/operations.js +382 -0
- package/dist/packages/harbour-cli/src/output.js +273 -0
- package/dist/packages/harbour-cli/src/productionise.js +508 -0
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +121 -0
- package/dist/packages/harbour-cli/src/retained-checks.js +435 -0
- package/dist/packages/harbour-cli/src/source-inventory.js +239 -0
- package/dist/packages/harbour-cli/src/starter.js +557 -0
- package/dist/packages/harbour-cli/src/upload.js +163 -0
- package/dist/packages/harbour-cli/src/version.js +1 -0
- package/dist/src/analyzer.js +685 -0
- package/dist/src/contracts.js +82 -0
- package/dist/src/digest.js +26 -0
- package/dist/src/secret-paths.js +38 -0
- package/dist/src/source-intake.js +125 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @isomorph.ai/cli
|
|
2
|
+
|
|
3
|
+
The Isomorph development kit CLI: sets up, runs, checks and ships an Isomorph app. It is meant to be driven by an AI coding agent (Claude Code or Codex) on your behalf; you can also use it directly.
|
|
4
|
+
|
|
5
|
+
## Quick start (no coding needed)
|
|
6
|
+
|
|
7
|
+
You describe the app in plain English inside Claude Code or Codex; the agent installs and runs everything. One paste, once per computer:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx -y @isomorph.ai/cli agent-setup
|
|
11
|
+
```
|
|
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.)
|
|
14
|
+
|
|
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
|
+
|
|
17
|
+
Then, in an empty folder:
|
|
18
|
+
|
|
19
|
+
1. Say what you want, for example "Build me a small app where my team can vote on lunch options and see the results live."
|
|
20
|
+
2. Say "run it" — the agent starts it and gives you a link to open.
|
|
21
|
+
3. Say "check it" — the agent runs the checks and tells you in plain words what passed and what it fixed.
|
|
22
|
+
4. Say "I need Slack" (or Gmail, or the warehouse) — the agent asks IT for access and tells you when it is approved.
|
|
23
|
+
5. Say "ship it" — the agent puts a private preview online and gives you the link; "make it live for everyone" promotes it after you have tried it.
|
|
24
|
+
|
|
25
|
+
The only step you do yourself is the company sign-in: when the agent runs `isomorph login`, your browser opens and you sign in there. You need Node 22+ on macOS Apple silicon, Linux x64 or Windows x64; Isomorph installs its local database, files and gateway without Docker. Full walkthrough: [docs/vibecoding.md](../../docs/vibecoding.md).
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
isomorph agent-setup install the `isomorph` skill for Claude Code and Codex; idempotent; reports a missing or stale installed CLI
|
|
31
|
+
isomorph init --app-root <path> [--upgrade] starter app in an empty folder, or kit files in a Vite + React app (also runs agent-setup); --upgrade re-pins the kit bundle
|
|
32
|
+
isomorph dev --app-root <path> [--reset] run the app locally on one loopback origin
|
|
33
|
+
isomorph stop --app-root <path> stop local services, keep data
|
|
34
|
+
isomorph check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys; report in .isomorph/local/check-report.json
|
|
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
|
+
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
|
+
isomorph integrations status --app-root <path> [--json]
|
|
38
|
+
isomorph connect <work-email-or-start-url> once per company; then isomorph login | logout
|
|
39
|
+
isomorph productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
|
|
40
|
+
isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`productionise`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `isomorph status --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
|
|
44
|
+
|
|
45
|
+
`isomorph --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
|
|
46
|
+
|
|
47
|
+
## Apps set up by the old `harbour` CLI
|
|
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 and the project skill, deletes the checks the old kit generated (the next `isomorph check` regenerates them) and rewrites the SDK package name and its exported type names in `package.json`, `src/`, `jobs/` and the checks you wrote yourself. Every other command refuses an un-upgraded app and names that command. The old CLI can stay installed; it no longer deploys.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
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>` (a `-rc.N` candidate first, then the stable tag).
|
|
54
|
+
|
|
55
|
+
Connect with `isomorph connect name@getlokal.com`: the CLI tries `https://platform.isomorph.ai/start/getlokal.json` and validates the profile before saving it. This uses the first domain label as a guess, not a domain registry; subdomains or companies whose tenant name differs should use their company setup link. Only the guessed tenant name is sent, not the email. Company sign-in and access checks still apply.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { CLI_VERSION } from "./version.js";
|
|
8
|
+
import { continueCommand } from "./operations.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
export const MANAGED_START = "<!-- isomorph:kit:start -->";
|
|
11
|
+
export const MANAGED_END = "<!-- isomorph:kit:end -->";
|
|
12
|
+
/**
|
|
13
|
+
* The one command that installs or upgrades the CLI. It carries no dist-tag: npm resolves an
|
|
14
|
+
* untagged name through `latest`, which is what a release publishes, so every instruction in
|
|
15
|
+
* the kit names the package the same way a person typing it from memory would (LLD §15.5).
|
|
16
|
+
*/
|
|
17
|
+
export const CLI_INSTALL_COMMAND = "npm i -g @isomorph.ai/cli";
|
|
18
|
+
/**
|
|
19
|
+
* Where the two agents read the user-level guide from; both honour the tools' own
|
|
20
|
+
* override variables. Both are skills — a file each tool lists by name and description
|
|
21
|
+
* and reads in full only when the task matches — so the guide costs nothing in every
|
|
22
|
+
* other session. `legacyCodexAgents` is Codex's global AGENTS.md, which releases up to
|
|
23
|
+
* 0.1.33 wrote the guide into; Codex reads that file in every session, in every
|
|
24
|
+
* folder, so the kit's rules for a non-developer's Isomorph app ("never paste logs",
|
|
25
|
+
* "end every report with three lines") were applied to all of a person's other work.
|
|
26
|
+
*/
|
|
27
|
+
export function agentPaths(env = process.env) {
|
|
28
|
+
const home = env.ISOMORPH_AGENT_HOME?.trim() || homedir();
|
|
29
|
+
const codexHome = env.CODEX_HOME?.trim() || join(home, ".codex");
|
|
30
|
+
return {
|
|
31
|
+
claudeSkill: join(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), "skills", "isomorph", "SKILL.md"),
|
|
32
|
+
codexSkill: join(codexHome, "skills", "isomorph", "SKILL.md"),
|
|
33
|
+
legacyCodexAgents: join(codexHome, "AGENTS.md")
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Installs the user-level Isomorph skill for Claude Code and Codex (one file each,
|
|
38
|
+
* wholly owned by the kit) and takes the guide back out of Codex's global AGENTS.md
|
|
39
|
+
* where an older release put it, keeping everything else in that file. Idempotent:
|
|
40
|
+
* unchanged files are reported as kept. Also reports which `isomorph` the agent's next
|
|
41
|
+
* command will run (`result.cli`), because this command is routinely reached through
|
|
42
|
+
* `npx` while everything after it is not.
|
|
43
|
+
*/
|
|
44
|
+
export async function agentSetup(env = process.env, lookups = {}) {
|
|
45
|
+
const paths = agentPaths(env);
|
|
46
|
+
const result = { created: [], updated: [], kept: [], removed: [], cli: await checkCliVersion(lookups) };
|
|
47
|
+
for (const skill of [paths.claudeSkill, paths.codexSkill])
|
|
48
|
+
result[await upsertManagedBlock(skill, `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}`, {})].push(skill);
|
|
49
|
+
if (await removeManagedBlock(paths.legacyCodexAgents, { start: MANAGED_START, end: MANAGED_END }))
|
|
50
|
+
result.removed.push(paths.legacyCodexAgents);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
/** Compares the globally resolvable `isomorph` with the package this process runs from. */
|
|
54
|
+
export async function checkCliVersion(lookups = {}) {
|
|
55
|
+
const running = await (lookups.runningCliVersion ?? runningPackageVersion)();
|
|
56
|
+
// No `isomorph` on PATH, output with no version in it, a non-zero exit or a hang all
|
|
57
|
+
// mean the same thing: there is no installed CLI whose version can be trusted.
|
|
58
|
+
const installed = parseVersion(await (lookups.installedCliVersion ?? installedCliVersion)() ?? "");
|
|
59
|
+
const consequence = "carries an old kit bundle, so the app it creates passes every local check and is then refused by the deployment pipeline (kit_bundle_incompatible)";
|
|
60
|
+
if (!installed) {
|
|
61
|
+
// The CLI used to be published under another name with another command; a machine that still has it needs to know that it no longer deploys.
|
|
62
|
+
const legacy = await (lookups.legacyCliInstalled ?? legacyCliInstalled)() ? " The old `harbour` CLI is installed; it no longer deploys — install @isomorph.ai/cli." : "";
|
|
63
|
+
return { running, state: "missing", upgradeRequired: true, remediation: CLI_INSTALL_COMMAND, message: `No \`isomorph\` command is installed on this machine; this package is ${running}. \`isomorph init\`, \`dev\` and \`check\` run the installed CLI, not this one, and a missing or stale CLI ${consequence}.${legacy}` };
|
|
64
|
+
}
|
|
65
|
+
const order = compareCliVersions(installed, running);
|
|
66
|
+
if (order < 0)
|
|
67
|
+
return { running, installed, state: "stale", upgradeRequired: true, remediation: CLI_INSTALL_COMMAND, message: `The installed \`isomorph\` command is ${installed}, older than this package (${running}). \`isomorph init\`, \`dev\` and \`check\` run the installed CLI, not this one, and a stale CLI ${consequence}.` };
|
|
68
|
+
if (order > 0)
|
|
69
|
+
return { running, installed, state: "ahead", upgradeRequired: false, message: `The installed \`isomorph\` command is ${installed}, newer than this package (${running}); the installed one is what runs.` };
|
|
70
|
+
return { running, installed, state: "current", upgradeRequired: false, message: `The installed \`isomorph\` command is ${installed}, the same version as this package.` };
|
|
71
|
+
}
|
|
72
|
+
/** What `agent-setup` and `init` print about the installed CLI; an upgrade is impossible to miss in a scrolling log. */
|
|
73
|
+
export function cliVersionLines(check) {
|
|
74
|
+
if (!check.upgradeRequired)
|
|
75
|
+
return [check.message];
|
|
76
|
+
return ["", "!!! UPGRADE THE ISOMORPH CLI BEFORE `isomorph init` OR ANY OTHER ISOMORPH COMMAND !!!", check.message, `Run: ${check.remediation}`, ""];
|
|
77
|
+
}
|
|
78
|
+
/** Numeric-core semver order; a prerelease sorts before its release. An unparseable version compares equal, so nothing is called stale on a guess. */
|
|
79
|
+
export function compareCliVersions(left, right) {
|
|
80
|
+
const parse = (value) => {
|
|
81
|
+
const match = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(value);
|
|
82
|
+
return match ? { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] } : undefined;
|
|
83
|
+
};
|
|
84
|
+
const a = parse(left);
|
|
85
|
+
const b = parse(right);
|
|
86
|
+
if (!a || !b)
|
|
87
|
+
return 0;
|
|
88
|
+
for (let index = 0; index < 3; index += 1)
|
|
89
|
+
if (a.core[index] !== b.core[index])
|
|
90
|
+
return a.core[index] < b.core[index] ? -1 : 1;
|
|
91
|
+
if (a.pre === b.pre)
|
|
92
|
+
return 0;
|
|
93
|
+
if (a.pre === undefined)
|
|
94
|
+
return 1;
|
|
95
|
+
if (b.pre === undefined)
|
|
96
|
+
return -1;
|
|
97
|
+
return a.pre < b.pre ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
/** Whatever the globally resolvable `isomorph` prints for `--version`; undefined when running it is not possible at all. */
|
|
100
|
+
async function installedCliVersion() {
|
|
101
|
+
try {
|
|
102
|
+
const { stdout } = await execFileAsync("isomorph", ["--version"], { timeout: 20_000, windowsHide: true });
|
|
103
|
+
return stdout;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
return error.stdout;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Whether the CLI's previous command name is still on PATH (whatever it answers). */
|
|
110
|
+
async function legacyCliInstalled() {
|
|
111
|
+
try {
|
|
112
|
+
await execFileAsync("harbour", ["--version"], { timeout: 20_000, windowsHide: true });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return error.code !== "ENOENT";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function parseVersion(text) {
|
|
120
|
+
return /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(text.trim())?.[1];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The version of the package this process runs from: the nearest package.json named
|
|
124
|
+
* `@isomorph.ai/cli`, walking up from this file. That is `packages/harbour-cli/`
|
|
125
|
+
* in the repository and the package root once published (the entry point is
|
|
126
|
+
* `dist/packages/harbour-cli/src/cli.js`), so no environment variable is involved.
|
|
127
|
+
*/
|
|
128
|
+
async function runningPackageVersion() {
|
|
129
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
130
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
131
|
+
const manifest = await readFile(join(directory, "package.json"), "utf8").then(text => JSON.parse(text), () => undefined);
|
|
132
|
+
if (manifest?.name === "@isomorph.ai/cli" && typeof manifest.version === "string")
|
|
133
|
+
return manifest.version;
|
|
134
|
+
const parent = dirname(directory);
|
|
135
|
+
if (parent === directory)
|
|
136
|
+
break;
|
|
137
|
+
directory = parent;
|
|
138
|
+
}
|
|
139
|
+
return CLI_VERSION;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Writes `block` to `file`: creates the file, replaces the text between the markers
|
|
143
|
+
* when both are present, or appends the block after the existing content. Without
|
|
144
|
+
* markers the whole file is the managed block and is rewritten only when it differs.
|
|
145
|
+
*/
|
|
146
|
+
export async function upsertManagedBlock(file, block, options) {
|
|
147
|
+
const current = await readFile(file, "utf8").catch(() => undefined);
|
|
148
|
+
if (current === undefined) {
|
|
149
|
+
await mkdir(dirname(file), { recursive: true });
|
|
150
|
+
await writeFile(file, `${block}\n`);
|
|
151
|
+
return "created";
|
|
152
|
+
}
|
|
153
|
+
const { start, end } = options;
|
|
154
|
+
let next;
|
|
155
|
+
if (start && end && current.includes(start) && current.includes(end))
|
|
156
|
+
next = current.replace(new RegExp(`${escape(start)}[\\s\\S]*?${escape(end)}`), () => block);
|
|
157
|
+
else if (start && end)
|
|
158
|
+
next = `${current.replace(/\n*$/, "")}${options.separator ?? "\n\n"}${block}\n`;
|
|
159
|
+
else
|
|
160
|
+
next = `${block}\n`;
|
|
161
|
+
if (next === current)
|
|
162
|
+
return "kept";
|
|
163
|
+
await writeFile(file, next);
|
|
164
|
+
return "updated";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Takes the marker-fenced block out of `file`, keeping everything before and after it;
|
|
168
|
+
* a file that held nothing else is deleted. Returns whether there was a block to remove.
|
|
169
|
+
*/
|
|
170
|
+
export async function removeManagedBlock(file, options) {
|
|
171
|
+
const current = await readFile(file, "utf8").catch(() => undefined);
|
|
172
|
+
if (current === undefined || !current.includes(options.start) || !current.includes(options.end))
|
|
173
|
+
return false;
|
|
174
|
+
const remainder = current.replace(new RegExp(`\\n*${escape(options.start)}[\\s\\S]*?${escape(options.end)}\\n*`), "\n\n").replace(/^\n+/, "").replace(/\n*$/, "\n");
|
|
175
|
+
if (remainder.trim())
|
|
176
|
+
await writeFile(file, remainder);
|
|
177
|
+
else
|
|
178
|
+
await rm(file);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
182
|
+
/**
|
|
183
|
+
* Both agents show a skill to the model as this name and description and read the body
|
|
184
|
+
* only for a task that matches, so the description is the whole gate: it names the
|
|
185
|
+
* three signs of Isomorph work and rules everything else out, because "run it" or
|
|
186
|
+
* "ship it" is said about every kind of project.
|
|
187
|
+
*/
|
|
188
|
+
export const SKILL_DESCRIPTION = `Build, run, check and ship a company app on Isomorph from plain English. Use only when the folder has an .isomorph/ directory, when the person names Isomorph, or when they ask for a new app for their team in an empty folder; there, "run it", "check it", "I need Slack/Gmail/company data", "ship it" and "make it live" mean this skill. Do not use for any other project, script, question or coding task — nothing in it applies outside an Isomorph app.`;
|
|
189
|
+
const SKILL_FRONTMATTER = `---
|
|
190
|
+
name: isomorph
|
|
191
|
+
description: ${SKILL_DESCRIPTION}
|
|
192
|
+
---`;
|
|
193
|
+
/** One guide, shared by the Claude Code and Codex skills. Written for an agent working with a non-developer, and only for that. */
|
|
194
|
+
export const AGENT_GUIDE = `# Isomorph development kit
|
|
195
|
+
|
|
196
|
+
This guide is for one job: an app that runs on Isomorph. It applies when the folder has an \`.isomorph/\` directory, when the person names Isomorph, or when they ask for a new app for their team and the folder holds no other project. Anything else — another project, a script, a question, ordinary coding — is not this job: ignore every rule below and work as you always do. Never turn an existing project into an Isomorph app unless the person asks for that by name.
|
|
197
|
+
|
|
198
|
+
The person you are working with may not be a developer. They say what they want in plain English; you build it with the Isomorph development kit and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Turn every failure into one sentence about what happened and one about what happens next. Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
|
|
199
|
+
|
|
200
|
+
## Getting ready (do this yourself, once per machine and folder)
|
|
201
|
+
|
|
202
|
+
1. CLI, before \`isomorph init\` or anything else: compare the installed CLI with the current release and upgrade it whenever it is behind — a CLI that merely runs is not good enough, only a current one is. \`isomorph agent-setup\` prints both versions and says plainly when the installed one is missing or older (its \`--json\` result carries \`cli.upgradeRequired\` and \`cli.remediation\`); without that output, compare \`isomorph --version\` with \`npx -y @isomorph.ai/cli --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @isomorph.ai/cli\` and confirm \`isomorph --version\` now matches, then continue. Every later command runs the *installed* CLI, so a stale one builds an app that passes every local check and is then refused by the deployment pipeline (\`kit_bundle_incompatible\`) minutes later, with nothing in the app to fix. The kit needs Node 22+ on macOS Apple silicon, Linux x64 or Windows x64; it installs and runs its local database, files and gateway without Docker.
|
|
203
|
+
2. Folder: if the current folder 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 added and nothing overwritten. Then read the "Isomorph development kit" block in CLAUDE.md / AGENTS.md; it holds the per-app rules.
|
|
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 login says the company is not connected yet, ask for their work email and run \`isomorph connect <work-email>\` first. This guesses the tenant from the first email-domain label and validates its company profile; it does not grant access. If the lookup fails, ask for the company setup link their IT/admin gave them and run \`isomorph connect <link>\`.
|
|
205
|
+
|
|
206
|
+
## What they say → what you do
|
|
207
|
+
|
|
208
|
+
- "run it", "show me", "let me try it" → start \`isomorph dev --app-root .\` in the background (it keeps running; the first start downloads the native runtime and takes a minute or two). Wait for the line \`Isomorph dev is running: http://127.0.0.1:<port>\` and give them that link. Do this unasked as soon as the first check is green — they should always have the link. Locally they are a fixture user; no company sign-in is needed.
|
|
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 to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
|
|
210
|
+
- "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`isomorph check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically. In development a Send sends a real email or Slack message: reuse explicit authorization for that bounded test, or ask once if none exists; IT access approval alone is not permission to send. Never ask again for the same authorized test. Before a real integration test, run \`isomorph integrations status --app-root . --json\`; explain pending IT approval or missing personal consent before pressing Send, and test the ready parts independently. If you have no browser, say that the button itself is untested.
|
|
211
|
+
- "I need Slack / Gmail / the warehouse / company data" → a fresh \`isomorph init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. 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 required object or column is absent, report that catalog gap instead of substituting a plausible name. A warehouse resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`; inspect the returned row keys before mapping them and do not invent a friendlier schema or ask the person to get IT to confirm one. Declare only connections and operations the app really calls, then submit the request yourself in the same turn with \`isomorph integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` — one request per connection; IT approves it once for every environment (development, preview and production together), and \`isomorph dev\` and \`isomorph productionise\` file it for you as well, so there is never a second request to make before shipping. Never tell the person to ask IT before you have submitted the request. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`isomorph integrations status --app-root . --json\` (development may already read ready while preview and production wait: that is the company's development preapproval, and the one approval covers the rest). A refusal with \`RESOURCE_NOT_APPROVED\` means the named resource is not on the connection yet: IT adds it in the Isomorph console under Controls & integrations, and then you run the same request again. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
|
|
212
|
+
- "summarise", "draft", "explain", "AI" → one \`isomorph.ai.chat\` call (on the client exported by \`src/isomorph.client.ts\`, never through a wrapper function) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`isomorph check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
|
|
213
|
+
- "ship it", "put it online", "let my team try it" → run \`isomorph check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Before sharing or deploying the private preview, show the person 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. Pass that confirmed setup in the same command: \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\`; omit \`--emails\` for "only you". The command records the confirmed setup before uploading or deploying the app. It files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve — say that line and nothing more, and ship again once IT has approved it (one approval covers preview and production); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. The command gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they confirmed, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. For kit apps, describe \`TRANSFORMING\` as building and checking the app; it does not mean a transformation AI is running. The CLI saves \`operationRef\` in \`.isomorph/local/productionise.json\`; repeating \`productionise\` continues that operation. Keep \`operationRef\`; \`isomorph setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`isomorph profile\` / \`isomorph audience\` / \`isomorph secrets set\` fill it in.
|
|
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. Then promote with the operation reference from productionise. Report the production link, or that an operator approval is pending.
|
|
215
|
+
- "stop it" → \`isomorph stop --app-root .\` (local data kept). \`isomorph dev --reset --app-root .\` deletes local data — only when they explicitly ask to start over.
|
|
216
|
+
- "every day at 2pm", "run this on a schedule", "send this automatically" → create one \`jobs/<name>.ts\` file with a literal UTC cron and one default async handler. Start \`isomorph dev\`, then run it immediately with \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\`; this checks the job against local data and safe fixtures. If the person explicitly asks to test the company action now, use \`--real\` only after the exact action has development approval. 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." Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment.
|
|
217
|
+
|
|
218
|
+
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
|
+
|
|
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
|
+
## Talking to the person
|
|
231
|
+
|
|
232
|
+
- Plain words, short: "Your app is running at <link>.", "All 6 checks passed.", "One check failed: votes were not being saved — fixed, checking again.", "IT has to approve Slack; the app works without it until then."
|
|
233
|
+
- Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.
|
|
234
|
+
- Keep app reports natural and brief: say what you actually verified, any remaining blocker and who can resolve it, and any material scope decision you made. Do not force headings or repeat unchanged status. A platform check is not proof that the main task worked in the browser.
|
|
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.`;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** A policy that decides rows by the signed-in identity, and the column it decides them on. */
|
|
4
|
+
const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
|
|
5
|
+
const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
|
|
6
|
+
/**
|
|
7
|
+
* The app's tables as the database holds them, from the catalog the kit gate
|
|
8
|
+
* read after replaying the migrations, or `undefined` when that text is not a
|
|
9
|
+
* catalog. `undefined` is not "no tables": nothing is generated from, or
|
|
10
|
+
* removed because of, a schema Isomorph could not read — deleting an app's
|
|
11
|
+
* retained checks on a failed query would be the worst possible reading of it.
|
|
12
|
+
*/
|
|
13
|
+
export async function readAppSchema(catalogText, root) {
|
|
14
|
+
const text = catalogText.trim();
|
|
15
|
+
if (!text.startsWith("["))
|
|
16
|
+
return undefined;
|
|
17
|
+
let catalog;
|
|
18
|
+
try {
|
|
19
|
+
catalog = JSON.parse(text);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(catalog))
|
|
25
|
+
return undefined;
|
|
26
|
+
const migrations = await migrationTexts(root);
|
|
27
|
+
const schema = new Map();
|
|
28
|
+
for (const table of catalog) {
|
|
29
|
+
schema.set(table.name, {
|
|
30
|
+
columns: table.columns.map(column => ({
|
|
31
|
+
name: column.name,
|
|
32
|
+
type: columnType(column.base, column.category),
|
|
33
|
+
declaredType: column.type,
|
|
34
|
+
notNull: column.notnull,
|
|
35
|
+
hasDefault: column.default !== null,
|
|
36
|
+
generated: column.generated,
|
|
37
|
+
identityDefault: IDENTITY.test(column.default ?? ""),
|
|
38
|
+
references: column.fk,
|
|
39
|
+
checks: column.checks
|
|
40
|
+
})),
|
|
41
|
+
primaryKey: table.primaryKey,
|
|
42
|
+
ownerScoped: table.policies.some(policy => IDENTITY.test(policy)),
|
|
43
|
+
ownerColumn: table.policies.map(policy => OWNER_SCOPE.exec(policy)?.[1]).find(Boolean),
|
|
44
|
+
file: createdIn(table.name, migrations)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return schema;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Postgres has already reduced every declared spelling to one base type name
|
|
51
|
+
* (`character varying` to `varchar`, `double precision` to `float8`, `BIGSERIAL`
|
|
52
|
+
* to `int8` with a nextval default), so this is a lookup rather than a parse.
|
|
53
|
+
*/
|
|
54
|
+
function columnType(base, category) {
|
|
55
|
+
if (category === "A")
|
|
56
|
+
return "array";
|
|
57
|
+
switch (base) {
|
|
58
|
+
case "text":
|
|
59
|
+
case "varchar":
|
|
60
|
+
case "bpchar":
|
|
61
|
+
case "char":
|
|
62
|
+
case "citext":
|
|
63
|
+
case "name": return "text";
|
|
64
|
+
case "uuid": return "uuid";
|
|
65
|
+
case "bool": return "boolean";
|
|
66
|
+
case "int2":
|
|
67
|
+
case "int4":
|
|
68
|
+
case "int8": return "integer";
|
|
69
|
+
case "numeric":
|
|
70
|
+
case "float4":
|
|
71
|
+
case "float8":
|
|
72
|
+
case "money": return "number";
|
|
73
|
+
case "timestamptz":
|
|
74
|
+
case "timestamp": return "timestamptz";
|
|
75
|
+
case "date": return "date";
|
|
76
|
+
case "time":
|
|
77
|
+
case "timetz": return "time";
|
|
78
|
+
case "json":
|
|
79
|
+
case "jsonb": return "json";
|
|
80
|
+
default: return "unknown";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function migrationTexts(root) {
|
|
84
|
+
const directory = join(root, "migrations");
|
|
85
|
+
const names = (await readdir(directory).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
|
|
86
|
+
return Promise.all(names.map(async (name) => ({ name: `migrations/${name}`, text: await readFile(join(directory, name), "utf8").catch(() => "") })));
|
|
87
|
+
}
|
|
88
|
+
/** Which file created a table: the only question the catalog cannot answer, so the only one still asked of the SQL text. */
|
|
89
|
+
function createdIn(table, migrations) {
|
|
90
|
+
const pattern = new RegExp(String.raw `create\s+table\b[^;]*\b${table}\b`, "i");
|
|
91
|
+
return migrations.find(migration => pattern.test(migration.text))?.name ?? "migrations/";
|
|
92
|
+
}
|
|
93
|
+
// ---- CHECK constraints ----------------------------------------------------------
|
|
94
|
+
//
|
|
95
|
+
// `pg_get_constraintdef` deparses, so these read one normal form rather than
|
|
96
|
+
// everything an author might have typed: `BETWEEN a AND b` has already become
|
|
97
|
+
// `>= a AND <= b`, `IN (…)` has become `= ANY (ARRAY[…])`, and each operand
|
|
98
|
+
// carries its cast. Only the shapes a value has to satisfy to be accepted are
|
|
99
|
+
// read; anything else is left alone, and a value the database rejects surfaces
|
|
100
|
+
// as a failing journey under `isomorph check` in seconds rather than in the
|
|
101
|
+
// pipeline minutes later.
|
|
102
|
+
/** `char_length(col) >= n`, `length(col) <= n` — including the `((col)::text)` spelling a deparse gives a varchar. */
|
|
103
|
+
export function textLengthBounds(checks, column) {
|
|
104
|
+
let min = 1;
|
|
105
|
+
let max = 500;
|
|
106
|
+
const pattern = new RegExp(String.raw `(?:char_length|length|octet_length)\([^<>=]*\b${column}\b[^<>=]*(<=|<|>=|>|=)\s*(\d+)`, "gi");
|
|
107
|
+
for (const check of checks)
|
|
108
|
+
for (const match of check.matchAll(pattern)) {
|
|
109
|
+
const value = Number(match[2]);
|
|
110
|
+
if (match[1] === "<=" || match[1] === "=")
|
|
111
|
+
max = Math.min(max, value);
|
|
112
|
+
else if (match[1] === "<")
|
|
113
|
+
max = Math.min(max, value - 1);
|
|
114
|
+
else if (match[1] === ">=")
|
|
115
|
+
min = Math.max(min, value);
|
|
116
|
+
else
|
|
117
|
+
min = Math.max(min, value + 1);
|
|
118
|
+
}
|
|
119
|
+
return { min, max: Math.max(max, min) };
|
|
120
|
+
}
|
|
121
|
+
/** `col = ANY (ARRAY['a'::text, 'b'::text])`: the closed set a value must come from. */
|
|
122
|
+
export function allowedLiterals(checks, column) {
|
|
123
|
+
for (const check of checks) {
|
|
124
|
+
const list = new RegExp(String.raw `\b${column}\b[^=]*=\s*ANY\s*[\s(]*ARRAY\[([^\]]*)\]`, "i").exec(check);
|
|
125
|
+
const values = [...(list?.[1] ?? "").matchAll(/'((?:[^']|'')*)'/g)].map(match => match[1].replace(/''/g, "'"));
|
|
126
|
+
if (values.length)
|
|
127
|
+
return values;
|
|
128
|
+
}
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
/** The smallest number `col > n` / `col >= n` accepts. */
|
|
132
|
+
export function numericMinimum(checks, column) {
|
|
133
|
+
let minimum = 1;
|
|
134
|
+
for (const check of checks) {
|
|
135
|
+
const comparison = new RegExp(String.raw `\b${column}\b[^<>=]*(>=|>)\s*(-?\d+(?:\.\d+)?)`, "i").exec(check);
|
|
136
|
+
if (!comparison)
|
|
137
|
+
continue;
|
|
138
|
+
const value = Number(comparison[2]);
|
|
139
|
+
minimum = Math.max(minimum, comparison[1] === ">" ? value + 1 : value);
|
|
140
|
+
}
|
|
141
|
+
return minimum;
|
|
142
|
+
}
|