@isomorph.ai/cli 0.4.2 → 0.5.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.
@@ -0,0 +1,101 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { lstat, readdir, readFile, writeFile } from "node:fs/promises";
4
+ import { scanWorkspace } from "../../../src/analyzer.js";
5
+ import { sha256Bytes } from "../../../src/digest.js";
6
+ import { isEnvExamplePath, isProhibitedSecretPath } from "../../../src/secret-paths.js";
7
+ import { archiveForManifest } from "./upload.js";
8
+ import { CliError } from "./output.js";
9
+ import { readKitLock } from "./kit.js";
10
+ /** Where `isomorph package` writes the console import package unless `--out` says otherwise. */
11
+ export const PACKAGE_FILE = "isomorph-import.zip";
12
+ export const PACKAGE_NEXT_STEP = `Upload ${PACKAGE_FILE} in the Isomorph console (Add app → Upload package), or connect the repository there instead.`;
13
+ /** A real env file (`.env`, `.env.local`, `.env.staging`), never a checked-in template. */
14
+ export const isEnvFile = (path) => /(^|\/)\.env(?:\.[^/]+)?$/i.test(path) && !isEnvExamplePath(path);
15
+ /** The filesystem root and the person's home are never an app. Cheap, so both `deploy` and `package` ask before reading anything. */
16
+ export function assertSafeAppRoot(root) {
17
+ if (root === parse(root).root || root === resolve(homedir()))
18
+ throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
19
+ }
20
+ /**
21
+ * The pre-flight `deploy` and `package` share: the same ignore-aware scan
22
+ * (`node_modules`, build output, caches and `.git` stay out), the same refusal
23
+ * of an empty boundary and of a secret-bearing path, then every file read once.
24
+ *
25
+ * `envFiles` is the one difference between the two: a deploy carries no env
26
+ * file (the scanner drops `.env` and refuses `.env.<anything>`), while the
27
+ * console import wants them — it reads their values into encrypted defaults
28
+ * and never commits the files — so `package` walks the tree for them and admits
29
+ * them past the secret-path refusal. Private keys and the like are refused on
30
+ * both paths. `exclude` keeps a package's own output out of the next package.
31
+ */
32
+ export async function readAppTree(root, options = {}) {
33
+ assertSafeAppRoot(root);
34
+ const graph = await scanWorkspace(root, { sourceBoundary: "root" });
35
+ const excluded = new Set(options.exclude ?? []);
36
+ const permitted = (path) => Boolean(options.envFiles) && isEnvFile(path);
37
+ const included = graph.deploymentScope.includedFiles.filter(path => !excluded.has(path));
38
+ if (!included.length)
39
+ throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
40
+ if (included.some(path => isProhibitedSecretPath(path) && !permitted(path)))
41
+ throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
42
+ const paths = options.envFiles ? [...new Set([...included, ...await envFilesIn(root, graph.deploymentScope.excludedDirectories)])].filter(path => !excluded.has(path)).sort() : included;
43
+ const files = await Promise.all(paths.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
44
+ return { graph, files };
45
+ }
46
+ /**
47
+ * The env files the scanner leaves out, found the way it walks: directories it
48
+ * ignores are ignored here, symlinks are skipped, paths are relative and
49
+ * slash-separated.
50
+ */
51
+ async function envFilesIn(root, ignoredDirectories) {
52
+ const ignored = new Set([...ignoredDirectories, ".git"]);
53
+ const found = [];
54
+ const walk = async (directory) => {
55
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
56
+ if (entry.isSymbolicLink())
57
+ continue;
58
+ const absolute = join(directory, entry.name);
59
+ if (entry.isDirectory()) {
60
+ if (!ignored.has(entry.name))
61
+ await walk(absolute);
62
+ continue;
63
+ }
64
+ if (entry.isFile() && isEnvFile(entry.name))
65
+ found.push(relative(root, absolute).split(sep).join("/"));
66
+ }
67
+ };
68
+ await walk(root);
69
+ return found;
70
+ }
71
+ /**
72
+ * `isomorph package`: the app as it is, zipped for the console's Upload
73
+ * package. Local — no company, no sign-in, no kit: a kit app is refused,
74
+ * because it deploys from here with `isomorph deploy`. The zip is deterministic
75
+ * (no timestamps), so the same tree gives the same sha256 twice.
76
+ */
77
+ export async function packageApp(rootArg, output, options = {}) {
78
+ const root = resolve(rootArg);
79
+ if (!(await lstat(root).then(info => info.isDirectory(), () => false)))
80
+ throw new CliError("APP_ROOT_MISSING", `${rootArg} is not a folder.`, undefined, "Pass --app-root <the app's folder>.");
81
+ if (await readKitLock(root))
82
+ throw new CliError("KIT_APP", "This folder is an Isomorph kit app; it does not go through the console import.", undefined, `Kit apps deploy with \`isomorph deploy --app-root ${rootArg}\`.`);
83
+ const out = options.out ? resolve(options.out) : join(root, PACKAGE_FILE);
84
+ // A package is never app content: neither the one written last time under the
85
+ // default name nor this one when --out puts it inside the app.
86
+ const outInside = relative(root, out);
87
+ const exclude = [PACKAGE_FILE, ...(outInside && !outInside.startsWith("..") && !isAbsolute(outInside) ? [outInside.split(sep).join("/")] : [])];
88
+ output(`Isomorph is packaging ${basename(root)}.`);
89
+ const tree = await readAppTree(root, { exclude, envFiles: true });
90
+ const envCount = tree.files.filter(file => isEnvFile(file.path)).length;
91
+ output(`Isomorph found ${tree.files.length} app files${envCount ? ` (${envCount} .env file${envCount === 1 ? "" : "s"} included: the console stores their values encrypted and never commits them)` : ""}.`);
92
+ // The file list, hashed as a deploy manifest hashes it. Not `createSourceManifest`:
93
+ // that validates with the server's staging rule, which refuses every real env file —
94
+ // right for what reaches source control, and exactly what the console import
95
+ // reads out of the package before that rule runs. Nothing here is sent.
96
+ const files = await Promise.all(tree.files.map(async (file) => ({ path: file.path, sha256: await sha256Bytes(file.content), bytes: file.content.byteLength })));
97
+ const archive = await archiveForManifest(root, { files }, { permit: isEnvFile });
98
+ await writeFile(out, archive.body);
99
+ output(`Package written: ${out} (${files.length} files, ${archive.body.byteLength} bytes, ${archive.digest}).`);
100
+ return { path: out, files: files.length, bytes: archive.body.byteLength, sha256: archive.digest, nextStep: PACKAGE_NEXT_STEP };
101
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { bundleDiff } from "./kit-bundle.js";
4
4
  import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
@@ -6,19 +6,46 @@ import { defaultAppProfile, emptyDeclaration, newKitLock, readKitLock, renderApp
6
6
  import { CliError } from "./output.js";
7
7
  export { MANAGED_END, MANAGED_START };
8
8
  /**
9
- * Creates the starter in an empty directory, or adds the missing kit files to
10
- * an existing Vite + React app. User files are never overwritten: a path that
11
- * exists is reported as kept. Instruction files get a managed block that points at the
12
- * rules, and the user-level agent guide (the rules themselves) is installed so the
13
- * agents know the kit from any folder.
9
+ * Files and folders that do not make a folder "an app": what an editor, git or an
10
+ * agent leaves in an otherwise empty folder. Anything else is content, and content
11
+ * without a kit lock is an app that was built without the kit.
12
+ */
13
+ const IGNORABLE_ENTRIES = new Set([".git", ".DS_Store", ".gitignore", "README.md", "LICENSE", "CLAUDE.md", "AGENTS.md", ".claude", ".codex", ".vscode", ".idea"]);
14
+ /** The three ways forward for a folder with an app and no kit, as the `APP_EXISTS` refusal carries them (`details.choices`). */
15
+ export function appExistsChoices(dir) {
16
+ return [
17
+ { id: "adopt", command: `isomorph init --app-root ${dir} --adopt`, description: "add the kit to this app in place; Vite + React only" },
18
+ { id: "new", command: "isomorph init --app-root <new-empty-folder>", description: "start a new kit app in a new empty folder" },
19
+ { id: "package", command: `isomorph package --app-root ${dir}`, description: "package this app as it is for import in the Isomorph console" }
20
+ ];
21
+ }
22
+ /**
23
+ * Creates the starter in an empty directory, adds the missing kit files to an
24
+ * existing Vite + React app (`adopt`), or keeps a kit app as it is. A folder
25
+ * with an app and no kit is never adopted silently: the refusal names the
26
+ * three choices and the agent relays them to the person. User files are never
27
+ * overwritten: a path that exists is reported as kept. Instruction files get a
28
+ * managed block that points at the rules, and the user-level agent guide (the
29
+ * rules themselves) is installed so the agents know the kit from any folder.
14
30
  */
15
31
  export async function initKit(root, bundle, options = {}) {
16
32
  await mkdir(root, { recursive: true });
17
33
  const existingPackage = await readJson(join(root, "package.json"));
18
- const emptyDir = !existingPackage && !(await exists(join(root, "src")));
19
- if (existingPackage && !isSupportedApp(existingPackage))
20
- throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).", undefined, "Run `isomorph init --app-root <new-empty-folder>` to start a Vite + React app, then move this app's code into it.");
21
- const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
34
+ const previous = await readKitLock(root);
35
+ const emptyDir = (await readdir(root)).every(name => IGNORABLE_ENTRIES.has(name));
36
+ const unsupported = () => new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).", undefined, "Run `isomorph init --app-root <new-empty-folder>` to start a Vite + React app, then move this app's code into it.");
37
+ if (!emptyDir && !previous && !options.upgrade) {
38
+ // Refused before anything is written, here or in the person's home: the choice is theirs.
39
+ const dir = options.displayRoot ?? root;
40
+ const choices = appExistsChoices(dir);
41
+ if (!options.adopt)
42
+ throw new CliError("APP_EXISTS", "This folder already has an app and no Isomorph kit.", undefined, "Choose one of the three commands below: add the kit to this app in place (--adopt, Vite + React only), start a new kit app in an empty folder, or package this app as it is for the console import.", undefined, { details: { choices } });
43
+ if (!existingPackage || !isSupportedApp(existingPackage))
44
+ throw unsupported();
45
+ }
46
+ else if (existingPackage && !isSupportedApp(existingPackage))
47
+ throw unsupported();
48
+ const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : previous ? "kit" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
22
49
  const write = async (path, content) => {
23
50
  const absolute = join(root, path);
24
51
  if (await exists(absolute)) {
@@ -37,7 +64,6 @@ export async function initKit(root, bundle, options = {}) {
37
64
  await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
38
65
  for (const file of ["CLAUDE.md", "AGENTS.md"])
39
66
  await appendManaged(root, file, managedBlock(), result, "\n\n", MANAGED_START, MANAGED_END);
40
- const previous = await readKitLock(root);
41
67
  if (!previous) {
42
68
  await writeKitLock(root, newKitLock(bundle, options.tenantId ?? ""));
43
69
  result.created.push(".isomorph/kit.lock.json");
@@ -82,7 +108,7 @@ async function appendManaged(root, file, block, result, separator, start, end) {
82
108
  const exists = (path) => stat(path).then(() => true, () => false);
83
109
  const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
84
110
  // ---- Templates -----------------------------------------------------------------
85
- const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/"].join("\n");
111
+ const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/", ".isomorph/checks/"].join("\n");
86
112
  /**
87
113
  * The per-app block: it says where the rules are and when to read each, and nothing
88
114
  * else — the rules themselves live in the modules `agent-setup` writes beside the
@@ -128,14 +154,10 @@ function kitFiles(root) {
128
154
  * `notes` table it never had, and a check that queries it then fails `flow` on
129
155
  * the next deploy (#456).
130
156
  *
131
- * The retained checks are deliberately not here, and not written by `init` at
132
- * all. `isomorph check` generates them from this schema — read back out of the
133
- * database it replays the migrations into — and from this source, so "one
134
- * journey per capability the UI declares, plus the cross-user denial per
135
- * owner-scoped table" is a property of the generator rather than three files
136
- * that have to be kept in step with `src/App.tsx` by hand. `init` has no
137
- * database and so invents nothing: `.isomorph/checks/` arrives with the first
138
- * `isomorph check`, which is also the command that keeps it right afterwards.
157
+ * No checks are here either: `.isomorph/checks/` is generated output the kit
158
+ * gate rebuilds on every `isomorph check` from this schema (read back out of
159
+ * the database it replays the migrations into) and this source, gitignored by
160
+ * `init`; a check the builder writes lives in `tests/<name>.mjs`.
139
161
  */
140
162
  function starterFiles(bundle) {
141
163
  return {
@@ -167,14 +189,14 @@ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in,
167
189
  ## Three commands
168
190
 
169
191
  - \`isomorph dev --app-root . --detach --json\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
170
- - \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); the output is the report.
192
+ - \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, journeys); the output is the report.
171
193
  - \`isomorph deploy --app-root . --json\` — deploys a private preview for the people named in \`.isomorph/app.json\`; \`isomorph promote\` makes it live for everyone once they have tried it.
172
194
 
173
195
  ## Where the rules are
174
196
 
175
197
  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, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md points there.
176
198
 
177
- \`.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. \`.isomorph/app.json\` holds the app's name, description and audience.
199
+ \`.isomorph/checks/\` is regenerated on each check and gitignored — put your own checks in \`tests/<name>.mjs\`. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start. \`.isomorph/app.json\` holds the app's name, description and audience.
178
200
  `;
179
201
  const VITE_CONFIG = `import { defineConfig } from "vite";
180
202
  import react from "@vitejs/plugin-react";
@@ -3,14 +3,20 @@ import { relative, resolve, sep } from "node:path";
3
3
  import { deflateRawSync } from "node:zlib";
4
4
  import { sha256Bytes } from "../../../src/digest.js";
5
5
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
6
- export async function archiveForManifest(root, manifest) {
6
+ /**
7
+ * The deterministic package for a manifest. Secret-bearing paths are refused
8
+ * unless `permit` admits one: the console import (`isomorph package`) carries
9
+ * the app's `.env` files on purpose, because the console reads their values
10
+ * into encrypted defaults and never commits the files; a deploy carries none.
11
+ */
12
+ export async function archiveForManifest(root, manifest, options = {}) {
7
13
  const rootAbsolute = resolve(root);
8
14
  if ((await lstat(rootAbsolute)).isSymbolicLink())
9
15
  throw new Error("Isomorph rejected an unsafe app boundary.");
10
16
  const files = [];
11
17
  const seen = new Set();
12
18
  for (const entry of manifest.files) {
13
- if (seen.has(entry.path) || isProhibitedSecretPath(entry.path) || entry.path.includes("\\") || entry.path.startsWith("/"))
19
+ if (seen.has(entry.path) || (isProhibitedSecretPath(entry.path) && !options.permit?.(entry.path)) || entry.path.includes("\\") || entry.path.startsWith("/"))
14
20
  throw new Error("Isomorph rejected an unsafe source path.");
15
21
  seen.add(entry.path);
16
22
  const absolute = resolve(rootAbsolute, entry.path);
@@ -13,10 +13,9 @@ const MAX_FILE_BYTES = 256_000;
13
13
  // application files. Keeping them out also makes reruns idempotent after a
14
14
  // previous save has written a receipt into the selected workspace.
15
15
  const IGNORED_DIRS = new Set([".git", ".harbour", ".isomorph", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
16
- /** The development kit's directory. It commits its declaration, lock, governed-AI inventory and journey checks there (see `isKitCommittedPath`); everything else in it (local state, checks evidence, generated control files) stays out of the package. The inventory is what the pipeline plans a kit app's AI over (data plane 0.78.2.0): the gate writes it under `isomorph check`, the lane refuses an app whose source calls governed AI without it. */
16
+ /** The development kit's directory. It commits its declaration, lock and app profile there (see `isKitCommittedPath`); everything else in it stays out of the package — local state, and the generated `checks/` and `ai-inventory.json`, which the pipeline's kit lane regenerates from the source it builds (data plane ADR 0014). */
17
17
  const KIT_DIR = ".isomorph";
18
- const KIT_CONTROL_FILES = [`${KIT_DIR}/integrations.json`, `${KIT_DIR}/kit.lock.json`, `${KIT_DIR}/app.json`, `${KIT_DIR}/ai-inventory.json`];
19
- const KIT_CHECKS_DIR = `${KIT_DIR}/checks`;
18
+ const KIT_CONTROL_FILES = [`${KIT_DIR}/integrations.json`, `${KIT_DIR}/kit.lock.json`, `${KIT_DIR}/app.json`];
20
19
  const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
21
20
  const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh"]);
22
21
  function normalizeIncludePath(value) {
@@ -90,20 +89,7 @@ async function collectFiles(root, current, output, unknowns) {
90
89
  }
91
90
  }
92
91
  async function collectKitFiles(root, output) {
93
- const paths = [...KIT_CONTROL_FILES];
94
- let checks = [];
95
- try {
96
- checks = await readdir(join(root, ...KIT_CHECKS_DIR.split("/")), { withFileTypes: true });
97
- }
98
- catch {
99
- checks = [];
100
- }
101
- for (const entry of checks) {
102
- const path = `${KIT_CHECKS_DIR}/${entry.name}`;
103
- if (isKitCommittedPath(path))
104
- paths.push(path);
105
- }
106
- for (const path of paths.sort()) {
92
+ for (const path of KIT_CONTROL_FILES) {
107
93
  const absolute = join(root, ...path.split("/"));
108
94
  let info;
109
95
  try {
@@ -19,14 +19,14 @@ export function isSourceIntakeExcludedPath(path) {
19
19
  }
20
20
  /**
21
21
  * The development kit commits exactly these files under `.isomorph`: the
22
- * integrations declaration, the kit lock, the governed-AI inventory, and the
23
- * journey checks directly in `.isomorph/checks/` as `.mjs`, `.js` or `.cjs`
24
- * modules (no subdirectories, no other extensions — evidence and reports
25
- * written next to them stay local). Everything else there (local state,
26
- * generated control files) stays local.
22
+ * integrations declaration, the kit lock and the app profile. Everything else
23
+ * there stays local — local state, and the generated `checks/` and
24
+ * `ai-inventory.json`, which the pipeline's kit lane regenerates from the
25
+ * source it builds (data plane ADR 0014); a builder's own checks are
26
+ * `tests/*.mjs` at the app root, ordinary source.
27
27
  */
28
28
  export function isKitCommittedPath(path) {
29
- return /(^|\/)\.isomorph\/(?:integrations\.json|kit\.lock\.json|app\.json|ai-inventory\.json|checks\/[^/]+\.(?:mjs|js|cjs))$/.test(path);
29
+ return /(^|\/)\.isomorph\/(?:integrations\.json|kit\.lock\.json|app\.json)$/.test(path);
30
30
  }
31
31
  /**
32
32
  * Dependency and internal directories the intake never stages: `node_modules`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.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.4.0"
40
+ "version": "0.5.1"
41
41
  }
42
42
  }
43
43
  }
@@ -1,150 +0,0 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
- import { join } from "node:path";
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
- */
8
- const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
9
- const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
10
- /**
11
- * The app's tables as the database holds them, from the catalog the kit gate
12
- * read after replaying the migrations, or `undefined` when that text is not a
13
- * catalog. `undefined` is not "no tables": nothing is generated from, or
14
- * removed because of, a schema Isomorph could not read — deleting an app's
15
- * retained checks on a failed query would be the worst possible reading of it.
16
- */
17
- export async function readAppSchema(catalogText, root) {
18
- const text = catalogText.trim();
19
- if (!text.startsWith("["))
20
- return undefined;
21
- let catalog;
22
- try {
23
- catalog = JSON.parse(text);
24
- }
25
- catch {
26
- return undefined;
27
- }
28
- if (!Array.isArray(catalog))
29
- return undefined;
30
- const migrations = await migrationTexts(root);
31
- const schema = new Map();
32
- for (const table of catalog) {
33
- schema.set(table.name, {
34
- columns: table.columns.map(column => ({
35
- name: column.name,
36
- type: columnType(column.base, column.category),
37
- declaredType: column.type,
38
- notNull: column.notnull,
39
- hasDefault: column.default !== null,
40
- generated: column.generated,
41
- identityDefault: IDENTITY.test(column.default ?? ""),
42
- references: column.fk,
43
- checks: column.checks
44
- })),
45
- primaryKey: table.primaryKey,
46
- ownerScoped: table.policies.some(policy => IDENTITY.test(policy)),
47
- ownerColumn: table.policies.map(policy => OWNER_SCOPE.exec(policy)?.[1]).find(Boolean),
48
- file: createdIn(table.name, migrations)
49
- });
50
- }
51
- return schema;
52
- }
53
- /**
54
- * Postgres has already reduced every declared spelling to one base type name
55
- * (`character varying` to `varchar`, `double precision` to `float8`, `BIGSERIAL`
56
- * to `int8` with a nextval default), so this is a lookup rather than a parse.
57
- */
58
- function columnType(base, category) {
59
- if (category === "A")
60
- return "array";
61
- switch (base) {
62
- case "text":
63
- case "varchar":
64
- case "bpchar":
65
- case "char":
66
- case "citext":
67
- case "name": return "text";
68
- case "uuid": return "uuid";
69
- case "bool": return "boolean";
70
- case "int2":
71
- case "int4":
72
- case "int8": return "integer";
73
- case "numeric":
74
- case "float4":
75
- case "float8":
76
- case "money": return "number";
77
- case "timestamptz":
78
- case "timestamp": return "timestamptz";
79
- case "date": return "date";
80
- case "time":
81
- case "timetz": return "time";
82
- case "json":
83
- case "jsonb": return "json";
84
- default: return "unknown";
85
- }
86
- }
87
- async function migrationTexts(root) {
88
- const directory = join(root, "migrations");
89
- const names = (await readdir(directory).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
90
- return Promise.all(names.map(async (name) => ({ name: `migrations/${name}`, text: await readFile(join(directory, name), "utf8").catch(() => "") })));
91
- }
92
- /** Which file created a table: the only question the catalog cannot answer, so the only one still asked of the SQL text. */
93
- function createdIn(table, migrations) {
94
- const pattern = new RegExp(String.raw `create\s+table\b[^;]*\b${table}\b`, "i");
95
- return migrations.find(migration => pattern.test(migration.text))?.name ?? "migrations/";
96
- }
97
- // ---- CHECK constraints ----------------------------------------------------------
98
- //
99
- // `pg_get_constraintdef` deparses, so these read one normal form rather than
100
- // everything an author might have typed: `BETWEEN a AND b` has already become
101
- // `>= a AND <= b`, `IN (…)` has become `= ANY (ARRAY[…])`, and each operand
102
- // carries its cast. Only the shapes a value has to satisfy to be accepted are
103
- // read; anything else is left alone, and a value the database rejects surfaces
104
- // as a failing journey under `isomorph check` in seconds rather than in the
105
- // pipeline minutes later.
106
- /** `char_length(col) >= n`, `length(col) <= n` — including the `((col)::text)` spelling a deparse gives a varchar. */
107
- export function textLengthBounds(checks, column) {
108
- let min = 1;
109
- let max = 500;
110
- const pattern = new RegExp(String.raw `(?:char_length|length|octet_length)\([^<>=]*\b${column}\b[^<>=]*(<=|<|>=|>|=)\s*(\d+)`, "gi");
111
- for (const check of checks)
112
- for (const match of check.matchAll(pattern)) {
113
- const value = Number(match[2]);
114
- if (match[1] === "<=" || match[1] === "=")
115
- max = Math.min(max, value);
116
- else if (match[1] === "<")
117
- max = Math.min(max, value - 1);
118
- else if (match[1] === ">=")
119
- min = Math.max(min, value);
120
- else
121
- min = Math.max(min, value + 1);
122
- }
123
- return { min, max: Math.max(max, min) };
124
- }
125
- /** `col = ANY (ARRAY['a'::text, 'b'::text])`: the closed set a value must come from. */
126
- export function allowedLiterals(checks, column) {
127
- for (const check of checks) {
128
- const list = new RegExp(String.raw `\b${column}\b[^=]*=\s*ANY\s*[\s(]*ARRAY\[([^\]]*)\]`, "i").exec(check);
129
- const values = [...(list?.[1] ?? "").matchAll(/'((?:[^']|'')*)'/g)].map(match => match[1].replace(/''/g, "'"));
130
- if (values.length)
131
- return values;
132
- }
133
- return [];
134
- }
135
- /** The smallest number `col > n` / `col >= n` accepts. */
136
- export function numericMinimum(checks, column) {
137
- let minimum = 1;
138
- for (const check of checks) {
139
- const comparison = new RegExp(String.raw `\b${column}\b[^<>=]*(>=|>)\s*(-?\d+(?:\.\d+)?)`, "i").exec(check);
140
- if (!comparison)
141
- continue;
142
- const value = Number(comparison[2]);
143
- minimum = Math.max(minimum, comparison[1] === ">" ? value + 1 : value);
144
- }
145
- return minimum;
146
- }
147
- /** Whether `col <= n` / `col < n` caps the column: the mirror of `numericMinimum`, asked so no invented value is written into a range the app's own CHECK does not admit. */
148
- export function hasNumericMaximum(checks, column) {
149
- return checks.some(check => new RegExp(String.raw `\b${column}\b[^<>=]*(?:<=|<)\s*-?\d+(?:\.\d+)?`, "i").test(check));
150
- }
@@ -1,135 +0,0 @@
1
- import { CLI_VERSION } from "./version.js";
2
- import { CliError } from "./output.js";
3
- /** The code for a refusal the server did not label. Its own words still reach
4
- * the builder; only the machine label is ours, and it says exactly that. */
5
- const UNLABELLED_REFUSAL = "ISOMORPH_REFUSED";
6
- /**
7
- * Isomorph labels part of its refusals `CODE: sentence` on the wire — `src/mcp.ts`
8
- * raises `APP_NOT_REGISTERED: …`, `SOURCE_UPLOAD_REQUIRED: …`, and the tool
9
- * result carries that text and nothing else. Lifting the prefix into `code` is
10
- * not classification: the label is the server's own, the sentence is kept
11
- * whole either way, and a miss only means the refusal goes out unlabelled.
12
- */
13
- const LABELLED = /^([A-Z][A-Z0-9_]{2,63}):[ \t]+(\S[\s\S]*)$/;
14
- function refusalText(result) {
15
- return (result.content ?? []).map(item => item.text ?? "").join("\n").trim();
16
- }
17
- /**
18
- * A tool refusal, carried whole: the server's own code, its own sentence and
19
- * its own fix. `structuredContent` is read first because that is where a code
20
- * belongs and where the server will put one once `toolError` carries one; today
21
- * only the labelled prefix is available.
22
- */
23
- export function serverRefusal(result) {
24
- const text = refusalText(result);
25
- const detail = result.structuredContent && typeof result.structuredContent === "object" ? result.structuredContent : undefined;
26
- const declared = typeof detail?.code === "string" ? detail.code.trim() : "";
27
- const hint = typeof detail?.remediationHint === "string" && detail.remediationHint.trim() ? detail.remediationHint.trim() : undefined;
28
- const labelled = declared ? null : LABELLED.exec(text);
29
- return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint, undefined, { layer: "governance" });
30
- }
31
- /** A JSON-RPC error is Isomorph refusing the call itself (an unknown method, a
32
- * body it could not parse). Its numeric code is the only one it has, so it is
33
- * reported as such rather than flattened away. */
34
- function rpcRefusal(error, status) {
35
- const text = error.message?.trim() ?? "";
36
- const labelled = LABELLED.exec(text);
37
- const code = labelled?.[1] ?? (typeof error.code === "number" ? `ISOMORPH_RPC_${error.code}` : `ISOMORPH_HTTP_${status}`);
38
- return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status}).`, undefined, undefined, undefined, { layer: "governance" });
39
- }
40
- /**
41
- * A second 401 in a row: the sign-in itself is gone, or this company does not
42
- * admit the signed-in account. The server says which in `error_description`
43
- * (plain English, no URL or token); an older server sends only the code, and
44
- * the fixed sentence stands in. Both fixes are named because the builder cannot
45
- * tell the two causes apart: on 2026-09-14 a `deploy` 14 seconds after a
46
- * successful `isomorph login` was refused by a company the account was not a
47
- * member of, and "run login again" would only have repeated it.
48
- */
49
- async function signInRefused(response) {
50
- const body = await response.json().catch(() => undefined);
51
- const said = typeof body?.error_description === "string" ? body.error_description.trim() : "";
52
- return new CliError("AUTH_REQUIRED", `${said || "Isomorph sign-in expired or was revoked."} Run \`isomorph login\` again, or \`isomorph connect <work-email-or-start-url>\` if this is the wrong company.`, undefined, undefined, undefined, { layer: "governance" });
53
- }
54
- async function readRpc(response) {
55
- try {
56
- return await response.json();
57
- }
58
- catch {
59
- throw new Error(`Isomorph returned a response that was not JSON (HTTP ${response.status}).`);
60
- }
61
- }
62
- export class RemoteMcpClient {
63
- url;
64
- tenant;
65
- id = 0;
66
- initialized = false;
67
- token;
68
- constructor(url, token, tenant) {
69
- this.url = url;
70
- this.tenant = tenant;
71
- this.token = typeof token === "string" ? async () => token : token;
72
- }
73
- get endpoint() { return this.url; }
74
- async initialize() {
75
- if (this.initialized)
76
- return;
77
- await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "isomorph-cli", version: CLI_VERSION } });
78
- await this.request("notifications/initialized", undefined, false);
79
- this.initialized = true;
80
- }
81
- async call(name, args) {
82
- const response = await this.request("tools/call", { name, arguments: args });
83
- const result = response;
84
- if (result.isError)
85
- throw serverRefusal(result);
86
- return result;
87
- }
88
- async request(method, params, expectResponse = true) {
89
- const body = JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, ...(params === undefined ? {} : { params }) });
90
- let response = await this.post(body);
91
- // A 401 mid-command usually means the stored token was rotated by another
92
- // process; re-resolve (which reloads the store) and retry once. A second
93
- // 401 is Isomorph refusing the sign-in, and `signInRefused` says why.
94
- if (response.status === 401)
95
- response = await this.post(body);
96
- if (response.status === 401)
97
- throw await signInRefused(response);
98
- if (!expectResponse)
99
- return undefined;
100
- const parsed = await readRpc(response);
101
- if (parsed.error)
102
- throw rpcRefusal(parsed.error, response.status);
103
- // A non-ok response with no JSON-RPC error in it is the network between us
104
- // and Isomorph, not Isomorph's answer, so it stays a plain Error: a long poll
105
- // treats plain Errors as transient and tries again (see `waitForSettled`),
106
- // and only `CliError` stops it. The wording is decided here rather than
107
- // guessed from this string downstream.
108
- if (!response.ok)
109
- throw new Error(`Isomorph request failed (HTTP ${response.status}).`);
110
- return parsed.result;
111
- }
112
- async post(body) {
113
- const headers = { "content-type": "application/json", "mcp-protocol-version": "2025-11-25", "x-harbour-tenant": this.tenant };
114
- const token = await this.token();
115
- if (token)
116
- headers.authorization = `Bearer ${token}`;
117
- try {
118
- return await fetch(this.url, { method: "POST", headers, body });
119
- }
120
- catch (error) {
121
- // Plain Error for the same reason as the non-ok branch: a dropped
122
- // connection mid-deployment is transient and the poll must be free to
123
- // retry. `redact()` strips the URL before any of this is printed.
124
- throw new Error(`Isomorph could not be reached (${error instanceof Error ? error.message : "no details"}).`);
125
- }
126
- }
127
- }
128
- export function structured(result) {
129
- if (result.isError)
130
- throw serverRefusal(result);
131
- return result.structuredContent;
132
- }
133
- export function plainText(result) {
134
- return result.content?.map(item => item.text ?? "").join("\n") ?? "";
135
- }