@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.
- package/README.md +9 -6
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +42 -103
- package/dist/packages/harbour-cli/src/cli.js +54 -34
- package/dist/packages/harbour-cli/src/deploy.js +128 -330
- package/dist/packages/harbour-cli/src/guide.js +2 -2
- package/dist/packages/harbour-cli/src/integrations.js +66 -10
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +12 -12
- package/dist/packages/harbour-cli/src/kit.js +5 -3
- package/dist/packages/harbour-cli/src/local-runtime.js +9 -8
- package/dist/packages/harbour-cli/src/operations.js +101 -71
- package/dist/packages/harbour-cli/src/output.js +11 -2
- package/dist/packages/harbour-cli/src/package.js +101 -0
- package/dist/packages/harbour-cli/src/starter.js +44 -22
- package/dist/packages/harbour-cli/src/upload.js +8 -2
- package/dist/src/analyzer.js +3 -17
- package/dist/src/secret-paths.js +6 -6
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/app-schema.js +0 -150
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +0 -135
- package/dist/packages/harbour-cli/src/retained-checks.js +0 -443
- package/dist/packages/harbour-cli/src/source-inventory.js +0 -239
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* database it replays the migrations into
|
|
134
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/src/analyzer.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
package/dist/src/secret-paths.js
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
}
|