@isomorph.ai/cli 0.10.10 → 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.
@@ -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\` (its live checks). \`--session-notes\`: what cost you time in Isomorph, or \`""\`. |
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
- openBrowser(authorize.toString());
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
- await saveStoredToken(mcpUrl, tenant, { ...stored, ...refreshed, refreshToken: refreshed.refreshToken ?? stored.refreshToken }, path);
111
- return refreshed.accessToken;
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
- /** Email of the signed-in company account, from the OAuth userinfo endpoint beside the token endpoint; undefined when unavailable. */
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
- function loopbackCallback() {
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(0, "127.0.0.1", () => {
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", () => { }); }
@@ -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, 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";
@@ -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
- const already = await alreadySignedIn(url, tenant);
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
- if (command === "integrations") {
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"))
@@ -295,7 +310,7 @@ else {
295
310
  }
296
311
  envelope = operationEnvelope(summary, ref, CLI_VERSION);
297
312
  }
298
- process.stdout.write(json ? `${JSON.stringify(envelope)}\n` : renderSummary(envelope));
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,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));
@@ -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
- async call(method, path, body) {
78
- return (await this.exchange(method, path, body)).data;
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 { code, remediationHint, ...details } = parsed.error?.details ?? {};
98
- throw new CliError(code ?? parsed.error?.category ?? `HTTP_${response.status}`, parsed.error?.message || "Isomorph governance rejected the request.", undefined, typeof remediationHint === "string" && remediationHint.trim() ? remediationHint.trim() : undefined, undefined, { layer: "governance", ...(Object.keys(details).length ? { details } : {}) });
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {