@isomorph.ai/cli 0.11.0 → 0.11.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 +2 -2
- package/dist/packages/harbour-cli/src/agent-setup.js +2 -2
- package/dist/packages/harbour-cli/src/check.js +12 -1
- package/dist/packages/harbour-cli/src/cli.js +13 -7
- package/dist/packages/harbour-cli/src/deploy.js +38 -3
- package/dist/packages/harbour-cli/src/guide.js +1 -1
- package/dist/packages/harbour-cli/src/integrations.js +4 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/operations.js +23 -3
- package/dist/packages/harbour-cli/src/starter.js +55 -28
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The only step you do yourself is the company sign-in: when the agent runs `isomo
|
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
isomorph agent-setup install the `isomorph` skill for Claude Code and Codex; idempotent
|
|
31
|
-
isomorph init --app-root <path> [--adopt] [--upgrade] [--json] starter app in an empty folder (also runs agent-setup); a folder with an app and no kit is refused with
|
|
31
|
+
isomorph init --app-root <path> [--adopt] [--upgrade] [--json] starter app in an empty folder or around data files (also runs agent-setup); a folder with an app and no kit is refused with the choices that can succeed (APP_EXISTS); --adopt adds the kit files to a Vite + React app in place; --upgrade re-pins the kit bundle and runs the checks
|
|
32
32
|
isomorph package --app-root <path> [--out <file>] [--json] zip an app built without the kit for the console's Upload package (local; .env files included, node_modules/build output/.git left out)
|
|
33
33
|
isomorph dev --app-root <path> [--reset] [--detach] [--json] run the app locally on one loopback origin; --detach starts it in the background and prints {origin, pid} once it answers
|
|
34
34
|
isomorph stop --app-root <path> stop local services, keep data
|
|
@@ -47,7 +47,7 @@ isomorph status | retry | promote [--app-root <path>] [--operation <reference>]
|
|
|
47
47
|
|
|
48
48
|
`promote` never prompts: `--confirm-tested` is the one flag that means the person has opened the preview and it works. (`productionise`, the command's name until 0.3.4, was accepted as an alias for one release and is no longer a command.)
|
|
49
49
|
|
|
50
|
-
There are two ways onto Isomorph and `init` is where the folder decides. An empty folder (or one holding only `.git`, a README, a licence or editor and agent files) gets the starter. A folder that already is a kit app (it has `.isomorph/kit.lock.json`) is kept as it is and told its next commands (`dev`, `check`, `deploy`). A folder with an app and no kit is never adopted silently: `init` refuses with exit 2, `APP_EXISTS`, and the
|
|
50
|
+
There are two ways onto Isomorph and `init` is where the folder decides. An empty folder (or one holding only `.git`, a README, a licence, `node_modules` or editor and agent files) gets the starter. So does a folder that holds only data — no `package.json`, no `src/`, no index or source file (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.html`): the starter is created around the files, none is overwritten, and one line names them (`Kept 2 existing files (data.csv, rows.csv); read them from the app.`). A folder that already is a kit app (it has `.isomorph/kit.lock.json`) is kept as it is and told its next commands (`dev`, `check`, `deploy`). A folder with an app and no kit is never adopted silently: `init` refuses with exit 2, `APP_EXISTS`, and the choices that can succeed, in order — `isomorph init --app-root <dir> --adopt` (add the kit in place; offered only when `package.json` depends on vite and react, otherwise `APP_UNSUPPORTED`), `isomorph init --app-root <new-empty-folder>` (start a new kit app), then `isomorph package --app-root <dir>` (package the app as it is for the console import). With `--json` the choices are in `error.details.choices` as `{ id, command, description }`, so an agent relays them and runs the one the person picks. `package` is local (no company, no sign-in): it scans the tree the way `deploy` does, includes the app's `.env` files (the console reads their values into encrypted defaults and never commits the files), refuses private keys and other secret-bearing paths, and writes a deterministic `isomorph-import.zip` (or `--out <file>`) whose result names the path, file count, bytes, sha256 and the one next step: upload it in the console (Add app → Upload package), or connect the repository there instead. A kit app is refused (`KIT_APP`): it deploys with `isomorph deploy`.
|
|
51
51
|
|
|
52
52
|
`isomorph --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
|
|
53
53
|
|
|
@@ -108,7 +108,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
108
108
|
|
|
109
109
|
## Getting ready
|
|
110
110
|
|
|
111
|
-
- No \`.isomorph/\` yet: \`isomorph init --app-root .\` (
|
|
111
|
+
- No \`.isomorph/\` yet: \`isomorph init --app-root .\` (an existing app: relay its choices and ask; never run \`package\` unasked). Node 22+, no Docker. The starter is a notes-and-files placeholder: before the first \`dev\`, replace \`src/App.tsx\`, \`index.html\`'s title and \`migrations/0001_notes.sql\` with what they asked, and say so in one line (applied migrations freeze after the first deploy). Then propose a \`name\` and one-sentence \`description\` from the request, write them into \`.isomorph/app.json\`, and show both in your next reply asking for corrections: IT sees the app under exactly these, once, on its first approval.
|
|
112
112
|
- Sign-in, only when a command answers \`CONFIG_REQUIRED\` or \`AUTH_REQUIRED\` (most machines already are): \`isomorph connect <work-email>\`, then \`isomorph login\` — the browser opens and the person finishes there (their one step; say so).
|
|
113
113
|
|
|
114
114
|
## Intent → command
|
|
@@ -120,7 +120,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
120
120
|
| "does it work?" (and before reporting anything as working) | open the dev link in your own browser, press the control you built or changed, read what the app shows; a green \`isomorph check\` is not that proof (fixtures answer AI and company systems). A Send in development is real: reuse explicit authorization or ask once. No browser: say the button is untested. |
|
|
121
121
|
| "I need Slack / Gmail / the warehouse" | read \`integrations.md\` beside this skill first; then: catalog, write the call (literals only), \`isomorph check\`, then submit the request; say READY or PENDING in one line. |
|
|
122
122
|
| "summarise", "draft", "AI" | read \`ai.md\` beside this skill first; then one call behind a control they press. |
|
|
123
|
-
| "ship it", "let my team try it" | show the person the three values in \`.isomorph/app.json\` (name, description, audience — "only you" when empty) and correct the file as they say; then \`isomorph deploy --app-root . --json\` (it runs the checks when needed and refuses once naming every blocker and its fix: relay that line; the app works meanwhile). To give someone access later: add their email to \`audience\`, then \`isomorph share --app-root . --json\`, never \`deploy\`. Give them \`result.deployment.protectedUrl\` and \`result.consoleUrl\`
|
|
123
|
+
| "ship it", "let my team try it" | show the person the three values in \`.isomorph/app.json\` (name, description, audience — "only you" when empty), ask who else should be able to open it (colleagues' work emails only; no groups or whole company yet) and correct the file as they say; then \`isomorph deploy --app-root . --json\` (it runs the checks when needed and refuses once naming every blocker and its fix: relay that line; the app works meanwhile). To give someone access later: add their email to \`audience\`, then \`isomorph share --app-root . --json\`, never \`deploy\`. Give them \`result.deployment.protectedUrl\` as "your app, a private preview — open it and try it" and \`result.consoleUrl\` as "the Isomorph console page for this deployment: its checks and status". If \`result.ai\` is present, relay it in one line. \`--session-notes\`: what cost you time in Isomorph, or \`""\`. |
|
|
124
124
|
| "make it live" | only after they have tried the preview: \`isomorph promote --operation <ref> --app-root . --confirm-tested --json\`. Report the production link, or that operator approval is pending. |
|
|
125
125
|
| "stop it" | \`isomorph stop --app-root .\` (data kept); \`isomorph dev --app-root . --reset\` only when they ask to start over. |
|
|
126
126
|
| "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first (follow its in-conversation owner approval steps); write the job, run it once with \`isomorph jobs run\`, and report what was verified. |
|
|
@@ -57,6 +57,13 @@ export async function runChecks(root, options) {
|
|
|
57
57
|
const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}), ...(options.pollMs ? { pollMs: options.pollMs } : {}) });
|
|
58
58
|
for (const check of gate.checks)
|
|
59
59
|
record(check);
|
|
60
|
+
// A green ai journey reads as "AI works here" unless it is said what answered
|
|
61
|
+
// it (a builder was told the check "simulated the AI reply" and could not
|
|
62
|
+
// tell whether AI was integrated, 2026-09-26). The gate's own detail names
|
|
63
|
+
// the canned reply; this is the one plain line that says what that means.
|
|
64
|
+
const ai = gate.checks.some(cannedAi) ? "canned" : "not tested";
|
|
65
|
+
if (ai === "canned")
|
|
66
|
+
output(AI_CANNED_LINE);
|
|
60
67
|
let integrations = "not tested";
|
|
61
68
|
if (options.governance) {
|
|
62
69
|
// Reads that could not run are one check the report carries as not_run with
|
|
@@ -96,12 +103,16 @@ export async function runChecks(root, options) {
|
|
|
96
103
|
checks,
|
|
97
104
|
gate,
|
|
98
105
|
integrations,
|
|
99
|
-
ai
|
|
106
|
+
ai,
|
|
100
107
|
passed: checks.every(check => check.status !== "fail") && (integrations === "not tested" || integrations.every(item => item.status !== "fail"))
|
|
101
108
|
};
|
|
102
109
|
await writeFile(kitPaths(root).report, `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
|
|
103
110
|
return report;
|
|
104
111
|
}
|
|
112
|
+
/** What the check's canned AI reply means, in the builder's terms, printed once after the gate. */
|
|
113
|
+
export const AI_CANNED_LINE = "AI in this check: answered by a canned reply (call shape checked, not a real model). Real AI runs when you use the app from `isomorph dev` or the preview link.";
|
|
114
|
+
/** The gate's own wording for a journey whose governed AI call the canned fixture answered (data plane kit_gate.go `fixtureAnsweredDetail`). */
|
|
115
|
+
const cannedAi = (check) => check.name.startsWith("journey:") && check.status === "pass" && (check.detail ?? "").includes("governed AI was answered by the gate's canned completion");
|
|
105
116
|
function renderCheck(check) {
|
|
106
117
|
const mark = check.status === "pass" ? "ok " : check.status === "fail" ? "FAIL" : "skip";
|
|
107
118
|
return `${mark} ${check.name}${check.detail ? ` — ${indentLines(check.detail)}` : ""}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { appCallsAi, deploy, readDeployNote } from "./deploy.js";
|
|
2
|
+
import { appCallsAi, deploy, describeAi, readDeployNote } from "./deploy.js";
|
|
3
3
|
import { continueCommand, follow, promoteToProduction, retryDeployment, statusFetch, summarize } from "./operations.js";
|
|
4
4
|
import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
|
|
5
5
|
import { CLI_VERSION } from "./version.js";
|
|
@@ -7,7 +7,7 @@ import { alreadySignedIn, connectedAccount, loadStoredToken, login, logout, open
|
|
|
7
7
|
import { assertCliCurrent, CLI_INSTALL_COMMAND, cliStaleness, companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
|
|
8
8
|
import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
|
|
9
9
|
import { appRoot, assertLockCompany, readAppProfile, readKitLock, requestResourceName } from "./kit.js";
|
|
10
|
-
import { initKit } from "./starter.js";
|
|
10
|
+
import { initKit, keptDataLine } from "./starter.js";
|
|
11
11
|
import { packageApp } from "./package.js";
|
|
12
12
|
import { agentPaths, agentSetup } from "./agent-setup.js";
|
|
13
13
|
import { detachDev, startDev } from "./dev.js";
|
|
@@ -33,7 +33,7 @@ const reset = args.includes("--reset");
|
|
|
33
33
|
/** `dev --detach`: start the services, print the origin, exit while they keep running. */
|
|
34
34
|
const detach = args.includes("--detach");
|
|
35
35
|
const upgrade = args.includes("--upgrade");
|
|
36
|
-
/** `init --adopt`: add the kit to the app already in the folder (Vite + React only). Without it a folder with an app and no kit is refused with its
|
|
36
|
+
/** `init --adopt`: add the kit to the app already in the folder (Vite + React only). Without it a folder with an app and no kit is refused with its choices. */
|
|
37
37
|
const adopt = args.includes("--adopt");
|
|
38
38
|
/** `package --out`: where the console import package is written (default `<app-root>/isomorph-import.zip`). */
|
|
39
39
|
const out = optionValue("--out");
|
|
@@ -57,7 +57,7 @@ const usage = [
|
|
|
57
57
|
" isomorph connect <work-email | company-start-url>",
|
|
58
58
|
" isomorph login | logout",
|
|
59
59
|
" isomorph agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
|
|
60
|
-
" isomorph init --app-root <path> [--adopt] [--upgrade] [--json] create the starter in an empty folder (also runs agent-setup); a folder with an app and no kit is refused with
|
|
60
|
+
" isomorph init --app-root <path> [--adopt] [--upgrade] [--json] create the starter in an empty folder, or around data files (CSVs and the like are kept and named; also runs agent-setup); a folder with an app (package.json or source files) and no kit is refused with the choices that can succeed; --adopt adds the kit files to a Vite + React app in place; --upgrade re-pins the kit bundle and runs the checks",
|
|
61
61
|
" isomorph package --app-root <path> [--out <file>] [--json] zip an app built without the kit for the Isomorph console's Upload package (local, no sign-in; .env files included, node_modules/build output/.git left out; kit apps use deploy)",
|
|
62
62
|
" isomorph dev --app-root <path> [--reset] [--detach] [--json] run the app locally on one loopback origin (--reset deletes this app's local data; --detach starts it in the background and prints the origin and pid once it answers)",
|
|
63
63
|
" isomorph stop --app-root <path> stop this app's local services, keeping its database and files",
|
|
@@ -150,7 +150,9 @@ else {
|
|
|
150
150
|
progress(line);
|
|
151
151
|
// The SDK is a normal dependency: one plain `npm install` when node_modules lacks it or holds one outside the range init wrote.
|
|
152
152
|
await installDependencies(target, bundle, runCommand, progress);
|
|
153
|
-
|
|
153
|
+
if (result.dataFiles.length)
|
|
154
|
+
progress(keptDataLine(result.dataFiles));
|
|
155
|
+
progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Both agents read the Isomorph block in CLAUDE.md / AGENTS.md and the `isomorph` skill installed by agent-setup. The starter page is a placeholder for the app you asked for."
|
|
154
156
|
: upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.")
|
|
155
157
|
: result.mode === "kit" ? `This folder is already an Isomorph kit app (any missing kit file was added; nothing else was changed). Next: \`isomorph dev --app-root ${root}\`, \`isomorph check --app-root ${root}\`, \`isomorph deploy --app-root ${root}\`.`
|
|
156
158
|
: "Kit files added; existing files were kept.");
|
|
@@ -298,17 +300,21 @@ else {
|
|
|
298
300
|
if (!ref)
|
|
299
301
|
throw new CliError("OPERATION_REQUIRED", "No deployment has been started from this folder yet.", undefined, "Run `isomorph deploy --app-root .`, or pass --operation <reference>.");
|
|
300
302
|
let summary;
|
|
303
|
+
let ai;
|
|
301
304
|
if (command === "status")
|
|
302
305
|
summary = wait ? await follow(statusFetch(governance, appId, ref), ref, progress, waitOptions) : summarize(await governance.deploymentStatus(appId, ref));
|
|
303
306
|
else if (command === "retry")
|
|
304
307
|
summary = await retryDeployment(governance, appId, ref, progress, { wait: !noWait, waitOptions });
|
|
305
308
|
else {
|
|
306
309
|
// Production has its own lane and its own AI mode: the same one-call pre-flight runs for production before promoting.
|
|
307
|
-
|
|
310
|
+
const callsAi = await appCallsAi(target);
|
|
311
|
+
await assertDeployReady(target, governance, tenant, EMBEDDED_KIT_BUNDLE, "production", progress, callsAi);
|
|
312
|
+
if (callsAi)
|
|
313
|
+
ai = await describeAi(governance, appId, "production", progress);
|
|
308
314
|
const profile = await readAppProfile(target);
|
|
309
315
|
summary = await promoteToProduction(governance, appId, ref, { name: profile.name, description: profile.description, audience: profile.audience }, progress, { wait: !noWait, waitOptions, confirmedTested: confirmTested });
|
|
310
316
|
}
|
|
311
|
-
envelope = operationEnvelope(summary, ref, CLI_VERSION);
|
|
317
|
+
envelope = operationEnvelope({ ...summary, ...(ai ? { ai } : {}) }, ref, CLI_VERSION);
|
|
312
318
|
}
|
|
313
319
|
emit(envelope, line);
|
|
314
320
|
}
|
|
@@ -124,15 +124,22 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
124
124
|
throw error;
|
|
125
125
|
}
|
|
126
126
|
const { operationRef } = opened;
|
|
127
|
+
// The preflight passed, so a company whose AI setup is not ready has already
|
|
128
|
+
// been refused; what the builder never heard is the affirmative. Said once, here.
|
|
129
|
+
const ai = body.callsAi ? await describeAi(governance, appId, "preview", output) : undefined;
|
|
127
130
|
await writeDeployNote(root, { schema: "isomorph.deploy-note/1.0", tenantId, operationRef, sha256: archive.digest });
|
|
128
131
|
if (opened.resumed && note?.sha256 !== archive.digest)
|
|
129
132
|
output(`Isomorph is continuing operation ${operationRef}; no new deployment was started.`);
|
|
130
133
|
output(`Isomorph operation saved. Continue with ${continueCommand(operationRef)}.`);
|
|
131
134
|
// Everything Isomorph checks, fixes and provisions for this version is shown
|
|
132
|
-
// live in the console
|
|
135
|
+
// live in the console. The browser is opened only on the app's first deploy
|
|
136
|
+
// from this folder for this company (no deploy note yet): in an agent's
|
|
137
|
+
// build loop nearly every deploy is a new operation — any edit, even a re-run
|
|
138
|
+
// of `check`, changes the package digest — and a tab per operation left a
|
|
139
|
+
// builder with four in one day. Later deploys print the link only.
|
|
133
140
|
const consoleUrl = consoleOperationUrl(tenantId, appId, operationRef);
|
|
134
141
|
output(`Watch Isomorph check, secure and deploy this app: ${consoleUrl}`);
|
|
135
|
-
if (!opened.resumed) {
|
|
142
|
+
if (!opened.resumed && !note) {
|
|
136
143
|
try {
|
|
137
144
|
options.openConsole?.(consoleUrl);
|
|
138
145
|
}
|
|
@@ -167,7 +174,7 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
167
174
|
}
|
|
168
175
|
output(outcomeLine(summary));
|
|
169
176
|
}
|
|
170
|
-
return { cliVersion: CLI_VERSION, operationRef, result: { ...summary, consoleUrl } };
|
|
177
|
+
return { cliVersion: CLI_VERSION, operationRef, result: { ...summary, consoleUrl, ...(ai ? { ai } : {}) } };
|
|
171
178
|
}
|
|
172
179
|
catch (error) {
|
|
173
180
|
throw await operationFailure(fetch, operationRef, error);
|
|
@@ -355,6 +362,34 @@ export async function appCallsAi(root) {
|
|
|
355
362
|
const callsites = report?.gate?.inventory?.aiCallsites;
|
|
356
363
|
return Array.isArray(callsites) && callsites.length > 0;
|
|
357
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* One plain line on whether this app's AI calls are real once it is deployed
|
|
367
|
+
* to the environment, printed for an app that calls governed AI. A builder was
|
|
368
|
+
* told by their agent that the check "simulated the AI reply" and could not
|
|
369
|
+
* tell whether AI was integrated (2026-09-26): the check's journey is answered
|
|
370
|
+
* by a canned reply, `deploy` refused a company without AI (`AI_NOT_READY`)
|
|
371
|
+
* but said nothing when the setup was fine. Grounded in governance's readiness
|
|
372
|
+
* route, which answers the same question the preflight asked and its
|
|
373
|
+
* `plainEnglish` when the answer is no. The readiness call is best-effort: a
|
|
374
|
+
* platform without the route, or a transport fault, falls back to what the
|
|
375
|
+
* passed preflight already established, and says that it is inferred.
|
|
376
|
+
*/
|
|
377
|
+
export async function describeAi(governance, appId, environment, output) {
|
|
378
|
+
const link = environment === "preview" ? "the preview link" : "the production link";
|
|
379
|
+
let readiness;
|
|
380
|
+
try {
|
|
381
|
+
readiness = await governance.aiReadiness(appId, environment);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
readiness = undefined;
|
|
385
|
+
}
|
|
386
|
+
if (readiness && !readiness.ready) {
|
|
387
|
+
output(`AI: not enabled for this company's ${environment} apps — ${readiness.plainEnglish}`);
|
|
388
|
+
return { enabled: false, environment, reason: readiness.plainEnglish };
|
|
389
|
+
}
|
|
390
|
+
output(`AI: enabled for this company's ${environment} apps${readiness ? "" : " (inferred: the deploy pre-flight passed)"} — this app's AI calls are real from ${link}.`);
|
|
391
|
+
return { enabled: true, environment };
|
|
392
|
+
}
|
|
358
393
|
/** The three values as the command prints them before it starts. */
|
|
359
394
|
export function describeProfile(profile) {
|
|
360
395
|
return [`App name: ${profile.name}`, `Description: ${profile.description || "(none yet)"}`, `Audience: ${profile.audience.length ? profile.audience.join(", ") : "only you"}`];
|
|
@@ -88,7 +88,7 @@ AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxToke
|
|
|
88
88
|
const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
|
|
89
89
|
\`\`\`
|
|
90
90
|
|
|
91
|
-
The starter calls no AI; add the one call when the person asks. The check's generated ai journey
|
|
91
|
+
The starter calls no AI; add the one call when the person asks. The check's generated ai journey is answered by a canned reply everywhere (locally and in the pipeline): it proves the call shape, never a model. Real AI runs from the app under \`isomorph dev\` and from the preview link.`,
|
|
92
92
|
jobs: `# Scheduled jobs
|
|
93
93
|
|
|
94
94
|
Jobs run as the app. Gmail and user-mode Slack require the current owner's connected account, an approved integration grant and explicit background permission in each environment. App-mode Slack needs no personal background consent.
|
|
@@ -47,6 +47,10 @@ export class GovernanceClient {
|
|
|
47
47
|
deployPreflight(appId, body) {
|
|
48
48
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
|
|
49
49
|
}
|
|
50
|
+
/** Read-only: would the company's AI setup let this app's deployment PLAN pass for the environment? The same seam the deploy preflight asks beside the other blockers; `deploy` and `promote` read it to say in plain words whether the app's AI calls are real once deployed. */
|
|
51
|
+
aiReadiness(appId, environment) {
|
|
52
|
+
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/ai/readiness`, { environment });
|
|
53
|
+
}
|
|
50
54
|
/**
|
|
51
55
|
* What the building agent says cost it time in Isomorph itself. Never blocks a
|
|
52
56
|
* deployment: the caller discards every failure, and an empty note files nothing.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "isomorph.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.
|
|
3
|
+
"kitVersion": "0.11.1",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@isomorph.ai/app-sdk",
|
|
6
6
|
"version": "1.4.3",
|
|
@@ -8,21 +8,21 @@ export const PUBLISHED_KIT_BUNDLE = {
|
|
|
8
8
|
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:56489ae64e0765e0576172c5f0997230a3663cbe033608239de232c11aab0791"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:a22dfa2b2ff5780e0e2ea7e949216c1703123442f8e2d1e519d5a8493bb5a450",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:9ad3a92fed4584bc2b43ebb5819ae729d333f8ed8c9930faabeaf8c0d15924ce"
|
|
13
13
|
},
|
|
14
14
|
"nativeRuntime": {
|
|
15
15
|
"darwinArm64": {
|
|
16
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
17
|
-
"sha256": "
|
|
16
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:f753062e2cf21f52f7010152fbcb1d46d1d4e9cf819c608f0e66afaa39e61cd0",
|
|
17
|
+
"sha256": "f753062e2cf21f52f7010152fbcb1d46d1d4e9cf819c608f0e66afaa39e61cd0"
|
|
18
18
|
},
|
|
19
19
|
"linuxX64": {
|
|
20
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
21
|
-
"sha256": "
|
|
20
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:1938c28a92fd3fe5054c6b732bc6fe1a6a31728f9a0e416d653db1ef3726317f",
|
|
21
|
+
"sha256": "1938c28a92fd3fe5054c6b732bc6fe1a6a31728f9a0e416d653db1ef3726317f"
|
|
22
22
|
},
|
|
23
23
|
"windowsX64": {
|
|
24
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
25
|
-
"sha256": "
|
|
24
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:244a255159a0c641875b22aa550622ff5345e5c129c40802d7609bf375245c1b",
|
|
25
|
+
"sha256": "244a255159a0c641875b22aa550622ff5345e5c129c40802d7609bf375245c1b"
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"brief": {
|
|
@@ -197,11 +197,31 @@ function activity(status) {
|
|
|
197
197
|
return "deploying the app";
|
|
198
198
|
return saveInFlight(status) ? "saving the app" : "preparing the deployment";
|
|
199
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The deployment as a builder reads it: which of four steps is running and
|
|
202
|
+
* roughly how long it takes. Keyed by `deployment.status`, the data plane's
|
|
203
|
+
* version state (`deploymentStatuses` in governance); the control plane's own
|
|
204
|
+
* phase sentence rides along in parentheses so an agent, and anyone debugging,
|
|
205
|
+
* still sees the finer phase. A status outside the table — LIVE, FAILED,
|
|
206
|
+
* WAITING_INPUT, or none at all — keeps the plain line.
|
|
207
|
+
*/
|
|
208
|
+
const DEPLOYMENT_STEPS = [
|
|
209
|
+
[["QUEUED", "ANALYZING"], "Step 1 of 4: Isomorph is getting a build slot ready, usually under a minute"],
|
|
210
|
+
[["TRANSFORMING", "VALIDATING", "BUILDING"], "Step 2 of 4: Isomorph is building the app and re-running its checks in the cloud, about 3–5 minutes"],
|
|
211
|
+
[["VERIFYING"], "Step 3 of 4: Isomorph is setting up the app's database, files and sign-in"],
|
|
212
|
+
[["GATING"], "Step 4 of 4: final security check, then the preview link switches on"]
|
|
213
|
+
];
|
|
214
|
+
/** One line for a deployment in flight: the builder's step when the status names one, then the server's phase in parentheses. */
|
|
215
|
+
export function deploymentProgressLine(deployment) {
|
|
216
|
+
const step = DEPLOYMENT_STEPS.find(([statuses]) => statuses.includes(deployment.status ?? ""))?.[1] ?? "Isomorph is deploying the app";
|
|
217
|
+
return `${step}${deployment.message ? ` (${deployment.message})` : ""}.`;
|
|
218
|
+
}
|
|
200
219
|
/** What the server is doing right now, as one line; printed when it changes. */
|
|
201
220
|
function progressLine(status) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
221
|
+
if (status.production)
|
|
222
|
+
return `Isomorph is ${activity(status)}${status.production.message ? ` (${status.production.message})` : ""}.`;
|
|
223
|
+
if (status.deployment)
|
|
224
|
+
return deploymentProgressLine(status.deployment);
|
|
205
225
|
switch (status.operation?.stage) {
|
|
206
226
|
case "inspection": return "Isomorph is inspecting the app package.";
|
|
207
227
|
case "verification": return "Isomorph is verifying the saved app.";
|
|
@@ -1,51 +1,75 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { dirname, extname, join } from "node:path";
|
|
3
3
|
import { bundleDiff, sdkDependencyRange } from "./kit-bundle.js";
|
|
4
4
|
import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
|
|
5
5
|
import { defaultAppProfile, emptyDeclaration, newKitLock, readKitLock, renderAppProfile, writeKitLock } from "./kit.js";
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
7
|
export { MANAGED_END, MANAGED_START };
|
|
8
8
|
/**
|
|
9
|
-
* Files and folders that do not make a folder "an app": what an editor, git or
|
|
10
|
-
* agent leaves in an otherwise empty folder.
|
|
11
|
-
* without a kit lock is an app that was built without the kit.
|
|
9
|
+
* Files and folders that do not make a folder "an app": what an editor, git, npm or
|
|
10
|
+
* an agent leaves in an otherwise empty folder.
|
|
12
11
|
*/
|
|
13
|
-
const IGNORABLE_ENTRIES = new Set([".git", ".DS_Store", ".gitignore", "README.md", "LICENSE", "CLAUDE.md", "AGENTS.md", ".claude", ".codex", ".vscode", ".idea"]);
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
const IGNORABLE_ENTRIES = new Set([".git", ".DS_Store", ".gitignore", "README.md", "LICENSE", "CLAUDE.md", "AGENTS.md", ".claude", ".codex", ".vscode", ".idea", "node_modules"]);
|
|
13
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".html"]);
|
|
14
|
+
/**
|
|
15
|
+
* Whether a top-level entry makes the folder "an app that was built without the
|
|
16
|
+
* kit": a package manifest, a `src/` folder, an index file or a source file. Anything
|
|
17
|
+
* else (CSVs, JSON, images, notes) is data the starter is created around.
|
|
18
|
+
*/
|
|
19
|
+
function isAppEntry(name) {
|
|
20
|
+
return name === "package.json" || name === "src" || /^index\./.test(name) || SOURCE_EXTENSIONS.has(extname(name));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The ways forward for a folder with an app and no kit, as the `APP_EXISTS` refusal
|
|
24
|
+
* carries them (`details.choices`), in the order they can succeed: `adopt` only when
|
|
25
|
+
* the app is one the kit can be added to (package.json depends on vite and react),
|
|
26
|
+
* then a new kit app in a new folder, then the console import of the app as it is.
|
|
27
|
+
*/
|
|
28
|
+
export function appExistsChoices(dir, adoptable) {
|
|
16
29
|
return [
|
|
17
|
-
{ id: "adopt", command: `isomorph init --app-root ${dir} --adopt`, description: "add the kit to this
|
|
30
|
+
...(adoptable ? [{ id: "adopt", command: `isomorph init --app-root ${dir} --adopt`, description: "add the kit to this Vite + React app in place" }] : []),
|
|
18
31
|
{ id: "new", command: "isomorph init --app-root <new-empty-folder>", description: "start a new kit app in a new empty folder" },
|
|
19
32
|
{ id: "package", command: `isomorph package --app-root ${dir}`, description: "package this app as it is for import in the Isomorph console" }
|
|
20
33
|
];
|
|
21
34
|
}
|
|
35
|
+
/** The one line `init` prints for a starter created around existing data files, so the person and the agent know they are there. */
|
|
36
|
+
export function keptDataLine(dataFiles) {
|
|
37
|
+
return `Kept ${dataFiles.length} existing file${dataFiles.length === 1 ? "" : "s"} (${dataFiles.join(", ")}); read them from the app.`;
|
|
38
|
+
}
|
|
22
39
|
/**
|
|
23
|
-
* Creates the starter in an empty directory
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
40
|
+
* Creates the starter in an empty directory or one that holds only data (no
|
|
41
|
+
* package.json, no source: the data stays and is named), adds the missing kit
|
|
42
|
+
* files to an existing Vite + React app (`adopt`), or keeps a kit app as it is.
|
|
43
|
+
* A folder with an app and no kit is never adopted silently: the refusal names
|
|
44
|
+
* the choices that can succeed and the agent relays them to the person. User
|
|
45
|
+
* files are never overwritten: a path that exists is reported as kept.
|
|
46
|
+
* Instruction files get a managed block that points at the rules, and the
|
|
47
|
+
* user-level agent guide (the rules themselves) is installed so the agents know
|
|
48
|
+
* the kit from any folder.
|
|
30
49
|
*/
|
|
31
50
|
export async function initKit(root, bundle, options = {}) {
|
|
32
51
|
await mkdir(root, { recursive: true });
|
|
33
52
|
const existingPackage = await readJson(join(root, "package.json"));
|
|
34
53
|
const previous = await readKitLock(root);
|
|
35
|
-
const
|
|
54
|
+
const entries = (await readdir(root)).filter(name => !IGNORABLE_ENTRIES.has(name)).sort();
|
|
55
|
+
const appHere = entries.some(isAppEntry);
|
|
56
|
+
// No app and no kit here: the starter is created, around whatever data the folder holds.
|
|
57
|
+
const starter = !previous && !appHere;
|
|
36
58
|
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
|
-
|
|
59
|
+
const adoptable = Boolean(existingPackage && isSupportedApp(existingPackage));
|
|
60
|
+
if (appHere && !previous && !options.upgrade) {
|
|
38
61
|
// Refused before anything is written, here or in the person's home: the choice is theirs.
|
|
39
|
-
const
|
|
40
|
-
const choices = appExistsChoices(dir);
|
|
62
|
+
const choices = appExistsChoices(options.displayRoot ?? root, adoptable);
|
|
41
63
|
if (!options.adopt)
|
|
42
|
-
throw new CliError("APP_EXISTS", "This folder already has an app and no Isomorph kit.", undefined,
|
|
43
|
-
|
|
64
|
+
throw new CliError("APP_EXISTS", "This folder already has an app and no Isomorph kit.", undefined, adoptable
|
|
65
|
+
? "Choose one of the three commands below: add the kit to this app in place (--adopt), start a new kit app in an empty folder, or package this app as it is for the console import."
|
|
66
|
+
: "Choose one of the two commands below: start a new kit app in an empty folder, or package this app as it is for the console import. --adopt does not apply: it needs a package.json that depends on vite and react.", undefined, { details: { choices } });
|
|
67
|
+
if (!adoptable)
|
|
44
68
|
throw unsupported();
|
|
45
69
|
}
|
|
46
|
-
else if (existingPackage && !
|
|
70
|
+
else if (existingPackage && !adoptable)
|
|
47
71
|
throw unsupported();
|
|
48
|
-
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : previous ? "kit" :
|
|
72
|
+
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : previous ? "kit" : starter ? "starter" : "existing", bundleChanges: [], dataFiles: starter ? entries : [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
|
|
49
73
|
const write = async (path, content) => {
|
|
50
74
|
const absolute = join(root, path);
|
|
51
75
|
if (await exists(absolute)) {
|
|
@@ -58,7 +82,7 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
58
82
|
};
|
|
59
83
|
// The starter (its source and schema) is created only when there is no app here
|
|
60
84
|
// yet; kit infrastructure is written on every path, including `--upgrade`.
|
|
61
|
-
const appFiles =
|
|
85
|
+
const appFiles = starter ? starterFiles(bundle) : {};
|
|
62
86
|
for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles(root) }))
|
|
63
87
|
await write(path, content);
|
|
64
88
|
await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
|
|
@@ -199,12 +223,14 @@ function starterFiles(bundle) {
|
|
|
199
223
|
/** The starter's README, for the people who open the folder: what it is, the three commands, and where the rules are. Rule text lives in the `isomorph` skill only. */
|
|
200
224
|
export const STARTER_README = `# Isomorph app
|
|
201
225
|
|
|
226
|
+
The first page — a notes list and a private-files card — is a placeholder the agent replaces with the app you asked for.
|
|
227
|
+
|
|
202
228
|
Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in, data, files and company systems come from the platform through \`@isomorph.ai/app-sdk\`, so the app has no backend of its own.
|
|
203
229
|
|
|
204
230
|
## Three commands
|
|
205
231
|
|
|
206
232
|
- \`isomorph dev --app-root . --detach --json\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
|
|
207
|
-
- \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, journeys)
|
|
233
|
+
- \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, journeys).
|
|
208
234
|
- \`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.
|
|
209
235
|
|
|
210
236
|
## Where the rules are
|
|
@@ -213,7 +239,7 @@ The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` inst
|
|
|
213
239
|
|
|
214
240
|
## Look and feel
|
|
215
241
|
|
|
216
|
-
\`src/styles.css\` is the whole look: plain CSS, no UI library, light and dark. Change it freely
|
|
242
|
+
\`src/styles.css\` is the whole look: plain CSS, no UI library, light and dark. Change it freely.
|
|
217
243
|
|
|
218
244
|
\`.isomorph/checks/\` is regenerated on each check and gitignored — put your own checks in \`tests/<name>.mjs\`. \`.isomorph/integrations.json\` is derived by \`isomorph check\` from the app's \`isomorph.integrations.execute\` calls and committed, never edited. \`.isomorph/app.json\` holds the app's name, description and audience.
|
|
219
245
|
`;
|
|
@@ -289,8 +315,9 @@ export function App() {
|
|
|
289
315
|
return (
|
|
290
316
|
<main className="app">
|
|
291
317
|
<header className="app-header">
|
|
292
|
-
<h1>Isomorph starter</h1>
|
|
293
|
-
<p className="muted">
|
|
318
|
+
<h1>Isomorph starter (placeholder)</h1>
|
|
319
|
+
<p className="muted">This page is a placeholder; your app replaces it.</p>
|
|
320
|
+
<p className="muted">Signed in as <strong>{user ? user.email : "…"}</strong> (a test user while running locally).</p>
|
|
294
321
|
</header>
|
|
295
322
|
{error && <p role="alert" className="alert">{error}</p>}
|
|
296
323
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"harbour": {
|
|
40
40
|
"kitBundle": {
|
|
41
41
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
42
|
-
"version": "0.
|
|
42
|
+
"version": "0.11.1"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
}
|