@isomorph.ai/cli 0.10.10 → 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/auth.js +112 -11
- package/dist/packages/harbour-cli/src/check.js +12 -1
- package/dist/packages/harbour-cli/src/cli.js +36 -15
- package/dist/packages/harbour-cli/src/config.js +20 -3
- 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 +39 -10
- 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/share.js +25 -0
- 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). 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. |
|
|
@@ -5,6 +5,15 @@ import { homedir, platform } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
export const requestedOAuthScopes = "mcp";
|
|
8
|
+
/**
|
|
9
|
+
* The loopback ports the company sign-in returns to. Cognito matches redirect
|
|
10
|
+
* URIs exactly and allows plain http only on loopback, so the port cannot be
|
|
11
|
+
* random: the platform registers exactly `http://127.0.0.1:<port>/callback`
|
|
12
|
+
* for these two on every company's client (the second is for a second
|
|
13
|
+
* `isomorph login` on the same machine). `ISOMORPH_LOGIN_PORT` names one other
|
|
14
|
+
* port, for an operator who registered it.
|
|
15
|
+
*/
|
|
16
|
+
export const CLI_LOGIN_PORTS = [47821, 47822];
|
|
8
17
|
export function tokenStorePath(env = process.env) {
|
|
9
18
|
return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "tokens.json");
|
|
10
19
|
}
|
|
@@ -52,7 +61,10 @@ export async function clearStoredToken(mcpUrl, tenant, path = tokenStorePath())
|
|
|
52
61
|
await rm(path, { force: true });
|
|
53
62
|
return token;
|
|
54
63
|
}
|
|
55
|
-
export async function login(mcpUrl, tenant, output) {
|
|
64
|
+
export async function login(mcpUrl, tenant, output, options = {}) {
|
|
65
|
+
if (options.login)
|
|
66
|
+
return cognitoLogin(mcpUrl, tenant, output, options.login, options);
|
|
67
|
+
const open = options.open ?? openBrowser;
|
|
56
68
|
const metadata = await discover(mcpUrl);
|
|
57
69
|
if (!metadata.registration_endpoint || !metadata.authorization_endpoint || !metadata.token_endpoint)
|
|
58
70
|
throw new Error("Isomorph sign-in is not available on this endpoint yet.");
|
|
@@ -67,17 +79,52 @@ export async function login(mcpUrl, tenant, output) {
|
|
|
67
79
|
const authorize = new URL(metadata.authorization_endpoint);
|
|
68
80
|
authorize.search = new URLSearchParams({ response_type: "code", client_id: registered.client_id, redirect_uri: callback.redirectUri, scope: requestedOAuthScopes, state, resource: mcpUrl, code_challenge: challenge, code_challenge_method: "S256" }).toString();
|
|
69
81
|
output("Isomorph is opening your browser to sign in.");
|
|
70
|
-
|
|
82
|
+
open(authorize.toString());
|
|
71
83
|
output(`If it does not open, visit: ${authorize}`);
|
|
72
84
|
const code = await callback.wait(state);
|
|
73
85
|
const response = await token(metadata.token_endpoint, { grant_type: "authorization_code", client_id: registered.client_id, code, redirect_uri: callback.redirectUri, code_verifier: verifier });
|
|
74
|
-
await saveStoredToken(mcpUrl, tenant, { ...response, clientId: registered.client_id, tokenEndpoint: metadata.token_endpoint, ...(metadata.revocation_endpoint ? { revocationEndpoint: metadata.revocation_endpoint } : {}) });
|
|
86
|
+
await saveStoredToken(mcpUrl, tenant, { accessToken: response.accessToken, ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}), ...(response.accessTokenExpiresAt ? { accessTokenExpiresAt: response.accessTokenExpiresAt } : {}), clientId: registered.client_id, tokenEndpoint: metadata.token_endpoint, ...(metadata.revocation_endpoint ? { revocationEndpoint: metadata.revocation_endpoint } : {}) }, options.path);
|
|
75
87
|
output("Isomorph sign-in is complete.");
|
|
76
88
|
}
|
|
77
89
|
finally {
|
|
78
90
|
callback.close();
|
|
79
91
|
}
|
|
80
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Sign-in to the company's own Cognito client, as the console does: the
|
|
95
|
+
* authorization-code + PKCE flow against the profile's endpoints, no dynamic
|
|
96
|
+
* registration, and the ID token kept as the bearer. Every governance route
|
|
97
|
+
* the CLI calls accepts it, including the maker routes the console uses
|
|
98
|
+
* (`share`), which the adapter's opaque token never reached.
|
|
99
|
+
*/
|
|
100
|
+
async function cognitoLogin(mcpUrl, tenant, output, login, options) {
|
|
101
|
+
const open = options.open ?? openBrowser;
|
|
102
|
+
const callback = await loopbackCallback(loginPorts(options.env ?? process.env));
|
|
103
|
+
try {
|
|
104
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
105
|
+
const state = randomBytes(24).toString("base64url");
|
|
106
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
107
|
+
const authorize = new URL(login.authorizationEndpoint);
|
|
108
|
+
authorize.search = new URLSearchParams({ response_type: "code", client_id: login.clientId, redirect_uri: callback.redirectUri, scope: login.scope, state, code_challenge: challenge, code_challenge_method: "S256" }).toString();
|
|
109
|
+
output("Isomorph is opening your browser to sign in.");
|
|
110
|
+
open(authorize.toString());
|
|
111
|
+
output(`If it does not open, visit: ${authorize}`);
|
|
112
|
+
const code = await callback.wait(state);
|
|
113
|
+
const response = await token(login.tokenEndpoint, { grant_type: "authorization_code", client_id: login.clientId, code, redirect_uri: callback.redirectUri, code_verifier: verifier });
|
|
114
|
+
if (!response.idToken)
|
|
115
|
+
throw new Error("Isomorph sign-in did not return an identity token.");
|
|
116
|
+
await saveStoredToken(mcpUrl, tenant, { kind: "cognito", accessToken: response.idToken, ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}), ...(response.accessTokenExpiresAt ? { accessTokenExpiresAt: response.accessTokenExpiresAt } : {}), clientId: login.clientId, tokenEndpoint: login.tokenEndpoint, revocationEndpoint: `${new URL(login.tokenEndpoint).origin}/oauth2/revoke` }, options.path);
|
|
117
|
+
output("Isomorph sign-in is complete.");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
callback.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** The ports to try, in order: the operator's one, or the two the platform registers. */
|
|
124
|
+
function loginPorts(env) {
|
|
125
|
+
const override = Number(env.ISOMORPH_LOGIN_PORT?.trim() || "");
|
|
126
|
+
return Number.isInteger(override) && override > 0 && override < 65_536 ? [override] : CLI_LOGIN_PORTS;
|
|
127
|
+
}
|
|
81
128
|
/**
|
|
82
129
|
* The account this device is already signed in as, or undefined: a stored
|
|
83
130
|
* token that still answers the userinfo endpoint. `login` stops here instead
|
|
@@ -107,8 +154,12 @@ export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath()
|
|
|
107
154
|
return stored.accessToken;
|
|
108
155
|
try {
|
|
109
156
|
const refreshed = await token(stored.tokenEndpoint, { grant_type: "refresh_token", client_id: stored.clientId, refresh_token: stored.refreshToken });
|
|
110
|
-
|
|
111
|
-
|
|
157
|
+
// Cognito answers a refresh with a new ID token and access token and no refresh token: the stored one stays.
|
|
158
|
+
const bearer = stored.kind === "cognito" ? refreshed.idToken : refreshed.accessToken;
|
|
159
|
+
if (!bearer)
|
|
160
|
+
throw new Error("Isomorph sign-in did not return an identity token.");
|
|
161
|
+
await saveStoredToken(mcpUrl, tenant, { ...stored, accessToken: bearer, refreshToken: refreshed.refreshToken ?? stored.refreshToken, ...(refreshed.accessTokenExpiresAt ? { accessTokenExpiresAt: refreshed.accessTokenExpiresAt } : {}) }, path);
|
|
162
|
+
return bearer;
|
|
112
163
|
}
|
|
113
164
|
catch {
|
|
114
165
|
const rotated = await loadStoredToken(mcpUrl, tenant, path);
|
|
@@ -126,6 +177,16 @@ export async function logout(mcpUrl, tenant, path = tokenStorePath()) {
|
|
|
126
177
|
const stored = await clearStoredToken(mcpUrl, tenant, path);
|
|
127
178
|
if (!stored?.revocationEndpoint)
|
|
128
179
|
return;
|
|
180
|
+
if (stored.kind === "cognito") {
|
|
181
|
+
// Cognito's revoke takes the refresh token only (and revokes what was minted from it); the ID token expires on its own within the hour.
|
|
182
|
+
if (!stored.refreshToken)
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
await fetch(stored.revocationEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: stored.refreshToken, client_id: stored.clientId }), redirect: "error" });
|
|
186
|
+
}
|
|
187
|
+
catch { /* as below */ }
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
129
190
|
for (const [token, hint] of [[stored.refreshToken, "refresh_token"], [stored.accessToken, "access_token"]]) {
|
|
130
191
|
if (!token)
|
|
131
192
|
continue;
|
|
@@ -135,11 +196,21 @@ export async function logout(mcpUrl, tenant, path = tokenStorePath()) {
|
|
|
135
196
|
catch { /* Local credentials are removed even if offline revocation cannot complete. */ }
|
|
136
197
|
}
|
|
137
198
|
}
|
|
138
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* Email of the signed-in company account; undefined when unavailable. A Cognito
|
|
201
|
+
* record carries it in the ID token itself, read locally (Cognito's userinfo
|
|
202
|
+
* wants the access token, which is not kept; the server verifies the signature
|
|
203
|
+
* on every request, so the CLI only needs the label). An adapter record asks
|
|
204
|
+
* the OAuth userinfo endpoint beside the token endpoint.
|
|
205
|
+
*/
|
|
139
206
|
export async function connectedAccount(mcpUrl, tenant, accessToken, path = tokenStorePath()) {
|
|
140
207
|
const stored = await loadStoredToken(mcpUrl, tenant, path);
|
|
141
208
|
if (!stored)
|
|
142
209
|
return undefined;
|
|
210
|
+
if (stored.kind === "cognito") {
|
|
211
|
+
const claims = idTokenClaims(accessToken);
|
|
212
|
+
return typeof claims?.email === "string" && typeof claims.exp === "number" && claims.exp * 1_000 > Date.now() ? claims.email : undefined;
|
|
213
|
+
}
|
|
143
214
|
try {
|
|
144
215
|
const response = await fetch(stored.tokenEndpoint.replace(/\/token$/, "/userinfo"), { headers: { authorization: `Bearer ${accessToken}` }, redirect: "error" });
|
|
145
216
|
const body = response.ok ? await response.json() : {};
|
|
@@ -162,18 +233,48 @@ async function discover(mcpUrl) {
|
|
|
162
233
|
throw new Error("Isomorph sign-in metadata is incomplete.");
|
|
163
234
|
return { authorization_endpoint: metadata.authorization_endpoint, token_endpoint: metadata.token_endpoint, registration_endpoint: metadata.registration_endpoint, revocation_endpoint: metadata.revocation_endpoint ?? "" };
|
|
164
235
|
}
|
|
236
|
+
/** The payload of a JWT, decoded without verification; undefined for anything that is not one. */
|
|
237
|
+
function idTokenClaims(token) {
|
|
238
|
+
try {
|
|
239
|
+
const payload = token.split(".")[1];
|
|
240
|
+
const claims = payload ? JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) : undefined;
|
|
241
|
+
return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : undefined;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
165
247
|
async function token(endpoint, values) {
|
|
166
248
|
const response = await json(endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values), redirect: "error" });
|
|
167
249
|
if (!response.access_token)
|
|
168
250
|
throw new Error("Isomorph sign-in did not return an access token.");
|
|
169
|
-
return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}), ...(typeof response.expires_in === "number" && response.expires_in > 0 ? { accessTokenExpiresAt: Date.now() + response.expires_in * 1_000 } : {}) };
|
|
251
|
+
return { accessToken: response.access_token, ...(response.id_token ? { idToken: response.id_token } : {}), ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}), ...(typeof response.expires_in === "number" && response.expires_in > 0 ? { accessTokenExpiresAt: Date.now() + response.expires_in * 1_000 } : {}) };
|
|
170
252
|
}
|
|
171
253
|
async function json(url, init) { const response = await fetch(url, { ...init, redirect: "error" }); if (!response.ok)
|
|
172
254
|
throw new Error(`Isomorph sign-in request failed (${response.status}).`); return response.json(); }
|
|
173
255
|
export function authorizationServerMetadataUrl(server) { return `${server.replace(/\/$/, "")}/.well-known/oauth-authorization-server`; }
|
|
174
|
-
function validToken(value) { return Boolean(value && typeof value === "object" && typeof value.accessToken === "string" && typeof value.clientId === "string" && typeof value.tokenEndpoint === "string"); }
|
|
256
|
+
function validToken(value) { return Boolean(value && typeof value === "object" && typeof value.accessToken === "string" && typeof value.clientId === "string" && typeof value.tokenEndpoint === "string" && (value.kind === undefined || value.kind === "cognito")); }
|
|
175
257
|
function isMissing(error) { return Boolean(error && typeof error === "object" && error.code === "ENOENT"); }
|
|
176
|
-
|
|
258
|
+
/**
|
|
259
|
+
* The local page the sign-in returns to. With `ports`, the first of them that is
|
|
260
|
+
* free is bound (the company client registers exactly those); with none, any
|
|
261
|
+
* free port (the adapter registers the CLI's redirect per sign-in).
|
|
262
|
+
*/
|
|
263
|
+
async function loopbackCallback(ports) {
|
|
264
|
+
if (!ports)
|
|
265
|
+
return listenCallback(0);
|
|
266
|
+
for (const port of ports) {
|
|
267
|
+
try {
|
|
268
|
+
return await listenCallback(port);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error.code !== "EADDRINUSE")
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Isomorph could not start local sign-in: ${ports.length > 1 ? `ports ${ports.join(" and ")} are both` : `port ${ports[0]} is`} in use. Another \`isomorph login\` may be running; finish or stop it, or set ISOMORPH_LOGIN_PORT to a port your Isomorph admin registered.`);
|
|
276
|
+
}
|
|
277
|
+
function listenCallback(port) {
|
|
177
278
|
return new Promise((resolve, reject) => {
|
|
178
279
|
const server = createServer();
|
|
179
280
|
const timer = setTimeout(() => { server.close(); reject(new Error("Isomorph sign-in timed out. Run `isomorph login` again.")); }, 5 * 60_000);
|
|
@@ -186,7 +287,7 @@ function loopbackCallback() {
|
|
|
186
287
|
if (code && state)
|
|
187
288
|
server.callback = { code, state };
|
|
188
289
|
});
|
|
189
|
-
server.listen(
|
|
290
|
+
server.listen(port, "127.0.0.1", () => {
|
|
190
291
|
const address = server.address();
|
|
191
292
|
if (!address || typeof address === "string") {
|
|
192
293
|
reject(new Error("Isomorph could not start local sign-in."));
|
|
@@ -200,7 +301,7 @@ function loopbackCallback() {
|
|
|
200
301
|
done(callback.code); }, 25);
|
|
201
302
|
}), close: () => { clearTimeout(timer); server.close(); } });
|
|
202
303
|
});
|
|
203
|
-
server.on("error", reject);
|
|
304
|
+
server.on("error", error => { clearTimeout(timer); reject(error); });
|
|
204
305
|
});
|
|
205
306
|
}
|
|
206
307
|
export function openBrowser(url) { const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open"; const args = platform() === "win32" ? ["/c", "start", "", url] : [url]; const child = spawn(command, args, { detached: true, stdio: "ignore" }); child.unref(); child.on("error", () => { }); }
|
|
@@ -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,13 +1,13 @@
|
|
|
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";
|
|
6
|
-
import { alreadySignedIn, connectedAccount, login, logout, openBrowser, refreshStoredToken } from "./auth.js";
|
|
6
|
+
import { alreadySignedIn, connectedAccount, loadStoredToken, login, logout, openBrowser, refreshStoredToken } from "./auth.js";
|
|
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";
|
|
@@ -15,6 +15,7 @@ import { endProcess, installDependencies, LocalRuntime, readDevLock, releaseDevL
|
|
|
15
15
|
import { CHECKS_FAILED_HINT, checksFailedMessage, runChecks } from "./check.js";
|
|
16
16
|
import { connectIntegration, assertDeployReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
|
|
17
17
|
import { runJob } from "./jobs.js";
|
|
18
|
+
import { renderShare, shareApp } from "./share.js";
|
|
18
19
|
const args = process.argv.slice(2);
|
|
19
20
|
// `productionise` (the command's name until 0.3.4) was an alias of `deploy` for one release; it is no longer a command and falls through to the usage error.
|
|
20
21
|
const command = args[0];
|
|
@@ -32,7 +33,7 @@ const reset = args.includes("--reset");
|
|
|
32
33
|
/** `dev --detach`: start the services, print the origin, exit while they keep running. */
|
|
33
34
|
const detach = args.includes("--detach");
|
|
34
35
|
const upgrade = args.includes("--upgrade");
|
|
35
|
-
/** `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. */
|
|
36
37
|
const adopt = args.includes("--adopt");
|
|
37
38
|
/** `package --out`: where the console import package is written (default `<app-root>/isomorph-import.zip`). */
|
|
38
39
|
const out = optionValue("--out");
|
|
@@ -56,12 +57,13 @@ const usage = [
|
|
|
56
57
|
" isomorph connect <work-email | company-start-url>",
|
|
57
58
|
" isomorph login | logout",
|
|
58
59
|
" isomorph agent-setup [--json] install the plain-English Isomorph skill for Claude Code (~/.claude/skills/isomorph) and Codex (~/.codex/skills/isomorph)",
|
|
59
|
-
" 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",
|
|
60
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)",
|
|
61
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)",
|
|
62
63
|
" isomorph stop --app-root <path> stop this app's local services, keeping its database and files",
|
|
63
64
|
" isomorph check --app-root <path> [--integrations] [--json] types, build, then the pipeline's kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, journeys, operation coverage (+ authorised real reads)",
|
|
64
65
|
" isomorph deploy --app-root <path> [--session-notes <text>] [--no-wait] [--max-wait <seconds>] [--json] deploy the private preview: name, description and audience come from .isomorph/app.json (--no-wait returns once Isomorph has the package, with the command that follows it)",
|
|
66
|
+
" isomorph share --app-root <path> [--json] give the audience in .isomorph/app.json access to the deployed preview without a new release (deploy carries the audience too; share is for an audience-only change)",
|
|
65
67
|
" isomorph status [--app-root <path>] [--operation <reference>] [--wait] [--max-wait <seconds>] [--json] (--app-root defaults to the current folder; --operation to the deployment last started from it)",
|
|
66
68
|
" isomorph retry [--app-root <path>] [--operation <reference>] [--no-wait] [--max-wait <seconds>] [--json]",
|
|
67
69
|
" isomorph promote [--app-root <path>] [--operation <reference>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--confirm-tested: the person has tried the preview; the company's AI setup is confirmed for production when the app calls governed AI)",
|
|
@@ -91,7 +93,7 @@ const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(va
|
|
|
91
93
|
*/
|
|
92
94
|
const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
|
|
93
95
|
/** Exit 2, like a usage error: nothing started and the fix is a command the maker runs (or a file the maker edits). */
|
|
94
|
-
const USAGE_REFUSALS = ["APP_EXISTS", "APP_UNSUPPORTED", "KIT_APP", "DEPLOY_BLOCKED", "INTEGRATIONS_NOT_READY", "AI_NOT_READY", "CLI_UPGRADE_REQUIRED", "KIT_BUNDLE_STALE", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE", "DEPLOY_IN_FLIGHT", "OPERATION_REQUIRED"];
|
|
96
|
+
const USAGE_REFUSALS = ["APP_EXISTS", "APP_UNSUPPORTED", "KIT_APP", "DEPLOY_BLOCKED", "INTEGRATIONS_NOT_READY", "AI_NOT_READY", "CLI_UPGRADE_REQUIRED", "KIT_BUNDLE_STALE", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE", "DEPLOY_IN_FLIGHT", "OPERATION_REQUIRED", "INDIVIDUAL_SHARING_DISABLED", "EXTERNAL_EMAIL_NOT_ALLOWED", "INVALID_SHARE_EMAILS"];
|
|
95
97
|
if (command === "--version" || command === "version") {
|
|
96
98
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
97
99
|
// `-h` was matched anywhere in argv while `--help` was only recognised as the
|
|
@@ -112,10 +114,10 @@ else if (command === "agent-setup") {
|
|
|
112
114
|
progress("Claude Code and Codex: the `isomorph` skill is installed. Each agent uses it only for an Isomorph app (a folder with .isomorph/, or an app you ask it to build) and works as usual everywhere else. Say what you want to build; the agent installs and runs the kit itself.");
|
|
113
115
|
emit(summaryEnvelope({ ...result, paths: agentPaths(process.env) }));
|
|
114
116
|
}
|
|
115
|
-
else if (!["connect", "login", "logout", "deploy", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
117
|
+
else if (!["connect", "login", "logout", "deploy", "share", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
|
|
116
118
|
|| (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
|
|
117
119
|
|| (["deploy", "integrations", ...OPERATION_COMMANDS].includes(command) && optionError)
|
|
118
|
-
|| (command === "deploy" && !root)
|
|
120
|
+
|| ((command === "deploy" || command === "share") && !root)
|
|
119
121
|
|| (LOCAL_COMMANDS.includes(command) && !root)
|
|
120
122
|
|| (command === "jobs" && (subcommand !== "run" || !args[2] || args[2].startsWith("--")))
|
|
121
123
|
|| (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog", "connect"].includes(subcommand) || (["request", "connect"].includes(subcommand) && (!args[2] || args[2].startsWith("--")))))) {
|
|
@@ -148,7 +150,9 @@ else {
|
|
|
148
150
|
progress(line);
|
|
149
151
|
// The SDK is a normal dependency: one plain `npm install` when node_modules lacks it or holds one outside the range init wrote.
|
|
150
152
|
await installDependencies(target, bundle, runCommand, progress);
|
|
151
|
-
|
|
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."
|
|
152
156
|
: upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.")
|
|
153
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}\`.`
|
|
154
158
|
: "Kit files added; existing files were kept.");
|
|
@@ -228,9 +232,14 @@ else {
|
|
|
228
232
|
// every unattended builder five minutes (auth.ts loopbackCallback), and
|
|
229
233
|
// when the sign-in could not complete, agents escalated to `logout`
|
|
230
234
|
// — which signs the whole machine out (bench, 2026-09-16).
|
|
231
|
-
|
|
235
|
+
// A device still holding the adapter's token for a company that now
|
|
236
|
+
// signs the CLI in through its own Cognito client (0.11.0) re-signs:
|
|
237
|
+
// that token cannot open the console's routes (`share`), and "already
|
|
238
|
+
// signed in" would send the agent to `logout` to switch.
|
|
239
|
+
const stale = Boolean(config.login) && (await loadStoredToken(url, tenant))?.kind !== "cognito";
|
|
240
|
+
const already = stale ? undefined : await alreadySignedIn(url, tenant);
|
|
232
241
|
if (!already)
|
|
233
|
-
await login(url, tenant, progress);
|
|
242
|
+
await login(url, tenant, progress, { ...(config.login ? { login: config.login } : {}) });
|
|
234
243
|
else
|
|
235
244
|
progress(`Already signed in to ${companyLabel(config)} as ${already}; nothing to do. Run \`isomorph logout\` first to sign in as someone else.`);
|
|
236
245
|
const signedIn = await signedInDetails(config);
|
|
@@ -248,7 +257,15 @@ else {
|
|
|
248
257
|
// The client re-resolves per request so a token rotated by a sibling process mid-poll is picked up.
|
|
249
258
|
const governance = new GovernanceClient(config.apiUrl, resolveToken, tenant);
|
|
250
259
|
let envelope;
|
|
251
|
-
|
|
260
|
+
/** The human form, for a command whose result is a sentence rather than a summary. */
|
|
261
|
+
let line;
|
|
262
|
+
if (command === "share") {
|
|
263
|
+
// An explicit ISOMORPH_TOKEN is the operator's to vouch for; a stored record must be the company sign-in.
|
|
264
|
+
const shared = await shareApp(appRoot(root), governance, tenant, EMBEDDED_KIT_BUNDLE, explicitToken ? undefined : (await loadStoredToken(url, tenant)) ?? {});
|
|
265
|
+
envelope = summaryEnvelope(shared.outcome);
|
|
266
|
+
line = renderShare(shared.audience);
|
|
267
|
+
}
|
|
268
|
+
else if (command === "integrations") {
|
|
252
269
|
const target = appRoot(root);
|
|
253
270
|
const result = subcommand === "connect"
|
|
254
271
|
? await connectIntegration(target, governance, args[2], optionValue("--return-url"))
|
|
@@ -283,19 +300,23 @@ else {
|
|
|
283
300
|
if (!ref)
|
|
284
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>.");
|
|
285
302
|
let summary;
|
|
303
|
+
let ai;
|
|
286
304
|
if (command === "status")
|
|
287
305
|
summary = wait ? await follow(statusFetch(governance, appId, ref), ref, progress, waitOptions) : summarize(await governance.deploymentStatus(appId, ref));
|
|
288
306
|
else if (command === "retry")
|
|
289
307
|
summary = await retryDeployment(governance, appId, ref, progress, { wait: !noWait, waitOptions });
|
|
290
308
|
else {
|
|
291
309
|
// Production has its own lane and its own AI mode: the same one-call pre-flight runs for production before promoting.
|
|
292
|
-
|
|
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);
|
|
293
314
|
const profile = await readAppProfile(target);
|
|
294
315
|
summary = await promoteToProduction(governance, appId, ref, { name: profile.name, description: profile.description, audience: profile.audience }, progress, { wait: !noWait, waitOptions, confirmedTested: confirmTested });
|
|
295
316
|
}
|
|
296
|
-
envelope = operationEnvelope(summary, ref, CLI_VERSION);
|
|
317
|
+
envelope = operationEnvelope({ ...summary, ...(ai ? { ai } : {}) }, ref, CLI_VERSION);
|
|
297
318
|
}
|
|
298
|
-
|
|
319
|
+
emit(envelope, line);
|
|
299
320
|
}
|
|
300
321
|
}
|
|
301
322
|
}
|
|
@@ -121,6 +121,7 @@ export async function connect(startUrl, path = configPath(), fetchImpl = fetch)
|
|
|
121
121
|
if (body.schema !== "harbour.tenant-start/1.0")
|
|
122
122
|
throw new Error("This is not an Isomorph connection profile.");
|
|
123
123
|
const minimum = minimumCliVersion(body);
|
|
124
|
+
const login = tenantLogin(body);
|
|
124
125
|
const config = {
|
|
125
126
|
schema: "isomorph.cli-config/1.0",
|
|
126
127
|
tenantId: String(body.tenantId ?? ""),
|
|
@@ -128,7 +129,8 @@ export async function connect(startUrl, path = configPath(), fetchImpl = fetch)
|
|
|
128
129
|
mcpUrl: String(body.mcpUrl ?? ""),
|
|
129
130
|
...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
|
|
130
131
|
...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {}),
|
|
131
|
-
...(minimum ? { minimumCliVersion: minimum } : {})
|
|
132
|
+
...(minimum ? { minimumCliVersion: minimum } : {}),
|
|
133
|
+
...(login ? { login } : {})
|
|
132
134
|
};
|
|
133
135
|
if (!validConfig(config))
|
|
134
136
|
throw new Error("This is not the expected Isomorph connection profile.");
|
|
@@ -140,6 +142,20 @@ function minimumCliVersion(body) {
|
|
|
140
142
|
const kit = isRecord(body.kit) ? body.kit : undefined;
|
|
141
143
|
return typeof kit?.minimumCliVersion === "string" && kit.minimumCliVersion.trim() ? kit.minimumCliVersion.trim() : undefined;
|
|
142
144
|
}
|
|
145
|
+
/** The `login` block of a tenant-start profile, verbatim; undefined when absent or incomplete (the adapter path stands then). */
|
|
146
|
+
function tenantLogin(body) {
|
|
147
|
+
const login = body.login;
|
|
148
|
+
if (!validLogin(login))
|
|
149
|
+
return undefined;
|
|
150
|
+
return { clientId: login.clientId, authorizationEndpoint: login.authorizationEndpoint, tokenEndpoint: login.tokenEndpoint, ...(typeof login.issuer === "string" ? { issuer: login.issuer } : {}), scope: login.scope };
|
|
151
|
+
}
|
|
152
|
+
function validLogin(value) {
|
|
153
|
+
return isRecord(value) && typeof value.clientId === "string" && value.clientId.length > 0
|
|
154
|
+
&& typeof value.authorizationEndpoint === "string" && /^https?:\/\//.test(value.authorizationEndpoint)
|
|
155
|
+
&& typeof value.tokenEndpoint === "string" && /^https?:\/\//.test(value.tokenEndpoint)
|
|
156
|
+
&& (value.issuer === undefined || typeof value.issuer === "string")
|
|
157
|
+
&& typeof value.scope === "string" && value.scope.length > 0;
|
|
158
|
+
}
|
|
143
159
|
function platformOrigin() {
|
|
144
160
|
return process.env.ISOMORPH_PLATFORM_URL?.trim() || "https://platform.isomorph.ai";
|
|
145
161
|
}
|
|
@@ -179,7 +195,7 @@ export function resolveConfig(env, saved) {
|
|
|
179
195
|
if (!mcpUrl || !tenantId)
|
|
180
196
|
return undefined;
|
|
181
197
|
// The saved name belongs to the saved tenant; an ISOMORPH_TENANT override names another company.
|
|
182
|
-
return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl), ...(saved?.minimumCliVersion && tenantId === saved.tenantId ? { minimumCliVersion: saved.minimumCliVersion } : {}) };
|
|
198
|
+
return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl), ...(saved?.minimumCliVersion && tenantId === saved.tenantId ? { minimumCliVersion: saved.minimumCliVersion } : {}), ...(saved?.login && tenantId === saved.tenantId ? { login: saved.login } : {}) };
|
|
183
199
|
}
|
|
184
200
|
/** The company as the CLI names it: `Fourier (fourier)`, or the id twice when the profile carried no name. */
|
|
185
201
|
export function companyLabel(config) {
|
|
@@ -202,7 +218,8 @@ function validConfig(value) {
|
|
|
202
218
|
&& /^https?:\/\//.test(value.mcpUrl)
|
|
203
219
|
&& (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
|
|
204
220
|
&& (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl)))
|
|
205
|
-
&& (value.minimumCliVersion === undefined || typeof value.minimumCliVersion === "string")
|
|
221
|
+
&& (value.minimumCliVersion === undefined || typeof value.minimumCliVersion === "string")
|
|
222
|
+
&& (value.login === undefined || validLogin(value.login)));
|
|
206
223
|
}
|
|
207
224
|
function isRecord(value) {
|
|
208
225
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -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.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { basename } from "node:path";
|
|
2
3
|
import { linkIdempotencyKey, readDeclaration, readKitLock, readReviewedAppProfile, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
|
|
3
4
|
import { CliError } from "./output.js";
|
|
@@ -46,6 +47,10 @@ export class GovernanceClient {
|
|
|
46
47
|
deployPreflight(appId, body) {
|
|
47
48
|
return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/deploy-preflight`, body);
|
|
48
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
|
+
}
|
|
49
54
|
/**
|
|
50
55
|
* What the building agent says cost it time in Isomorph itself. Never blocks a
|
|
51
56
|
* deployment: the caller discards every failure, and an empty note files nothing.
|
|
@@ -74,8 +79,18 @@ export class GovernanceClient {
|
|
|
74
79
|
retryDeployment(appId, operationRef) {
|
|
75
80
|
return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/retry`, {});
|
|
76
81
|
}
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Who may open the app: the console's own audience route, called with the
|
|
84
|
+
* company sign-in (a Cognito ID token) this CLI holds since 0.11.0. It changes
|
|
85
|
+
* the access policy only — no release. The route's refusals are a bare
|
|
86
|
+
* `{ error: "<code>" }`, unlike the `/v1/development/*` envelope, and a
|
|
87
|
+
* 401 is the gateway refusing a token of the old adapter kind.
|
|
88
|
+
*/
|
|
89
|
+
share(appId, emails) {
|
|
90
|
+
return this.call("PUT", `/v1/maker/${encodeURIComponent(this.tenantId)}/apps/${encodeURIComponent(appId)}/access`, { emails }, { headers: { "x-harbour-idempotency-key": randomUUID() }, refusals: SHARE_REFUSALS, unauthorized: SHARE_SIGN_IN });
|
|
91
|
+
}
|
|
92
|
+
async call(method, path, body, options) {
|
|
93
|
+
return (await this.exchange(method, path, body, options)).data;
|
|
79
94
|
}
|
|
80
95
|
/**
|
|
81
96
|
* One request, with governance's refusal carried whole: its code (or category,
|
|
@@ -85,26 +100,40 @@ export class GovernanceClient {
|
|
|
85
100
|
* 5xx are named as such (`GOVERNANCE_UNREACHABLE`, `HTTP_5xx`) so a long poll
|
|
86
101
|
* can tell a transient fault from a refusal; see `waitForSettled`.
|
|
87
102
|
*/
|
|
88
|
-
async exchange(method, path, body) {
|
|
103
|
+
async exchange(method, path, body, options = {}) {
|
|
89
104
|
const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
|
|
90
|
-
let response = await this.send(url, method, body);
|
|
105
|
+
let response = await this.send(url, method, body, options.headers);
|
|
91
106
|
if (response.status === 401)
|
|
92
|
-
response = await this.send(url, method, body);
|
|
107
|
+
response = await this.send(url, method, body, options.headers);
|
|
93
108
|
const parsed = await response.json().catch(() => ({}));
|
|
94
109
|
if (response.status === 401)
|
|
95
|
-
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
110
|
+
throw new CliError("AUTH_REQUIRED", options.unauthorized ?? "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
111
|
+
// The maker routes (the console's) refuse with `{ error: "<code>" }` and no sentence: the CLI's own words for each code.
|
|
112
|
+
if (!response.ok && typeof parsed.error === "string")
|
|
113
|
+
throw new CliError(parsed.error.toUpperCase(), options.refusals?.[parsed.error] ?? "Isomorph governance rejected the request.", undefined, undefined, undefined, { layer: "governance" });
|
|
96
114
|
if (!response.ok) {
|
|
97
|
-
const
|
|
98
|
-
|
|
115
|
+
const failure = typeof parsed.error === "object" ? parsed.error : undefined;
|
|
116
|
+
const { code, remediationHint, ...details } = failure?.details ?? {};
|
|
117
|
+
throw new CliError(code ?? failure?.category ?? `HTTP_${response.status}`, failure?.message || "Isomorph governance rejected the request.", undefined, typeof remediationHint === "string" && remediationHint.trim() ? remediationHint.trim() : undefined, undefined, { layer: "governance", ...(Object.keys(details).length ? { details } : {}) });
|
|
99
118
|
}
|
|
100
119
|
return { status: response.status, data: (parsed.data ?? parsed) };
|
|
101
120
|
}
|
|
102
|
-
async send(url, method, body) {
|
|
121
|
+
async send(url, method, body, headers = {}) {
|
|
103
122
|
const token = await this.token();
|
|
104
|
-
return this.fetchImpl(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
|
|
123
|
+
return this.fetchImpl(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "x-harbour-tenant": this.tenantId, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...headers }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error" })
|
|
105
124
|
.catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
|
|
106
125
|
}
|
|
107
126
|
}
|
|
127
|
+
/** The console's refusals of an audience change, in the words it shows (makerAppActionPlainEnglish). */
|
|
128
|
+
export const SHARE_REFUSALS = {
|
|
129
|
+
app_access_not_owned: "Only the builder who registered this app can change it.",
|
|
130
|
+
individual_sharing_disabled: "This company has not enabled sharing apps with named colleagues. Confirm an empty audience, or ask an administrator.",
|
|
131
|
+
external_email_not_allowed: "Only email addresses on the company's approved domains can be given access.",
|
|
132
|
+
invalid_share_emails: "Audience must be a list of up to 100 company email addresses.",
|
|
133
|
+
app_not_found: "Isomorph has no registered app for this operation."
|
|
134
|
+
};
|
|
135
|
+
/** A 401 from the audience route, or a stored sign-in of the adapter kind: the maker routes take only the company sign-in. */
|
|
136
|
+
export const SHARE_SIGN_IN = "Sign in again with `isomorph login`: sharing needs the company sign-in this CLI now uses.";
|
|
108
137
|
const deployments = (appId) => `/v1/development/apps/${encodeURIComponent(appId)}/deployments`;
|
|
109
138
|
/**
|
|
110
139
|
* What actually failed under a `fetch` rejection. undici reports every transport
|
|
@@ -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.";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describeProfile } from "./deploy.js";
|
|
2
|
+
import { ensureLinkedApp, SHARE_SIGN_IN } from "./integrations.js";
|
|
3
|
+
import { assertLockCompany, readKitLock, readReviewedAppProfile } from "./kit.js";
|
|
4
|
+
import { CliError } from "./output.js";
|
|
5
|
+
/**
|
|
6
|
+
* `isomorph share`: the audience in `.isomorph/app.json`, sent as the app's
|
|
7
|
+
* access policy without a release. `deploy` carries the same audience with the
|
|
8
|
+
* package; this is the path for the builder who only changed who may open the
|
|
9
|
+
* app. The route is the console's, behind the company sign-in: a record of the
|
|
10
|
+
* adapter kind is refused here, before the network, rather than by a 401.
|
|
11
|
+
*/
|
|
12
|
+
export async function shareApp(root, governance, tenantId, bundle, signedIn) {
|
|
13
|
+
if (signedIn && signedIn.kind !== "cognito")
|
|
14
|
+
throw new CliError("AUTH_REQUIRED", SHARE_SIGN_IN, undefined, "Run `isomorph logout` then `isomorph login`.");
|
|
15
|
+
const profile = await readReviewedAppProfile(root);
|
|
16
|
+
assertLockCompany(await readKitLock(root), tenantId);
|
|
17
|
+
const appId = await ensureLinkedApp(root, governance, tenantId, bundle);
|
|
18
|
+
const outcome = await governance.share(appId, profile.audience);
|
|
19
|
+
return { audience: profile.audience, outcome };
|
|
20
|
+
}
|
|
21
|
+
/** The audience line `deploy` prints, then what did (and did not) happen. */
|
|
22
|
+
export function renderShare(audience) {
|
|
23
|
+
const line = describeProfile({ schema: "isomorph.app/1.0", name: "", description: "", audience }).find(entry => entry.startsWith("Audience:"));
|
|
24
|
+
return `${line}\nIsomorph updated who can open the preview; no new release was made.\n`;
|
|
25
|
+
}
|
|
@@ -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.
|
|
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
|
}
|