@isomorph.ai/cli 0.8.2 → 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.
- package/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/deploy.js +7 -8
- package/dist/packages/harbour-cli/src/dev.js +15 -10
- package/dist/packages/harbour-cli/src/integrations.js +21 -9
- package/dist/packages/harbour-cli/src/kit.js +52 -3
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
|
|
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 {
|
|
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`
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 = [];
|
|
@@ -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
|
|
151
|
-
//
|
|
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}
|
|
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) {
|