@isomorph.ai/cli 0.3.0 → 0.3.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,12 +36,14 @@ isomorph integrations catalog --app-root <path> [--json] the company's connect
36
36
  isomorph integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`isomorph dev` and `isomorph productionise` file it for you)
37
37
  isomorph integrations status --app-root <path> [--json]
38
38
  isomorph connect <work-email | company-start-url> once per company; then isomorph login | logout
39
- isomorph productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
40
- isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference>
39
+ isomorph productionise --app-root <path> [--confirm-save] [--json] save + preview deployment, prints the protected link
40
+ isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference> (promote: [--confirm-tested])
41
41
  ```
42
42
 
43
43
  `productionise`, `status --wait`, `retry` and `promote` follow the deployment for up to `--max-wait <seconds>` (default 30 minutes). Past that they exit 0 with `status: "RUNNING"` and the `isomorph status --operation <reference> --wait --max-wait 120 --json` that continues the same deployment — a bounded wait is not a failure, and never a reason to start another deploy.
44
44
 
45
+ `productionise` and `promote` each stop for a typed reply — `Approved.` before the app files are copied to the company code system, `Tested.` before the preview is released. `--confirm-save` and `--confirm-tested` give that same reply on the command line when nobody can type it.
46
+
45
47
  `isomorph --help` prints the full usage. Local commands need no company sign-in; integrations and shipping do.
46
48
 
47
49
  ## Apps set up by the old `harbour` CLI
@@ -220,8 +220,8 @@ The kit's rules are the files beside this one: \`core.md\` before the first edit
220
220
  - "does it work?", and before you report anything as working → open the dev link in your own browser, press the control you built or changed, and read what the app shows. A green \`isomorph check\` is not that proof: fixtures answer AI and company systems, so their refusals appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically; a Send in development is real: reuse explicit authorization for that bounded test, or ask once if none exists (IT approval alone is not permission to send). If you have no browser, say that the button itself is untested.
221
221
  - "I need Slack / Gmail / the warehouse / company data" → read \`integrations.md\` beside this skill first; then, in the same turn, read the catalog, declare only what the app calls, submit the access request yourself, and say READY or PENDING in one line.
222
222
  - "summarise", "draft", "explain", "AI" → read \`ai.md\` beside this skill first; then one call behind a control they press.
223
- - "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked, and fix everything it finds: the same gates run again in the cloud, minutes per attempt. Before sharing or deploying the private preview, show the exact proposed app name, description and audience that will be passed to Isomorph (say "only you" when the audience is empty) and ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. Then \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\` (omit \`--emails\` for "only you"). It records the confirmed setup before uploading or deploying the app, files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve, or with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without them until then. \`result.deployment.protectedUrl\` is the private preview, for them and the confirmed audience after company sign-in. If your tool cuts it off, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened; \`TRANSFORMING\` means building and checking.
224
- - "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph promote --operation <ref> --json\`, show the exact app name, description and production audience again and ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Report the production link, or that an operator approval is pending.
223
+ - "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked; fix everything it finds: the same gates rerun in the cloud, minutes per attempt. Before sharing or deploying the private preview, show the exact proposed app name, description and audience that will be passed to Isomorph (say "only you" when the audience is empty); ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --confirm-save --json\` (omit \`--emails\` for "only you"; \`--confirm-save\` is the approval they just gave). It records the confirmed setup before uploading or deploying the app, files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve, or \`AI_NOT_READY\` and the one IT step: relay that line only; the app works without them until then. \`result.deployment.protectedUrl\` is the private preview, for them and the confirmed audience after company sign-in. If your tool cuts it off, \`${continueCommand("<ref>")}\` continues the same deployment; never start another one to find out what happened; \`TRANSFORMING\` means building and checking.
224
+ - "make it live for everyone", "go to production" → only after they have tried the preview, which \`--confirm-tested\` states. Before \`isomorph promote --operation <ref> --confirm-tested --json\`, show the exact app name, description and production audience again; ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Report the production link, or that operator approval is pending.
225
225
  - "stop it" → \`isomorph stop --app-root .\` (data kept); \`isomorph dev --reset --app-root .\` deletes local data, only when they ask to start over.
226
226
  - "every day at 2pm", "run this on a schedule", "send this automatically" → read \`jobs.md\` beside this skill first; then write the job, run it once with \`isomorph jobs run\`, and say the sentence it gives you.
227
227
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { RemoteMcpClient } from "./remote-mcp-client.js";
3
- import { appCallsAi, productionise } from "./productionise.js";
4
- import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
3
+ import { appCallsAi, productionise, SAVE_APPROVAL } from "./productionise.js";
4
+ import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchStatus, follow, getAppSetup, listSecrets, promoteToProduction, PROMOTION_CONFIRMATION, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize } from "./operations.js";
5
5
  import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
@@ -28,6 +28,9 @@ const profileDescription = optionValue("--description");
28
28
  const audienceEmails = optionValue("--emails");
29
29
  const personal = args.includes("--personal");
30
30
  const valueStdin = args.includes("--value-stdin");
31
+ /** The reply the save and promote prompts would otherwise wait to have typed, for a caller with no terminal. */
32
+ const confirmSave = args.includes("--confirm-save");
33
+ const confirmTested = args.includes("--confirm-tested");
31
34
  const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
32
35
  const wait = args.includes("--wait");
33
36
  const reset = args.includes("--reset");
@@ -62,10 +65,10 @@ const usage = [
62
65
  "Usage:",
63
66
  " isomorph connect <work-email | company-start-url>",
64
67
  " isomorph login | logout",
65
- " isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--no-wait] [--max-wait <seconds>] [--json]",
68
+ " isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--confirm-save] [--no-wait] [--max-wait <seconds>] [--json]",
66
69
  " isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
67
70
  " isomorph retry --operation <reference> [--no-wait] [--max-wait <seconds>] [--json]",
68
- " isomorph promote --operation <reference> [--app-root <path>] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
71
+ " isomorph promote --operation <reference> [--app-root <path>] [--confirm-tested] [--no-wait] [--max-wait <seconds>] [--json] (--app-root: also confirm the company's AI setup for production when the app calls governed AI)",
69
72
  " isomorph setup --operation <reference> [--json] what the app still needs (name, audience, secrets)",
70
73
  " isomorph profile --operation <reference> [--name <text>] [--description <text>] [--json]",
71
74
  " isomorph audience --operation <reference> [--emails a@co,b@co] [--json] (no --emails = only you)",
@@ -83,6 +86,7 @@ const usage = [
83
86
  " isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
84
87
  "Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
85
88
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
89
+ "productionise stops for a typed `Approved.` before it copies the app files to the company code system, and promote for a typed `Tested.` before it releases the preview. --confirm-save and --confirm-tested give that same reply on the command line when nobody can type it; the approval recorded is the one made by whoever ran the command.",
86
90
  `--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
87
91
  "For kit apps, productionise first files the access request for every connection in .isomorph/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
88
92
  "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
@@ -95,6 +99,14 @@ const progress = (message) => { process.stderr.write(`${message}\n`); };
95
99
  const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
96
100
  /** `line` is the human form for a command whose result is a sentence, not a summary. */
97
101
  const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : line ?? renderSummary(value)); process.exitCode = 0; };
102
+ /**
103
+ * The command's last bytes, then its exit code. Every non-zero exit goes through
104
+ * here: `process.exitCode` is not ours alone — a dependency's `beforeExit` hook
105
+ * overwrites it (see local-runtime.ts) — and `process.exit` on its own truncates
106
+ * a write still in flight when the stream is a pipe rather than a terminal,
107
+ * which is how an agent or a CI job reads this CLI. Both writes flush first.
108
+ */
109
+ const exitAfterWriting = (code, out, err = "") => { process.stderr.write(err, () => process.stdout.write(out, () => process.exit(code))); };
98
110
  /** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
99
111
  const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
100
112
  if (command === "--version" || command === "version") {
@@ -127,10 +139,7 @@ else if (!["connect", "login", "logout", "productionise", "integrations", ...LOC
127
139
  || (command === "integrations" && (!root || !subcommand || !["request", "status", "catalog"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--")))))
128
140
  || (OPERATION_COMMANDS.includes(command) && !operationRef)
129
141
  || (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
130
- if (optionError)
131
- process.stderr.write(`${optionError}\n`);
132
- process.stderr.write(usage);
133
- process.exit(2);
142
+ exitAfterWriting(2, "", `${optionError ? `${optionError}\n` : ""}${usage}`);
134
143
  }
135
144
  else {
136
145
  try {
@@ -192,10 +201,8 @@ else {
192
201
  governance = new GovernanceClient(config.apiUrl, token, config.tenantId);
193
202
  }
194
203
  const report = await runChecks(target, { run: runCommand, bundle, output: progress, governance });
195
- if (!report.passed) {
196
- process.stdout.write(json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .isomorph/local/check-report.json." } })}\n` : "Checks failed; see .isomorph/local/check-report.json.\n");
197
- process.exitCode = 1;
198
- }
204
+ if (!report.passed)
205
+ exitAfterWriting(1, json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .isomorph/local/check-report.json." } })}\n` : "Checks failed; see .isomorph/local/check-report.json.\n");
199
206
  else
200
207
  emit(summaryEnvelope(report));
201
208
  }
@@ -257,7 +264,7 @@ else {
257
264
  ? { displayName: profileName, description: profileDescription, audienceEmails: audienceEmails ? audienceEmails.split(",").map(value => value.trim()).filter(Boolean) : [] }
258
265
  : undefined;
259
266
  await assertKitCurrent(appRoot(root));
260
- const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmedAppSetup ? { confirmedAppSetup } : {}) });
267
+ const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, waitOptions, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: EMBEDDED_KIT_BUNDLE }, ...(confirmSave ? { readApproval: async () => SAVE_APPROVAL } : {}), ...(confirmedAppSetup ? { confirmedAppSetup } : {}) });
261
268
  envelope = operationEnvelope(result.result, result.operationRef, CLI_VERSION);
262
269
  }
263
270
  else {
@@ -273,7 +280,7 @@ else {
273
280
  if (lock?.appId)
274
281
  await assertAiReady(lock.appId, "production", new GovernanceClient(config.apiUrl, token, tenant), progress);
275
282
  }
276
- summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions });
283
+ summary = await promoteToProduction(client, operationRef, progress, { wait: !noWait, waitOptions, ...(confirmTested ? { readConfirmation: async () => PROMOTION_CONFIRMATION } : {}) });
277
284
  }
278
285
  else if (command === "setup")
279
286
  summary = { setup: await getAppSetup(client, operationRef) };
@@ -295,10 +302,7 @@ else {
295
302
  }
296
303
  catch (error) {
297
304
  const envelope = failureEnvelope(error, CLI_VERSION);
298
- process.stderr.write(renderFailure(envelope));
299
- if (json || command === "productionise")
300
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
301
- process.exit(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1);
305
+ exitAfterWriting(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1, json || command === "productionise" ? `${JSON.stringify(envelope)}\n` : "", renderFailure(envelope));
302
306
  }
303
307
  }
304
308
  /** One line per (connection, identity): the three lanes are one approval, so they are read together. */
@@ -4,7 +4,6 @@ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promi
4
4
  import { createServer } from "node:net";
5
5
  import { dirname, join, relative, win32 } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import EmbeddedPostgres from "embedded-postgres";
8
7
  import { kitPaths, projectName } from "./kit.js";
9
8
  import { CliError } from "./output.js";
10
9
  export function nodePackageCommand(tool, args, platform = process.platform, execPath = process.execPath) {
@@ -209,6 +208,7 @@ export class LocalRuntime {
209
208
  const paths = kitPaths(this.root);
210
209
  const postgresBinaries = await hydrateEmbeddedPostgres(this.run);
211
210
  const postgresMessages = [];
211
+ const { default: EmbeddedPostgres } = await import("embedded-postgres");
212
212
  this.postgres = new EmbeddedPostgres({ databaseDir: join(paths.state, "postgres"), port: this.ports.postgres, user: LOCAL.dbUser, password: LOCAL.dbPassword, persistent: true, onLog: message => postgresMessages.push(String(message).trim()), onError: error => postgresMessages.push(String(error)) });
213
213
  try {
214
214
  if (!(await readdir(join(paths.state, "postgres")).catch(() => [])).length)
@@ -250,6 +250,10 @@ export async function retryDeployment(client, operationRef, output, options = {}
250
250
  return summarize(await fetchStatus(client, operationRef));
251
251
  return follow(client, operationRef, output, options.waitOptions);
252
252
  }
253
+ /** What the maker types at the promote prompt. `--confirm-tested` supplies this same literal through
254
+ * `readConfirmation`, so governance receives the attestation it receives today, byte for byte — the
255
+ * claim is unchanged, only reachable where no terminal can answer the prompt. */
256
+ export const PROMOTION_CONFIRMATION = "Tested.";
253
257
  /**
254
258
  * Promotion mirrors the console's "I tested this version" button: the maker
255
259
  * must have opened the live preview and say so, and the version they tested
@@ -268,8 +272,8 @@ export async function promoteToProduction(client, operationRef, output, options
268
272
  output(`Open the protected preview and test it: ${deployment.protectedUrl}`);
269
273
  output("When it works as expected, type Tested. then press Enter to promote it to production.");
270
274
  const reply = await (options.readConfirmation ?? readLine)();
271
- if (reply !== "Tested.")
272
- throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed.", operationRef);
275
+ if (reply !== PROMOTION_CONFIRMATION)
276
+ throw new CliError("PROMOTION_NOT_CONFIRMED", "The production promotion was not confirmed. Confirm the tested preview with --confirm-tested instead.", operationRef);
273
277
  const result = structured(await client.call("isomorph_promote_to_production", {
274
278
  operationId: operationRef,
275
279
  expectedPreviewVersionId: deployment.versionId,
@@ -12,6 +12,9 @@ import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
13
13
  import { kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
14
14
  import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
15
+ /** What the maker types at the save prompt; `--confirm-save` supplies this same literal, so the
16
+ * `isomorph.stage-approval/1.0` attestation governance receives is unchanged, byte for byte. */
17
+ export const SAVE_APPROVAL = "Approved.";
15
18
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
16
19
  const root = resolve(rootArg);
17
20
  output(`Isomorph is checking ${basename(root)}.`);
@@ -159,9 +162,9 @@ export async function productionise(rootArg, client, output, tenantId, includePa
159
162
  let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
160
163
  if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
161
164
  output(`Approval required: Isomorph can copy ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
162
- const approval = await readApproval();
163
- if (approval !== "Approved.")
164
- throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved.", operationRef);
165
+ const approval = await (options.readApproval ?? readLine)();
166
+ if (approval !== SAVE_APPROVAL)
167
+ throw new CliError("APPROVAL_NOT_GRANTED", "The Isomorph save was not approved. Approve copying the app files with --confirm-save instead.", operationRef);
165
168
  execution = await execute(client, operationRef, appId, graph, { schema: "isomorph.stage-approval/1.0", step: "save_source_baseline", approved: true, approvedByUser: true, userApprovalText: approval, userVisibleProgress: "Approved.", nextStepSummary: "Isomorph can safely save the app.", dataOrActions: ["Save the app in the company code system."] });
166
169
  }
167
170
  if (execution.status === "ADMIN_SETUP_REQUIRED")
@@ -437,7 +440,6 @@ function outcomeLine(summary) {
437
440
  default: return summary.nextStep ?? "Isomorph saved the app; deployment has not started.";
438
441
  }
439
442
  }
440
- const readApproval = readLine;
441
443
  /**
442
444
  * Whether the app really calls governed AI: the gate's inventory of
443
445
  * `isomorph.ai.*` call sites (`.isomorph/local/check-report.json`,
@@ -421,8 +421,11 @@ export function App() {
421
421
  {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
422
422
 
423
423
  {/* Notes — paired with .isomorph/checks/notes-journey.mjs and notes-cross-user.mjs, generated
424
- from this section and migrations/0001_notes.sql; replace the table and \`isomorph check\`
425
- rewrites both. Rules: core.md in the isomorph skill (data). */}
424
+ from this section and migrations/0001_notes.sql; \`isomorph check\` rewrites both whenever
425
+ either one changes. Edit 0001_notes.sql in place only until this app's first deploy: an
426
+ applied migration is frozen, so after that the schema changes by adding the next
427
+ migrations/000N_*.sql — never by editing or deleting an applied file, and never by
428
+ dropping a deployed table. Rules: core.md in the isomorph skill (data). */}
426
429
  <section>
427
430
  <h2>Notes</h2>
428
431
  <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.3.0";
1
+ export const CLI_VERSION = "0.3.1-rc.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-rc.1",
4
4
  "description": "Isomorph development kit CLI",
5
5
  "type": "module",
6
6
  "bin": {