@isomorph.ai/cli 0.10.9 → 0.11.0
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/auth.js +113 -12
- package/dist/packages/harbour-cli/src/cli.js +24 -9
- package/dist/packages/harbour-cli/src/config.js +26 -3
- package/dist/packages/harbour-cli/src/deploy.js +12 -1
- package/dist/packages/harbour-cli/src/integrations.js +35 -10
- package/dist/packages/harbour-cli/src/share.js +25 -0
- package/package.json +1 -1
|
@@ -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
|
|
123
|
+
| "ship it", "let my team try it" | show the person the three values in \`.isomorph/app.json\` (name, description, audience — "only you" when empty) and correct the file as they say; then \`isomorph deploy --app-root . --json\` (it runs the checks when needed and refuses once naming every blocker and its fix: relay that line; the app works meanwhile). To give someone access later: add their email to \`audience\`, then \`isomorph share --app-root . --json\`, never \`deploy\`. Give them \`result.deployment.protectedUrl\` and \`result.consoleUrl\` (its live checks). \`--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
|
-
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", () => { }); }
|
|
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", () => { }); }
|
|
@@ -3,7 +3,7 @@ import { appCallsAi, deploy, 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, 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";
|
|
@@ -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];
|
|
@@ -62,6 +63,7 @@ const usage = [
|
|
|
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("--")))))) {
|
|
@@ -228,9 +230,14 @@ else {
|
|
|
228
230
|
// every unattended builder five minutes (auth.ts loopbackCallback), and
|
|
229
231
|
// when the sign-in could not complete, agents escalated to `logout`
|
|
230
232
|
// — which signs the whole machine out (bench, 2026-09-16).
|
|
231
|
-
|
|
233
|
+
// A device still holding the adapter's token for a company that now
|
|
234
|
+
// signs the CLI in through its own Cognito client (0.11.0) re-signs:
|
|
235
|
+
// that token cannot open the console's routes (`share`), and "already
|
|
236
|
+
// signed in" would send the agent to `logout` to switch.
|
|
237
|
+
const stale = Boolean(config.login) && (await loadStoredToken(url, tenant))?.kind !== "cognito";
|
|
238
|
+
const already = stale ? undefined : await alreadySignedIn(url, tenant);
|
|
232
239
|
if (!already)
|
|
233
|
-
await login(url, tenant, progress);
|
|
240
|
+
await login(url, tenant, progress, { ...(config.login ? { login: config.login } : {}) });
|
|
234
241
|
else
|
|
235
242
|
progress(`Already signed in to ${companyLabel(config)} as ${already}; nothing to do. Run \`isomorph logout\` first to sign in as someone else.`);
|
|
236
243
|
const signedIn = await signedInDetails(config);
|
|
@@ -248,7 +255,15 @@ else {
|
|
|
248
255
|
// The client re-resolves per request so a token rotated by a sibling process mid-poll is picked up.
|
|
249
256
|
const governance = new GovernanceClient(config.apiUrl, resolveToken, tenant);
|
|
250
257
|
let envelope;
|
|
251
|
-
|
|
258
|
+
/** The human form, for a command whose result is a sentence rather than a summary. */
|
|
259
|
+
let line;
|
|
260
|
+
if (command === "share") {
|
|
261
|
+
// An explicit ISOMORPH_TOKEN is the operator's to vouch for; a stored record must be the company sign-in.
|
|
262
|
+
const shared = await shareApp(appRoot(root), governance, tenant, EMBEDDED_KIT_BUNDLE, explicitToken ? undefined : (await loadStoredToken(url, tenant)) ?? {});
|
|
263
|
+
envelope = summaryEnvelope(shared.outcome);
|
|
264
|
+
line = renderShare(shared.audience);
|
|
265
|
+
}
|
|
266
|
+
else if (command === "integrations") {
|
|
252
267
|
const target = appRoot(root);
|
|
253
268
|
const result = subcommand === "connect"
|
|
254
269
|
? await connectIntegration(target, governance, args[2], optionValue("--return-url"))
|
|
@@ -266,7 +281,7 @@ else {
|
|
|
266
281
|
}
|
|
267
282
|
}
|
|
268
283
|
else if (command === "deploy") {
|
|
269
|
-
const result = await deploy(root, governance, progress, tenant, { waitForDeployment: !noWait, waitOptions, bundle: EMBEDDED_KIT_BUNDLE, ...(sessionNotes ? { sessionNotes } : {}) });
|
|
284
|
+
const result = await deploy(root, governance, progress, tenant, { waitForDeployment: !noWait, waitOptions, bundle: EMBEDDED_KIT_BUNDLE, ...(sessionNotes ? { sessionNotes } : {}), ...(process.env.CI || process.env.ISOMORPH_NO_BROWSER ? {} : { openConsole: openBrowser }) });
|
|
270
285
|
envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
|
|
271
286
|
}
|
|
272
287
|
else {
|
|
@@ -295,7 +310,7 @@ else {
|
|
|
295
310
|
}
|
|
296
311
|
envelope = operationEnvelope(summary, ref, CLI_VERSION);
|
|
297
312
|
}
|
|
298
|
-
|
|
313
|
+
emit(envelope, line);
|
|
299
314
|
}
|
|
300
315
|
}
|
|
301
316
|
}
|
|
@@ -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,9 +142,29 @@ 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
|
}
|
|
162
|
+
/** The console page where the builder watches one deployment: the app's releases, focused on this operation. */
|
|
163
|
+
export function consoleOperationUrl(tenantId, appId, operationRef) {
|
|
164
|
+
const url = new URL(`${platformOrigin().replace(/\/$/, "")}/t/${encodeURIComponent(tenantId)}`);
|
|
165
|
+
url.search = new URLSearchParams({ appId, tab: "releases", operationId: operationRef }).toString();
|
|
166
|
+
return url.toString();
|
|
167
|
+
}
|
|
146
168
|
/**
|
|
147
169
|
* The link as the person typed it, made fetchable: the console's company page
|
|
148
170
|
* (`/t/<company>`) is a start link too, a link without its scheme (as a browser
|
|
@@ -173,7 +195,7 @@ export function resolveConfig(env, saved) {
|
|
|
173
195
|
if (!mcpUrl || !tenantId)
|
|
174
196
|
return undefined;
|
|
175
197
|
// The saved name belongs to the saved tenant; an ISOMORPH_TENANT override names another company.
|
|
176
|
-
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 } : {}) };
|
|
177
199
|
}
|
|
178
200
|
/** The company as the CLI names it: `Fourier (fourier)`, or the id twice when the profile carried no name. */
|
|
179
201
|
export function companyLabel(config) {
|
|
@@ -196,7 +218,8 @@ function validConfig(value) {
|
|
|
196
218
|
&& /^https?:\/\//.test(value.mcpUrl)
|
|
197
219
|
&& (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
|
|
198
220
|
&& (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl)))
|
|
199
|
-
&& (value.minimumCliVersion === undefined || typeof value.minimumCliVersion === "string")
|
|
221
|
+
&& (value.minimumCliVersion === undefined || typeof value.minimumCliVersion === "string")
|
|
222
|
+
&& (value.login === undefined || validLogin(value.login)));
|
|
200
223
|
}
|
|
201
224
|
function isRecord(value) {
|
|
202
225
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -6,6 +6,7 @@ import { archiveForManifest, putMultipart } from "./upload.js";
|
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
7
|
import { continueCommand, follow, outcomeFor, pickSourceFailure, recordedRefusal, runningLine, statusFetch, summarize } from "./operations.js";
|
|
8
8
|
import { CLI_VERSION } from "./version.js";
|
|
9
|
+
import { consoleOperationUrl } from "./config.js";
|
|
9
10
|
import { assertSafeAppRoot, readAppTree } from "./package.js";
|
|
10
11
|
import { deployBlocked, ensureLinkedApp, kitLockBody } from "./integrations.js";
|
|
11
12
|
import { assertLockCompany, describeSourceChange, kitPaths, readReviewedAppProfile, readDeclaration, readKitLock, sourceDigest } from "./kit.js";
|
|
@@ -127,6 +128,16 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
127
128
|
if (opened.resumed && note?.sha256 !== archive.digest)
|
|
128
129
|
output(`Isomorph is continuing operation ${operationRef}; no new deployment was started.`);
|
|
129
130
|
output(`Isomorph operation saved. Continue with ${continueCommand(operationRef)}.`);
|
|
131
|
+
// Everything Isomorph checks, fixes and provisions for this version is shown
|
|
132
|
+
// live in the console; the builder is sent there the moment it starts.
|
|
133
|
+
const consoleUrl = consoleOperationUrl(tenantId, appId, operationRef);
|
|
134
|
+
output(`Watch Isomorph check, secure and deploy this app: ${consoleUrl}`);
|
|
135
|
+
if (!opened.resumed) {
|
|
136
|
+
try {
|
|
137
|
+
options.openConsole?.(consoleUrl);
|
|
138
|
+
}
|
|
139
|
+
catch { /* the printed link stands */ }
|
|
140
|
+
}
|
|
130
141
|
const fetch = statusFetch(governance, appId, operationRef);
|
|
131
142
|
try {
|
|
132
143
|
let status = opened.status;
|
|
@@ -156,7 +167,7 @@ async function runDeploy(rootArg, governance, output, tenantId, options) {
|
|
|
156
167
|
}
|
|
157
168
|
output(outcomeLine(summary));
|
|
158
169
|
}
|
|
159
|
-
return { cliVersion: CLI_VERSION, operationRef, result: summary };
|
|
170
|
+
return { cliVersion: CLI_VERSION, operationRef, result: { ...summary, consoleUrl } };
|
|
160
171
|
}
|
|
161
172
|
catch (error) {
|
|
162
173
|
throw await operationFailure(fetch, operationRef, error);
|
|
@@ -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";
|
|
@@ -74,8 +75,18 @@ export class GovernanceClient {
|
|
|
74
75
|
retryDeployment(appId, operationRef) {
|
|
75
76
|
return this.call("POST", `${deployments(appId)}/${encodeURIComponent(operationRef)}/retry`, {});
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Who may open the app: the console's own audience route, called with the
|
|
80
|
+
* company sign-in (a Cognito ID token) this CLI holds since 0.11.0. It changes
|
|
81
|
+
* the access policy only — no release. The route's refusals are a bare
|
|
82
|
+
* `{ error: "<code>" }`, unlike the `/v1/development/*` envelope, and a
|
|
83
|
+
* 401 is the gateway refusing a token of the old adapter kind.
|
|
84
|
+
*/
|
|
85
|
+
share(appId, emails) {
|
|
86
|
+
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 });
|
|
87
|
+
}
|
|
88
|
+
async call(method, path, body, options) {
|
|
89
|
+
return (await this.exchange(method, path, body, options)).data;
|
|
79
90
|
}
|
|
80
91
|
/**
|
|
81
92
|
* One request, with governance's refusal carried whole: its code (or category,
|
|
@@ -85,26 +96,40 @@ export class GovernanceClient {
|
|
|
85
96
|
* 5xx are named as such (`GOVERNANCE_UNREACHABLE`, `HTTP_5xx`) so a long poll
|
|
86
97
|
* can tell a transient fault from a refusal; see `waitForSettled`.
|
|
87
98
|
*/
|
|
88
|
-
async exchange(method, path, body) {
|
|
99
|
+
async exchange(method, path, body, options = {}) {
|
|
89
100
|
const url = `${this.apiUrl.replace(/\/$/, "")}${path}`;
|
|
90
|
-
let response = await this.send(url, method, body);
|
|
101
|
+
let response = await this.send(url, method, body, options.headers);
|
|
91
102
|
if (response.status === 401)
|
|
92
|
-
response = await this.send(url, method, body);
|
|
103
|
+
response = await this.send(url, method, body, options.headers);
|
|
93
104
|
const parsed = await response.json().catch(() => ({}));
|
|
94
105
|
if (response.status === 401)
|
|
95
|
-
throw new CliError("AUTH_REQUIRED", "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
106
|
+
throw new CliError("AUTH_REQUIRED", options.unauthorized ?? "Please sign in to Isomorph with `isomorph login`.", undefined, undefined, undefined, { layer: "governance" });
|
|
107
|
+
// The maker routes (the console's) refuse with `{ error: "<code>" }` and no sentence: the CLI's own words for each code.
|
|
108
|
+
if (!response.ok && typeof parsed.error === "string")
|
|
109
|
+
throw new CliError(parsed.error.toUpperCase(), options.refusals?.[parsed.error] ?? "Isomorph governance rejected the request.", undefined, undefined, undefined, { layer: "governance" });
|
|
96
110
|
if (!response.ok) {
|
|
97
|
-
const
|
|
98
|
-
|
|
111
|
+
const failure = typeof parsed.error === "object" ? parsed.error : undefined;
|
|
112
|
+
const { code, remediationHint, ...details } = failure?.details ?? {};
|
|
113
|
+
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
114
|
}
|
|
100
115
|
return { status: response.status, data: (parsed.data ?? parsed) };
|
|
101
116
|
}
|
|
102
|
-
async send(url, method, body) {
|
|
117
|
+
async send(url, method, body, headers = {}) {
|
|
103
118
|
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" })
|
|
119
|
+
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
120
|
.catch((error) => { throw new CliError("GOVERNANCE_UNREACHABLE", `Isomorph governance at ${new URL(url).host} could not be reached: ${transportFailure(error)}.`); });
|
|
106
121
|
}
|
|
107
122
|
}
|
|
123
|
+
/** The console's refusals of an audience change, in the words it shows (makerAppActionPlainEnglish). */
|
|
124
|
+
export const SHARE_REFUSALS = {
|
|
125
|
+
app_access_not_owned: "Only the builder who registered this app can change it.",
|
|
126
|
+
individual_sharing_disabled: "This company has not enabled sharing apps with named colleagues. Confirm an empty audience, or ask an administrator.",
|
|
127
|
+
external_email_not_allowed: "Only email addresses on the company's approved domains can be given access.",
|
|
128
|
+
invalid_share_emails: "Audience must be a list of up to 100 company email addresses.",
|
|
129
|
+
app_not_found: "Isomorph has no registered app for this operation."
|
|
130
|
+
};
|
|
131
|
+
/** A 401 from the audience route, or a stored sign-in of the adapter kind: the maker routes take only the company sign-in. */
|
|
132
|
+
export const SHARE_SIGN_IN = "Sign in again with `isomorph login`: sharing needs the company sign-in this CLI now uses.";
|
|
108
133
|
const deployments = (appId) => `/v1/development/apps/${encodeURIComponent(appId)}/deployments`;
|
|
109
134
|
/**
|
|
110
135
|
* What actually failed under a `fetch` rejection. undici reports every transport
|
|
@@ -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
|
+
}
|