@isomorph.ai/cli 0.8.1 → 0.8.3

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.
@@ -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 .\` (empty folder → starter; an existing app → three choices to relay). Node 22+, no Docker.
111
+ - No \`.isomorph/\` yet: \`isomorph init --app-root .\` (empty folder → starter; an existing app → three choices to relay). Node 22+, no Docker. Then write the app's \`name\` and \`description\` into \`.isomorph/app.json\` from what they asked and tell them both: 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
@@ -7,7 +7,7 @@ import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal
7
7
  import { CLI_VERSION } from "./version.js";
8
8
  import { assertSafeAppRoot, readAppTree } from "./package.js";
9
9
  import { deployBlocked, ensureLinkedApp, kitLockBody } from "./integrations.js";
10
- import { assertLockCompany, describeSourceChange, kitPaths, readAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
10
+ import { assertLockCompany, describeSourceChange, kitPaths, readReviewedAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
11
11
  import { CHECKS_FAILED_HINT, checksFailedMessage, FLOW_CHECK, readReport, runChecks } from "./check.js";
12
12
  import { runCommand } from "./local-runtime.js";
13
13
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
@@ -56,15 +56,14 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
56
56
  assertLockCompany(lock, tenantId);
57
57
  // The three values the person confirmed, printed so the transcript shows what
58
58
  // was recorded; recorded on the operation before anything is uploaded.
59
- const profile = await readAppProfile(root);
59
+ // Refused here, in the first second, and by the same rule as the first access
60
+ // request (`APP_PROFILE_UNREVIEWED`): a deploy with the starter profile used
61
+ // to run the whole gate and open an operation before the profile was refused
62
+ // (live, 2026-09-16). The deploy route records all three values, so it
63
+ // refuses a blank one too; this only says so before the checks spend a minute.
64
+ const profile = await readReviewedAppProfile(root);
60
65
  for (const line of describeProfile(profile))
61
66
  output(line);
62
- // Refused here, in the first second: a deploy with no description used to run
63
- // the whole gate and open an operation before the profile was refused (live,
64
- // 2026-09-16). The deploy route records all three values, so it refuses one
65
- // that is blank too; this only says so before the checks spend a minute.
66
- if (!profile.name.trim() || !profile.description.trim())
67
- throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json before it can deploy.", undefined, "Edit `name` and `description` in .isomorph/app.json (show them to the person first), then run this again.");
68
67
  const declaration = await readDeclaration(root);
69
68
  // Link first: the app's identity lands in `.isomorph/kit.lock.json`, which is
70
69
  // one of the deployable files, so a report pinned after the link stays fresh
@@ -6,7 +6,7 @@ import { createForwarder } from "./forwarder.js";
6
6
  import { readKitLock } from "./kit.js";
7
7
  import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
8
8
  import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand } from "./local-runtime.js";
9
- import { declarationDigest, kitPaths } from "./kit.js";
9
+ import { accessDigest, kitPaths } from "./kit.js";
10
10
  import { CliError, safeError } from "./output.js";
11
11
  /**
12
12
  * Signed in at startup: link the app and file the access request for every
@@ -29,16 +29,21 @@ export async function companyAccessAtStartup(root, client, tenantId, bundle) {
29
29
  return { appId: filed.appId, lines };
30
30
  }
31
31
  catch (error) {
32
+ // Not a fault: the ask waits on the person. Said in full, since this line is the only place the builder learns why IT has nothing yet; the watcher files once app.json changes.
33
+ if (error instanceof CliError && error.code === "APP_PROFILE_UNREVIEWED")
34
+ return { lines: [`Company access not requested yet: ${error.message} ${error.remediationHint ?? ""}`.trim()], failed: true };
32
35
  return { lines: [`Company access could not be checked (${safeError(error).message}); the app runs without it until the next start.`], failed: true };
33
36
  }
34
37
  }
35
38
  /**
36
- * Files the declared requests again whenever `.isomorph/integrations.json`
37
- * changes while `isomorph dev` runs — `isomorph check` rewrites it as the app's
38
- * calls change — so adding a company system needs no restart. Before this the
39
- * session filed once at start, nothing watched the file, and a builder's new
40
- * connection was never requested (2026-09-17 round). `poll` is one tick; a
41
- * filing that failed is retried on the next tick with the same digest.
39
+ * Files the declared requests again whenever `.isomorph/integrations.json` or
40
+ * `.isomorph/app.json` changes while `isomorph dev` runs — `isomorph check`
41
+ * rewrites the first as the app's calls change, the builder edits the second
42
+ * when the person names the app — so adding a company system, or reviewing the
43
+ * profile the first request waits on, needs no restart. Before this the session
44
+ * filed once at start, nothing watched the file, and a builder's new connection
45
+ * was never requested (2026-09-17 round). `poll` is one tick; a filing that
46
+ * failed is retried on the next tick with the same digest.
42
47
  */
43
48
  export function declarationWatcher(root, refile, output, filed) {
44
49
  let known = filed;
@@ -49,12 +54,12 @@ export function declarationWatcher(root, refile, output, filed) {
49
54
  return;
50
55
  busy = true;
51
56
  try {
52
- const digest = await declarationDigest(root);
57
+ const digest = await accessDigest(root);
53
58
  if (digest === known)
54
59
  return;
55
60
  const result = await refile();
56
61
  if (result.lines.length)
57
- output([".isomorph/integrations.json changed: company access requested again.", ...result.lines].map(line => ` ${line}`).join("\n"));
62
+ output([".isomorph/integrations.json or app.json changed: company access requested again.", ...result.lines].map(line => ` ${line}`).join("\n"));
58
63
  if (!result.failed)
59
64
  known = digest;
60
65
  }
@@ -158,7 +163,7 @@ export async function startDev(root, options) {
158
163
  ].join("\n"));
159
164
  // Signed in: link now and file the app-wide requests — after the link is out, so governance's round-trips never hold it — and say where each connection stands.
160
165
  if (governance && company) {
161
- const filed = await declarationDigest(root);
166
+ const filed = await accessDigest(root);
162
167
  const access = await companyAccessAtStartup(root, governance, company.tenantId, options.bundle);
163
168
  lockAppId = access.appId ?? lockAppId;
164
169
  const lines = [...(access.appId && !linkedAtStart ? [`Linked app: ${access.appId}`] : []), ...access.lines];
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path";
2
- import { linkIdempotencyKey, readDeclaration, readKitLock, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
2
+ import { linkIdempotencyKey, readDeclaration, readKitLock, readReviewedAppProfile, requestResourceName, requestResources, writeKitLock, newKitLock } from "./kit.js";
3
3
  import { CliError } from "./output.js";
4
4
  import { CLI_VERSION } from "./version.js";
5
5
  /** The lock's bundle as the preflight body carries it. */
@@ -18,7 +18,7 @@ export class GovernanceClient {
18
18
  link(input) {
19
19
  return this.call("POST", "/v1/development/apps/link", { tenantId: this.tenantId, ...input });
20
20
  }
21
- /** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. */
21
+ /** One app-wide request per (connection, identity): no environment and no expiry — IT approves once for every lane and sets any expiry itself. `app` is the reviewed name and description IT sees the ask under; governance records them before the request. */
22
22
  request(appId, input) {
23
23
  return this.call("POST", `/v1/development/apps/${encodeURIComponent(appId)}/integration-requests`, { tenantId: this.tenantId, ...input });
24
24
  }
@@ -119,12 +119,18 @@ export function transportFailure(error) {
119
119
  const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
120
120
  return typeof cause?.code === "string" && !text.includes(cause.code) ? `${text} (${cause.code})` : text;
121
121
  }
122
- /** Links the app once (stable idempotency key) and records the appId in kit.lock. */
122
+ /**
123
+ * Links the app once (stable idempotency key) and records the appId in kit.lock.
124
+ * The link's name is provisional — IT sees nothing of an app until its first
125
+ * access request, which carries the reviewed profile — so a profile not yet
126
+ * reviewed (or absent) links under the folder name and is renamed by that request.
127
+ */
123
128
  export async function ensureLinkedApp(root, client, tenantId, bundle) {
124
129
  const lock = (await readKitLock(root)) ?? newKitLock(bundle, tenantId);
125
130
  if (lock.appId)
126
131
  return lock.appId;
127
- const linked = await client.link({ displayName: basename(root), idempotencyKey: linkIdempotencyKey(tenantId, root) });
132
+ const displayName = await readReviewedAppProfile(root).then(profile => profile.name, () => basename(root));
133
+ const linked = await client.link({ displayName, idempotencyKey: linkIdempotencyKey(tenantId, root) });
128
134
  if (!linked.appId)
129
135
  throw new CliError("LINK_FAILED", "Isomorph did not return an app identity for this app.");
130
136
  await writeKitLock(root, { ...lock, appId: linked.appId, tenantId });
@@ -135,11 +141,11 @@ export const IDENTITY_WORDS = { app: "as the company bot", user: "as the signed-
135
141
  /** A refusal about the leg rather than the ask — signed out, unreachable, an answer with no code — is nobody's ask and stops the run. */
136
142
  const legFailure = (code) => code === "AUTH_REQUIRED" || code === "GOVERNANCE_UNREACHABLE" || code.startsWith("HTTP_");
137
143
  /** One request per identity mode of the connection. A refusal of an ask is returned beside the others, not thrown, so a caller can report every connection at once. */
138
- async function submitRequests(client, appId, declaration, root, connection, reason, only) {
144
+ async function submitRequests(client, appId, declaration, root, connection, app, reason, only) {
139
145
  return Promise.all(requestScope(declaration, connection, only).map(async (part) => {
140
146
  const ask = { connection, ...part };
141
147
  try {
142
- return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}) }) };
148
+ return { ...ask, outcome: await client.request(appId, { connection, identityMode: part.identityMode, operations: part.operations, resources: part.resources, ...(part.presentation ? { presentation: part.presentation } : {}), ...(reason?.trim() ? { reason: reason.trim() } : {}), app }) };
143
149
  }
144
150
  catch (error) {
145
151
  const refusal = unregisteredResourceGuidance(error, declaration, connection, root);
@@ -149,6 +155,8 @@ async function submitRequests(client, appId, declaration, root, connection, reas
149
155
  }
150
156
  }));
151
157
  }
158
+ /** The two profile values an access request carries. */
159
+ const reviewedProfileBody = (profile) => ({ name: profile.name, description: profile.description });
152
160
  /** The filed asks with every refusal thrown, for the commands that cannot go on past one. */
153
161
  function settled(requests) {
154
162
  return requests.map(item => { if (item.error)
@@ -163,15 +171,18 @@ function settled(requests) {
163
171
  * one it is still deciding, so a repeat opens nothing. Links the app first
164
172
  * (the same key as `integrations request`) and reads the lanes back, grouped.
165
173
  * An app with no declaration or no connections is left alone: nothing to ask,
166
- * so governance is not called.
174
+ * so governance is not called. A profile nobody has reviewed is refused
175
+ * (`APP_PROFILE_UNREVIEWED`) before anything is filed: the request is the
176
+ * first thing IT sees of the app, under that name and description.
167
177
  */
168
178
  export async function fileDeclaredRequests(root, client, tenantId, bundle) {
169
179
  const declaration = await readDeclaration(root);
170
180
  const connections = Object.keys(declaration.connections);
171
181
  if (!connections.length)
172
182
  return undefined;
183
+ const app = reviewedProfileBody(await readReviewedAppProfile(root));
173
184
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
174
- const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection)))).flat();
185
+ const requests = (await Promise.all(connections.map(connection => submitRequests(client, appId, declaration, root, connection, app)))).flat();
175
186
  return { appId, requests, groups: groupGrants((await client.list(appId)).grants) };
176
187
  }
177
188
  /**
@@ -181,8 +192,9 @@ export async function fileDeclaredRequests(root, client, tenantId, bundle) {
181
192
  */
182
193
  export async function requestIntegrations(root, client, tenantId, bundle, options) {
183
194
  const declaration = await readDeclaration(root);
195
+ const app = reviewedProfileBody(await readReviewedAppProfile(root));
184
196
  const appId = await ensureLinkedApp(root, client, tenantId, bundle);
185
- const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
197
+ const submitted = settled(await submitRequests(client, appId, declaration, root, options.connection, app, options.reason, options.operations)).map(item => ({ ...item, state: item.outcome.state }));
186
198
  const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
187
199
  const pollMs = options.pollMs ?? 3_000;
188
200
  let grants = [];
@@ -1,6 +1,6 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.7.2",
3
+ "kitVersion": "0.8.2",
4
4
  "sdk": {
5
5
  "package": "@isomorph.ai/app-sdk",
6
6
  "version": "1.2.2",
@@ -8,21 +8,21 @@ export const PUBLISHED_KIT_BUNDLE = {
8
8
  "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8953bb7eb6846bcddf48fd5407c098a13c8ad2cf249d702c35b5666d5af0ad53"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:2779990e78ff2bd9802c0bce5dc8487a95e6ac7148621e26709df201d93eda80",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:efe143ca983fc450cf598e28738a48c7f83e91631b5ab2af5148b919d68e8f78"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:b84a082180d9295e1fb4950231f2a858399a2d34855be02c6dc45df62d706903",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:8f65d7eacca39401142cd76b192d62a8f8db5805d27246142be8280d718ffa57"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:98857d8c93689295c63060f29ceae7d4879164fa6299fb53411dc61e4bcd487a",
17
- "sha256": "98857d8c93689295c63060f29ceae7d4879164fa6299fb53411dc61e4bcd487a"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:8856a2392fd25bd6a7052cfcb04fbe3f57cb90bf4da2811f63f17ccb74e8846f",
17
+ "sha256": "8856a2392fd25bd6a7052cfcb04fbe3f57cb90bf4da2811f63f17ccb74e8846f"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:c7f04ac6e9c1ec03a5204a5c80b5ac9a5d1a45e07191ecf25cc904256fee90a2",
21
- "sha256": "c7f04ac6e9c1ec03a5204a5c80b5ac9a5d1a45e07191ecf25cc904256fee90a2"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:b07d3c7f1ece6164a711de4cd611d9dba73f230935aea89fdb9e58856a913fa4",
21
+ "sha256": "b07d3c7f1ece6164a711de4cd611d9dba73f230935aea89fdb9e58856a913fa4"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:bf38cccfec86624a622284cfede1288ff1f353f0767f560d1068785595d72a02",
25
- "sha256": "bf38cccfec86624a622284cfede1288ff1f353f0767f560d1068785595d72a02"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:4c7a72ed19ce3780b81350d8e33fa72bc7b67cf2619776c36559748e10b1ea2e",
25
+ "sha256": "4c7a72ed19ce3780b81350d8e33fa72bc7b67cf2619776c36559748e10b1ea2e"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -146,11 +146,60 @@ export function kitPaths(root) {
146
146
  const local = join(kit, "local");
147
147
  return { kit, local, declaration: join(kit, "integrations.json"), app: join(kit, "app.json"), lock: join(kit, "kit.lock.json"), devLock: join(local, "dev.lock"), state: join(local, "state"), report: join(local, "check-report.json"), deployNote: join(local, "deploy.json") };
148
148
  }
149
+ /** The description `init` writes: recognisable later, so a profile nobody has reviewed is never sent to IT. */
150
+ const STARTER_DESCRIPTION_SUFFIX = ": an app built on Isomorph.";
149
151
  export function defaultAppProfile(root) {
150
- // A real description from the start: the person changes it, but a first
151
- // deploy is never refused for a field they have not been shown yet.
152
+ // A recognisable starter: `profileReviewProblem` refuses it before the first
153
+ // access request, so the person is shown the name and description exactly
154
+ // once, before IT sees either, and never again after.
152
155
  const name = basename(resolve(root));
153
- return { schema: "isomorph.app/1.0", name, description: `${name}: an app built on Isomorph.`, audience: [] };
156
+ return { schema: "isomorph.app/1.0", name, description: `${name}${STARTER_DESCRIPTION_SUFFIX}`, audience: [] };
157
+ }
158
+ /** Governance's bounds for the profile (maker-app-actions `validateAppProfileInput`), applied here so the refusal is local and names the field. */
159
+ const PROFILE_NAME_LENGTH = { min: 3, max: 80 };
160
+ const PROFILE_DESCRIPTION_LENGTH = { min: 20, max: 240 };
161
+ /**
162
+ * Why the profile cannot go to IT yet, or `undefined` when it can. The first
163
+ * access request carries the app's name and description into IT's queue, and
164
+ * a name IT has seen cannot be unseen: before 0.8.3 that request went out with
165
+ * the folder name (`my-slack-thing-v2`) and no description, and the real name
166
+ * only arrived with `deploy` — relabelling the ask under the admin (2026-09-18).
167
+ * So the starter values `init` wrote are refused until the builder has shown
168
+ * them to the person and written what they said; a reviewed profile passes
169
+ * every later run unchanged.
170
+ */
171
+ export function profileReviewProblem(profile, root) {
172
+ const name = profile.name.trim();
173
+ const description = profile.description.trim();
174
+ if (!name)
175
+ return "`name` is empty";
176
+ if (name === basename(resolve(root)))
177
+ return "`name` is still the folder name";
178
+ if (!description)
179
+ return "`description` is empty";
180
+ if (description.endsWith(STARTER_DESCRIPTION_SUFFIX))
181
+ return "`description` is still the starter text";
182
+ if (name.length < PROFILE_NAME_LENGTH.min || name.length > PROFILE_NAME_LENGTH.max)
183
+ return `\`name\` must be ${PROFILE_NAME_LENGTH.min}-${PROFILE_NAME_LENGTH.max} characters`;
184
+ if (description.length < PROFILE_DESCRIPTION_LENGTH.min || description.length > PROFILE_DESCRIPTION_LENGTH.max)
185
+ return `\`description\` must be ${PROFILE_DESCRIPTION_LENGTH.min}-${PROFILE_DESCRIPTION_LENGTH.max} characters`;
186
+ return undefined;
187
+ }
188
+ /** The profile as the first access request may carry it, or the one refusal that says what to review. */
189
+ export async function readReviewedAppProfile(root) {
190
+ const profile = await readAppProfile(root);
191
+ const problem = profileReviewProblem(profile, root);
192
+ if (problem)
193
+ throw new CliError("APP_PROFILE_UNREVIEWED", `IT will see this app by its name and description, and .isomorph/app.json has not been reviewed yet: ${problem}.`, undefined, "Show the person the `name` and `description` in .isomorph/app.json, write what they say (a name for colleagues, one sentence on what the app does), then run this again. This is asked once: a reviewed profile is never asked about again.");
194
+ return profile;
195
+ }
196
+ /** What changes the app's standing with the company: the declaration and the profile the first request carries. */
197
+ export async function accessDigest(root) {
198
+ const paths = kitPaths(root);
199
+ const hash = createHash("sha256");
200
+ for (const path of [paths.declaration, paths.app])
201
+ hash.update(await readFile(path).catch(() => Buffer.alloc(0))).update("\0");
202
+ return `sha256:${hash.digest("hex")}`;
154
203
  }
155
204
  /** `.isomorph/app.json` as `init` writes it. */
156
205
  export function renderAppProfile(profile) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "harbour": {
38
38
  "kitBundle": {
39
39
  "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
40
- "version": "0.7.2"
40
+ "version": "0.8.2"
41
41
  }
42
42
  }
43
43
  }