@isomorph.ai/cli 0.3.4 → 0.4.0-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 +12 -14
- package/dist/packages/harbour-cli/src/agent-setup.js +25 -125
- package/dist/packages/harbour-cli/src/auth.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +7 -34
- package/dist/packages/harbour-cli/src/cli.js +46 -100
- package/dist/packages/harbour-cli/src/config.js +51 -3
- package/dist/packages/harbour-cli/src/{productionise.js → deploy.js} +130 -183
- package/dist/packages/harbour-cli/src/dev.js +81 -2
- package/dist/packages/harbour-cli/src/guide.js +31 -45
- package/dist/packages/harbour-cli/src/integrations.js +47 -74
- package/dist/packages/harbour-cli/src/kit-bundle.js +0 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +9 -9
- package/dist/packages/harbour-cli/src/kit.js +43 -16
- package/dist/packages/harbour-cli/src/local-runtime.js +23 -1
- package/dist/packages/harbour-cli/src/operations.js +11 -90
- package/dist/packages/harbour-cli/src/output.js +24 -41
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +4 -4
- package/dist/packages/harbour-cli/src/starter.js +21 -201
- package/dist/packages/harbour-cli/src/upload.js +1 -1
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +1 -1
- package/dist/src/secret-paths.js +1 -1
- package/package.json +2 -2
|
@@ -211,13 +211,13 @@ export function outcomeFor(status, operationRef) {
|
|
|
211
211
|
if (status.deployment)
|
|
212
212
|
return { ...summary, outcome: "running" };
|
|
213
213
|
// No deployment yet has two very different causes. A save that stalled or
|
|
214
|
-
// failed (RETRYABLE_FAILURE, FAILED) is resumed by running `
|
|
214
|
+
// failed (RETRYABLE_FAILURE, FAILED) is resumed by running `deploy`
|
|
215
215
|
// again — observed 2026-09-14 (fourier "Dad Jokes"): a builder read the
|
|
216
216
|
// administrator sentence for a save that was simply resumable. Only a
|
|
217
217
|
// SUCCEEDED save with no deployment behind it is the company-setup case.
|
|
218
218
|
const save = status.sourceSave?.status;
|
|
219
219
|
if (save && save !== "SUCCEEDED")
|
|
220
|
-
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph
|
|
220
|
+
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Isomorph has not finished saving the app (${save}). Run \`isomorph deploy\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
|
|
221
221
|
return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Isomorph saved the app but has not started a deployment for this company yet. Ask your Isomorph administrator to connect deployment." };
|
|
222
222
|
}
|
|
223
223
|
/**
|
|
@@ -250,9 +250,11 @@ 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
|
-
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
253
|
+
/**
|
|
254
|
+
* What governance records as the promotion attestation. `--confirm-tested` is
|
|
255
|
+
* the one flag that means "the person tried the preview"; nothing is prompted
|
|
256
|
+
* for, because the CLI is run by an agent and the person is in the browser.
|
|
257
|
+
*/
|
|
256
258
|
export const PROMOTION_CONFIRMATION = "Tested.";
|
|
257
259
|
/**
|
|
258
260
|
* Promotion mirrors the console's "I tested this version" button: the maker
|
|
@@ -269,30 +271,18 @@ export async function promoteToProduction(client, operationRef, output, options
|
|
|
269
271
|
const deployment = status.deployment;
|
|
270
272
|
if (deployment?.state !== "LIVE" || !deployment.versionId || !deployment.protectedUrl)
|
|
271
273
|
throw new CliError("LIVE_PREVIEW_REQUIRED", "The preview must be live before it can be promoted. Check `isomorph status` first.", operationRef);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const reply = await (options.readConfirmation ?? readLine)();
|
|
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);
|
|
274
|
+
if (!options.confirmedTested)
|
|
275
|
+
throw new CliError("PROMOTION_NOT_CONFIRMED", `The preview has not been confirmed as tested: ${deployment.protectedUrl}`, operationRef, "Once the person has opened the preview and it works, run this again with --confirm-tested.");
|
|
277
276
|
const result = structured(await client.call("isomorph_promote_to_production", {
|
|
278
277
|
operationId: operationRef,
|
|
279
278
|
expectedPreviewVersionId: deployment.versionId,
|
|
280
|
-
confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText:
|
|
279
|
+
confirmation: { schema: "isomorph.stage-approval/1.0", step: "confirm_preview_tested_and_promote", approved: true, approvedByUser: true, userApprovalText: PROMOTION_CONFIRMATION }
|
|
281
280
|
}));
|
|
282
281
|
output("Isomorph accepted the production promotion.");
|
|
283
282
|
if (options.wait === false)
|
|
284
283
|
return { ...summarize(status), ...(result.production ? { production: pickProduction(result.production) } : {}) };
|
|
285
284
|
return follow(client, operationRef, output, options.waitOptions);
|
|
286
285
|
}
|
|
287
|
-
export async function readLine() {
|
|
288
|
-
if (!process.stdin.isTTY) {
|
|
289
|
-
const chunks = [];
|
|
290
|
-
for await (const chunk of process.stdin)
|
|
291
|
-
chunks.push(Buffer.from(chunk));
|
|
292
|
-
return Buffer.concat(chunks).toString("utf8").trim();
|
|
293
|
-
}
|
|
294
|
-
return new Promise(resolve => { process.stdin.setEncoding("utf8"); process.stdin.once("data", value => resolve(String(value).trim())); });
|
|
295
|
-
}
|
|
296
286
|
export async function getAppSetup(client, operationRef) {
|
|
297
287
|
await client.initialize();
|
|
298
288
|
return structured(await client.call("isomorph_get_app_setup", { operationId: operationRef }));
|
|
@@ -302,7 +292,7 @@ export async function confirmProfile(client, operationRef, input, output) {
|
|
|
302
292
|
const displayName = input.displayName ?? setup.profile?.suggested?.displayName ?? setup.profile?.displayName;
|
|
303
293
|
const description = input.description ?? setup.profile?.suggested?.description ?? setup.profile?.description;
|
|
304
294
|
if (!displayName || !description)
|
|
305
|
-
throw new CliError("PROFILE_INCOMPLETE", "
|
|
295
|
+
throw new CliError("PROFILE_INCOMPLETE", "The app needs a name and a description in .isomorph/app.json; Isomorph had no suggestion to fall back on.", operationRef);
|
|
306
296
|
if (!setup.profile?.profileVersion)
|
|
307
297
|
throw new CliError("PROFILE_VERSION_MISSING", "Isomorph did not return the app details version.", operationRef);
|
|
308
298
|
await client.call("isomorph_confirm_app_profile", { operationId: operationRef, displayName, description, expectedVersion: setup.profile.profileVersion });
|
|
@@ -315,72 +305,3 @@ export async function confirmAudience(client, operationRef, emails, output) {
|
|
|
315
305
|
output(emails.length ? `Isomorph recorded the app's audience: ${emails.length} colleague${emails.length === 1 ? "" : "s"}.` : "Isomorph recorded that only you can open the app for now.");
|
|
316
306
|
return getAppSetup(client, operationRef);
|
|
317
307
|
}
|
|
318
|
-
export async function listSecrets(client, operationRef) {
|
|
319
|
-
return (await getAppSetup(client, operationRef)).secrets;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* The value is read by the CLI itself — from the terminal with echo off, or
|
|
323
|
-
* from stdin when the caller pipes it — and sent straight to Isomorph. It is
|
|
324
|
-
* never printed, never placed in the result, and never seen by an AI tool that
|
|
325
|
-
* merely launched this command.
|
|
326
|
-
*/
|
|
327
|
-
export async function setSecret(client, operationRef, input, output) {
|
|
328
|
-
await client.initialize();
|
|
329
|
-
const value = (await input.readValue()).replace(/\r?\n$/, "");
|
|
330
|
-
if (!value)
|
|
331
|
-
throw new CliError("SECRET_VALUE_REQUIRED", "No value was entered.", operationRef);
|
|
332
|
-
const result = structured(await client.call("isomorph_set_app_secret", { operationId: operationRef, name: input.name, value, scope: input.personal ? "personal" : "app", ...(input.personal ? { acknowledgePersonal: true } : {}) }));
|
|
333
|
-
output(`Isomorph stored the value for ${input.name}.${result.resumedOperationIds?.length ? " The paused deployment is resuming." : ""}`);
|
|
334
|
-
return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
|
|
335
|
-
}
|
|
336
|
-
export async function dismissSecret(client, operationRef, name, output) {
|
|
337
|
-
await client.initialize();
|
|
338
|
-
const result = structured(await client.call("isomorph_dismiss_app_secret", { operationId: operationRef, name }));
|
|
339
|
-
output(`Isomorph recorded that ${name} is not needed.`);
|
|
340
|
-
return { ...(result.secret ? { secret: result.secret } : {}), resumedOperationIds: result.resumedOperationIds ?? [] };
|
|
341
|
-
}
|
|
342
|
-
/** Read one line from the controlling terminal with echo off, so the value
|
|
343
|
-
* never lands in shell history, scrollback, or a parent process's capture. */
|
|
344
|
-
export async function readSecretFromTerminal(prompt) {
|
|
345
|
-
const { openSync, readSync, closeSync } = await import("node:fs");
|
|
346
|
-
let fd;
|
|
347
|
-
try {
|
|
348
|
-
fd = openSync("/dev/tty", "r+");
|
|
349
|
-
}
|
|
350
|
-
catch {
|
|
351
|
-
throw new CliError("NO_TERMINAL", "No terminal is available to enter the value. Pipe it on stdin with --value-stdin instead.");
|
|
352
|
-
}
|
|
353
|
-
const { writeSync } = await import("node:fs");
|
|
354
|
-
writeSync(fd, prompt);
|
|
355
|
-
const stty = await import("node:child_process");
|
|
356
|
-
try {
|
|
357
|
-
stty.execSync("stty -echo", { stdio: ["inherit", "ignore", "ignore"] });
|
|
358
|
-
}
|
|
359
|
-
catch { /* best effort: some terminals cannot toggle echo */ }
|
|
360
|
-
const chunks = [];
|
|
361
|
-
const buffer = Buffer.alloc(1);
|
|
362
|
-
try {
|
|
363
|
-
while (true) {
|
|
364
|
-
const read = readSync(fd, buffer, 0, 1, null);
|
|
365
|
-
if (read === 0 || buffer[0] === 0x0a)
|
|
366
|
-
break;
|
|
367
|
-
if (buffer[0] !== 0x0d)
|
|
368
|
-
chunks.push(Buffer.from(buffer));
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
finally {
|
|
372
|
-
try {
|
|
373
|
-
stty.execSync("stty echo", { stdio: ["inherit", "ignore", "ignore"] });
|
|
374
|
-
}
|
|
375
|
-
catch { /* ignore */ }
|
|
376
|
-
writeSync(fd, "\n");
|
|
377
|
-
closeSync(fd);
|
|
378
|
-
}
|
|
379
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
380
|
-
}
|
|
381
|
-
export async function readSecretFromStdin() {
|
|
382
|
-
const chunks = [];
|
|
383
|
-
for await (const chunk of process.stdin)
|
|
384
|
-
chunks.push(Buffer.from(chunk));
|
|
385
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
386
|
-
}
|
|
@@ -16,11 +16,15 @@ export const NO_DETAIL = "Isomorph could not complete the request.";
|
|
|
16
16
|
*/
|
|
17
17
|
export function safeError(error) {
|
|
18
18
|
const code = error instanceof CliError ? error.code : "CLI_FAILED";
|
|
19
|
+
const layer = error instanceof CliError ? error.layer : "cli";
|
|
19
20
|
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
20
21
|
// The hint is a separate field rather than more sentence: it is often as long
|
|
21
22
|
// as the message, and appending it would push the reason itself past the cap.
|
|
22
23
|
const hint = error instanceof CliError && error.remediationHint ? redact(error.remediationHint) : "";
|
|
23
|
-
|
|
24
|
+
// Paths and identifiers are a list field, never prose: `redact` bounds prose and
|
|
25
|
+
// would elide the one token the reader needs. They are carried whole, always.
|
|
26
|
+
const paths = error instanceof CliError && error.paths?.length ? error.paths : undefined;
|
|
27
|
+
return { code, layer, message: redact(raw) || NO_DETAIL, ...(hint ? { remediationHint: hint } : {}), ...(paths ? { paths } : {}) };
|
|
24
28
|
}
|
|
25
29
|
/** Anything that must never reach a terminal, a log or an envelope. */
|
|
26
30
|
const SECRETS = /https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi;
|
|
@@ -121,7 +125,9 @@ export function renderFailure(envelope) {
|
|
|
121
125
|
const error = envelope.error;
|
|
122
126
|
const result = (envelope.result ?? {});
|
|
123
127
|
const stages = ["production", "deployment"].filter(kind => result[kind]?.failure && result[kind]?.versionId).map(kind => `\n${stageLine(kind, result[kind])}`);
|
|
124
|
-
|
|
128
|
+
// One path per line, verbatim: never through `said`, which would bound them.
|
|
129
|
+
const paths = (error?.paths ?? []).map(path => `\n ${path}`);
|
|
130
|
+
return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}${paths.join("")}${stages.join("")}\n`;
|
|
125
131
|
}
|
|
126
132
|
/**
|
|
127
133
|
* Server-supplied text on its way to a terminal, given exactly the treatment
|
|
@@ -131,7 +137,7 @@ export function renderFailure(envelope) {
|
|
|
131
137
|
* `safeError` was `redact`’s only caller. `renderSummary` printed the server’s
|
|
132
138
|
* words straight from the payload, so on the success-shaped path — a deployment
|
|
133
139
|
* that reports a failure inside an otherwise-successful envelope, which is what
|
|
134
|
-
* `status` and `
|
|
140
|
+
* `status` and `deploy` emit after a failed build — that text reached the
|
|
135
141
|
* terminal both unredacted and unbounded. A presigned URL, a bearer token or a
|
|
136
142
|
* forwarded header dump printed verbatim into whatever captured stdout, and a
|
|
137
143
|
* pathological message printed whole.
|
|
@@ -161,17 +167,6 @@ function said(value) {
|
|
|
161
167
|
* - `deployment.versionId`, `production.versionId` — printed only on a failed
|
|
162
168
|
* stage, and for the same reason: it is the reference the console and the
|
|
163
169
|
* email carry for that attempt, and a bounded or reworded one names nothing.
|
|
164
|
-
* - `secrets.asks[].name` and `.prefilledFromPath` — identifiers, not prose. A
|
|
165
|
-
* secret named `AUTHORIZATION_TOKEN` matches the header pattern and would be
|
|
166
|
-
* erased whole, printing `Secret : needs a value`; the path is read off the
|
|
167
|
-
* local checkout rather than sent by the server.
|
|
168
|
-
* - `audience.emails` — the builder’s own confirmed list, echoed back. A
|
|
169
|
-
* 240-character bound over a joined list silently drops members, which is
|
|
170
|
-
* the information loss the tail-keeping change was about.
|
|
171
|
-
* - `ask.status`, `ask.scope`, `secrets.available`, `audience.confirmed` and
|
|
172
|
-
* the three mapped `pending` steps — compared against fixed literals, so
|
|
173
|
-
* only Isomorph’s own words ever print. An unmapped `pending` step is the
|
|
174
|
-
* server’s own id, so that one does go through `said`.
|
|
175
170
|
*
|
|
176
171
|
* `deployment.message` and `production.message` are declared on the payload and
|
|
177
172
|
* never printed here; nothing routes them, and a line that printed one would
|
|
@@ -208,32 +203,6 @@ export function renderSummary(envelope) {
|
|
|
208
203
|
lines.push(`Action needed: ${waiting}`);
|
|
209
204
|
else if (nextStep)
|
|
210
205
|
lines.push(`Next: ${nextStep}`);
|
|
211
|
-
const setup = envelope.result?.setup;
|
|
212
|
-
const secrets = setup?.secrets ?? envelope.result?.secrets;
|
|
213
|
-
if (setup?.profile) {
|
|
214
|
-
lines.push(`App name: ${said(setup.profile.displayName) || "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${said(setup.profile.suggested.displayName)}` : " (not confirmed)"}`);
|
|
215
|
-
if (setup.profile?.description || setup.profile?.suggested?.description)
|
|
216
|
-
lines.push(`Description: ${said(setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description)}`);
|
|
217
|
-
if (setup.audience?.available === false)
|
|
218
|
-
lines.push(`Audience: ${said(setup.audience.plainEnglish) || "console only"}`);
|
|
219
|
-
else
|
|
220
|
-
lines.push(`Audience: ${setup.audience?.confirmed ? (setup.audience.emails?.length ? setup.audience.emails.join(", ") : "only you") + " (confirmed)" : "not confirmed"}`);
|
|
221
|
-
}
|
|
222
|
-
if (secrets) {
|
|
223
|
-
if (secrets.available === false)
|
|
224
|
-
lines.push(`Secrets: ${said(secrets.plainEnglish) || "console only"}`);
|
|
225
|
-
else if (!secrets.asks?.length)
|
|
226
|
-
lines.push("Secrets: none asked for");
|
|
227
|
-
else
|
|
228
|
-
for (const ask of secrets.asks)
|
|
229
|
-
lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
|
|
230
|
-
}
|
|
231
|
-
// productionise carries only the open steps; the setup commands carry the full view.
|
|
232
|
-
const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : said(step)) : [];
|
|
233
|
-
if (openSteps.length)
|
|
234
|
-
lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`isomorph setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
|
|
235
|
-
else if (setup?.profile)
|
|
236
|
-
lines.push("Setup complete.");
|
|
237
206
|
return `${lines.join("\n")}\n`;
|
|
238
207
|
}
|
|
239
208
|
/**
|
|
@@ -257,17 +226,31 @@ function failureText(failure) {
|
|
|
257
226
|
const hint = said(failure.remediationHint);
|
|
258
227
|
return `${said(failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`;
|
|
259
228
|
}
|
|
229
|
+
/** The layer a code implies when its producer named none: the gate's and the pipeline's codes are fixed sets. */
|
|
230
|
+
function layerFor(code) {
|
|
231
|
+
if (/^CHECKS_/.test(code) || code === "KIT_GATE_FAILED")
|
|
232
|
+
return "gate";
|
|
233
|
+
if (code === "DEPLOYMENT_FAILED" || code === "DEPLOYMENT_RETRYABLE_FAILURE" || code === "PRODUCTION_PROMOTION_FAILED")
|
|
234
|
+
return "pipeline";
|
|
235
|
+
return "cli";
|
|
236
|
+
}
|
|
260
237
|
export class CliError extends Error {
|
|
261
238
|
code;
|
|
262
239
|
operationRef;
|
|
263
240
|
remediationHint;
|
|
264
241
|
result;
|
|
242
|
+
/** Which machine refused; `layerFor(code)` when the producer named none. */
|
|
243
|
+
layer;
|
|
244
|
+
/** Paths and identifiers the message refers to, carried whole: the envelope never bounds them. */
|
|
245
|
+
paths;
|
|
265
246
|
/** `result` becomes the envelope's `result`: what the command knew about the operation when it failed. */
|
|
266
|
-
constructor(code, message, operationRef, remediationHint, result) {
|
|
247
|
+
constructor(code, message, operationRef, remediationHint, result, options = {}) {
|
|
267
248
|
super(message);
|
|
268
249
|
this.code = code;
|
|
269
250
|
this.operationRef = operationRef;
|
|
270
251
|
this.remediationHint = remediationHint;
|
|
271
252
|
this.result = result;
|
|
253
|
+
this.layer = options.layer ?? layerFor(code);
|
|
254
|
+
this.paths = options.paths?.length ? [...options.paths] : undefined;
|
|
272
255
|
}
|
|
273
256
|
}
|
|
@@ -26,7 +26,7 @@ export function serverRefusal(result) {
|
|
|
26
26
|
const declared = typeof detail?.code === "string" ? detail.code.trim() : "";
|
|
27
27
|
const hint = typeof detail?.remediationHint === "string" && detail.remediationHint.trim() ? detail.remediationHint.trim() : undefined;
|
|
28
28
|
const labelled = declared ? null : LABELLED.exec(text);
|
|
29
|
-
return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint);
|
|
29
|
+
return new CliError(declared || labelled?.[1] || UNLABELLED_REFUSAL, (labelled?.[2] ?? text) || "Isomorph rejected the request.", undefined, hint, undefined, { layer: "governance" });
|
|
30
30
|
}
|
|
31
31
|
/** A JSON-RPC error is Isomorph refusing the call itself (an unknown method, a
|
|
32
32
|
* body it could not parse). Its numeric code is the only one it has, so it is
|
|
@@ -35,21 +35,21 @@ function rpcRefusal(error, status) {
|
|
|
35
35
|
const text = error.message?.trim() ?? "";
|
|
36
36
|
const labelled = LABELLED.exec(text);
|
|
37
37
|
const code = labelled?.[1] ?? (typeof error.code === "number" ? `ISOMORPH_RPC_${error.code}` : `ISOMORPH_HTTP_${status}`);
|
|
38
|
-
return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status})
|
|
38
|
+
return new CliError(code, (labelled?.[2] ?? text) || `Isomorph refused the request (HTTP ${status}).`, undefined, undefined, undefined, { layer: "governance" });
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
41
|
* A second 401 in a row: the sign-in itself is gone, or this company does not
|
|
42
42
|
* admit the signed-in account. The server says which in `error_description`
|
|
43
43
|
* (plain English, no URL or token); an older server sends only the code, and
|
|
44
44
|
* the fixed sentence stands in. Both fixes are named because the builder cannot
|
|
45
|
-
* tell the two causes apart: on 2026-09-14 a `
|
|
45
|
+
* tell the two causes apart: on 2026-09-14 a `deploy` 14 seconds after a
|
|
46
46
|
* successful `isomorph login` was refused by a company the account was not a
|
|
47
47
|
* member of, and "run login again" would only have repeated it.
|
|
48
48
|
*/
|
|
49
49
|
async function signInRefused(response) {
|
|
50
50
|
const body = await response.json().catch(() => undefined);
|
|
51
51
|
const said = typeof body?.error_description === "string" ? body.error_description.trim() : "";
|
|
52
|
-
return new CliError("AUTH_REQUIRED", `${said || "Isomorph sign-in expired or was revoked."} Run \`isomorph login\` again, or \`isomorph connect <work-email-or-start-url>\` if this is the wrong company
|
|
52
|
+
return new CliError("AUTH_REQUIRED", `${said || "Isomorph sign-in expired or was revoked."} Run \`isomorph login\` again, or \`isomorph connect <work-email-or-start-url>\` if this is the wrong company.`, undefined, undefined, undefined, { layer: "governance" });
|
|
53
53
|
}
|
|
54
54
|
async function readRpc(response) {
|
|
55
55
|
try {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { bundleDiff } from "./kit-bundle.js";
|
|
4
4
|
import { agentSetup, MANAGED_END, MANAGED_START, upsertManagedBlock } from "./agent-setup.js";
|
|
5
|
-
import {
|
|
5
|
+
import { defaultAppProfile, emptyDeclaration, newKitLock, readKitLock, renderAppProfile, writeKitLock } from "./kit.js";
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
|
-
import { isPristine } from "./retained-checks.js";
|
|
8
7
|
export { MANAGED_END, MANAGED_START };
|
|
9
8
|
/**
|
|
10
9
|
* Creates the starter in an empty directory, or adds the missing kit files to
|
|
@@ -19,17 +18,7 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
19
18
|
const emptyDir = !existingPackage && !(await exists(join(root, "src")));
|
|
20
19
|
if (existingPackage && !isSupportedApp(existingPackage))
|
|
21
20
|
throw new CliError("APP_UNSUPPORTED", "isomorph init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).", undefined, "Run `isomorph init --app-root <new-empty-folder>` to start a Vite + React app, then move this app's code into it.");
|
|
22
|
-
// An app from a CLI older than 0.2.0 is moved to this layout by `--upgrade`
|
|
23
|
-
// and by nothing else: a plain `init` over it would write a second, empty kit
|
|
24
|
-
// beside the one it has. Refused before anything, the user-level skill included, is written.
|
|
25
|
-
if (!options.upgrade)
|
|
26
|
-
await assertKitCurrent(root);
|
|
27
21
|
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
|
|
28
|
-
if (options.upgrade) {
|
|
29
|
-
await migrateLegacyKit(root, bundle, result);
|
|
30
|
-
await retireLegacySkill(root, result);
|
|
31
|
-
await rewriteLegacyReferences(root, bundle.sdk.package, result);
|
|
32
|
-
}
|
|
33
22
|
const write = async (path, content) => {
|
|
34
23
|
const absolute = join(root, path);
|
|
35
24
|
if (await exists(absolute)) {
|
|
@@ -43,7 +32,7 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
43
32
|
// The starter (its source and schema) is created only when there is no app here
|
|
44
33
|
// yet; kit infrastructure is written on every path, including `--upgrade`.
|
|
45
34
|
const appFiles = emptyDir ? starterFiles(bundle) : {};
|
|
46
|
-
for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
|
|
35
|
+
for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles(root) }))
|
|
47
36
|
await write(path, content);
|
|
48
37
|
await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
|
|
49
38
|
for (const file of ["CLAUDE.md", "AGENTS.md"])
|
|
@@ -54,7 +43,6 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
54
43
|
result.created.push(".isomorph/kit.lock.json");
|
|
55
44
|
}
|
|
56
45
|
else if (options.upgrade) {
|
|
57
|
-
// Already re-pinned when the migration moved the lock: then the diff is the migration's and the file is kept here.
|
|
58
46
|
const changes = bundleDiff(previous.bundle, bundle);
|
|
59
47
|
result.bundleChanges.push(...changes);
|
|
60
48
|
if (changes.length) {
|
|
@@ -66,159 +54,8 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
66
54
|
}
|
|
67
55
|
else
|
|
68
56
|
result.kept.push(".isomorph/kit.lock.json");
|
|
69
|
-
// A file the migration rewrote is reported once, as updated, not again as kept by the pass after it.
|
|
70
|
-
result.kept = result.kept.filter(path => !result.updated.includes(path));
|
|
71
57
|
return result;
|
|
72
58
|
}
|
|
73
|
-
/** The kit layout of CLI releases before 0.2.0, as `init --upgrade` finds it: everything here is what that layout wrote, nothing an app author chose. */
|
|
74
|
-
const LEGACY = {
|
|
75
|
-
start: "<!-- harbour:kit:start -->",
|
|
76
|
-
end: "<!-- harbour:kit:end -->",
|
|
77
|
-
generatedPrefix: "// harbour:generated sha256:",
|
|
78
|
-
gitignoreLine: ".harbour/local/",
|
|
79
|
-
/**
|
|
80
|
-
* The project skills older CLIs wrote — `harbour-kit` up to 0.1.x, `isomorph-kit` up
|
|
81
|
-
* to 0.2.2 — each under one frontmatter name and one opening line, with a body that
|
|
82
|
-
* changed from release to release. Since 0.3.0 the rules live beside the user-level
|
|
83
|
-
* skill and no project skill is written.
|
|
84
|
-
*/
|
|
85
|
-
skills: [
|
|
86
|
-
{ path: ".claude/skills/harbour-kit/SKILL.md", frontmatter: "name: harbour-kit", opening: 'Follow the "Harbour development kit" block' },
|
|
87
|
-
{ path: ".claude/skills/isomorph-kit/SKILL.md", frontmatter: "name: isomorph-kit", opening: 'Follow the "Isomorph development kit" block' }
|
|
88
|
-
],
|
|
89
|
-
sdkPackage: "@harbour/app-sdk",
|
|
90
|
-
/** The SDK's exported type names, renamed with the package. */
|
|
91
|
-
typeNames: /\bHarbour(User|ErrorCategory|Error|Client|Result|CompatSource)\b/g,
|
|
92
|
-
/**
|
|
93
|
-
* The environment names the kit and the platform export to a check, a job and
|
|
94
|
-
* the Vite config, renamed with the CLI. Whole words: the session's
|
|
95
|
-
* `HARBOUR_LOCAL_USER_EMAIL` and the CLI↔gateway `HARBOUR_APP_GATEWAY_*` and
|
|
96
|
-
* `HARBOUR_GATE_*` names are not builder-visible and are not renamed.
|
|
97
|
-
*/
|
|
98
|
-
envNames: /\bHARBOUR_(APP_URL|SDK_MODULE|IDENTITY_CONTEXT_SECOND_USER|IDENTITY_CONTEXT_HEADER|IDENTITY_CONTEXT|GATEWAY_URL|WORKLOAD_TOKEN|SCHEDULED_AT|LOCAL_ORIGIN|VITE_PORT)\b/g
|
|
99
|
-
};
|
|
100
|
-
/**
|
|
101
|
-
* Moves an app set up by a CLI older than 0.2.0 to this kit, once, and only under
|
|
102
|
-
* `init --upgrade`: the kit directory and its five entries are renamed (git records
|
|
103
|
-
* renames), the two committed schema ids move with it, the `.gitignore` line and
|
|
104
|
-
* the managed block in CLAUDE.md / AGENTS.md are rewritten, and the checks the old
|
|
105
|
-
* kit generated are deleted for the next `check` to regenerate. A check the author
|
|
106
|
-
* wrote or edited is kept, as everywhere else. Nothing happens when there is no old
|
|
107
|
-
* layout, or when the current one already exists beside it. The old project skill
|
|
108
|
-
* and the old names in the app's own files are handled after this, on every
|
|
109
|
-
* `--upgrade`, so an app this pass already moved still loses them.
|
|
110
|
-
*/
|
|
111
|
-
async function migrateLegacyKit(root, bundle, result) {
|
|
112
|
-
const legacy = join(root, LEGACY_KIT_DIRECTORY);
|
|
113
|
-
const paths = kitPaths(root);
|
|
114
|
-
if (!(await exists(legacy)) || await exists(paths.kit))
|
|
115
|
-
return;
|
|
116
|
-
const touched = (path) => result.updated.push(path);
|
|
117
|
-
await mkdir(paths.kit, { recursive: true });
|
|
118
|
-
for (const entry of ["kit.lock.json", "integrations.json", "checks", "ai-inventory.json", "local"]) {
|
|
119
|
-
if (!(await exists(join(legacy, entry))))
|
|
120
|
-
continue;
|
|
121
|
-
await rename(join(legacy, entry), join(paths.kit, entry));
|
|
122
|
-
touched(`${KIT_DIRECTORY}/${entry}`);
|
|
123
|
-
}
|
|
124
|
-
await rmdir(legacy).catch(() => undefined); // anything else in it was never the kit's
|
|
125
|
-
const declaration = await readJson(paths.declaration);
|
|
126
|
-
if (declaration)
|
|
127
|
-
await writeFile(paths.declaration, `${JSON.stringify({ ...declaration, schema: emptyDeclaration().schema }, null, 2)}\n`);
|
|
128
|
-
const lock = await readJson(paths.lock);
|
|
129
|
-
if (lock) {
|
|
130
|
-
result.bundleChanges = bundleDiff(lock.bundle, bundle);
|
|
131
|
-
await writeKitLock(root, { ...newKitLock(bundle, lock.tenantId, lock.appId), createdAt: lock.createdAt });
|
|
132
|
-
}
|
|
133
|
-
const ignore = join(root, ".gitignore");
|
|
134
|
-
const ignored = (await readFile(ignore, "utf8").catch(() => undefined))?.split("\n");
|
|
135
|
-
if (ignored?.includes(LEGACY.gitignoreLine)) {
|
|
136
|
-
await writeFile(ignore, ignored.map(line => line === LEGACY.gitignoreLine ? `${KIT_DIRECTORY}/local/` : line).join("\n"));
|
|
137
|
-
touched(".gitignore");
|
|
138
|
-
}
|
|
139
|
-
for (const file of ["CLAUDE.md", "AGENTS.md"]) {
|
|
140
|
-
const current = await readFile(join(root, file), "utf8").catch(() => "");
|
|
141
|
-
if (current.includes(LEGACY.start) && current.includes(LEGACY.end)) {
|
|
142
|
-
await upsertManagedBlock(join(root, file), managedBlock(), { start: LEGACY.start, end: LEGACY.end });
|
|
143
|
-
touched(file);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
for (const name of await readdir(paths.checks).catch(() => [])) {
|
|
147
|
-
if (!isPristine(await readFile(join(paths.checks, name), "utf8").catch(() => ""), LEGACY.generatedPrefix))
|
|
148
|
-
continue;
|
|
149
|
-
await rm(join(paths.checks, name));
|
|
150
|
-
touched(`${KIT_DIRECTORY}/checks/${name}`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Deletes the project skills older CLIs wrote, on every `--upgrade` — an app the
|
|
155
|
-
* layout migration moved under 0.2.0 still has the old one, and every app set up
|
|
156
|
-
* before 0.3.0 has the `isomorph-kit` one. A file is the kit's by shape, not by
|
|
157
|
-
* bytes: each release wrote a different body under the same frontmatter name and
|
|
158
|
-
* the same opening line, so one whose body an author rewrote is kept, and
|
|
159
|
-
* reported, as theirs.
|
|
160
|
-
*/
|
|
161
|
-
async function retireLegacySkill(root, result) {
|
|
162
|
-
for (const skill of LEGACY.skills) {
|
|
163
|
-
const path = join(root, skill.path);
|
|
164
|
-
const text = await readFile(path, "utf8").catch(() => undefined);
|
|
165
|
-
if (text === undefined)
|
|
166
|
-
continue;
|
|
167
|
-
if (!isLegacyKitSkill(text, skill)) {
|
|
168
|
-
result.kept.push(skill.path);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
await rm(path);
|
|
172
|
-
await rmdir(dirname(path)).catch(() => undefined);
|
|
173
|
-
result.updated.push(skill.path);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
/** Frontmatter naming the kit's skill, and the opening every release wrote as the body's first line. */
|
|
177
|
-
function isLegacyKitSkill(text, skill) {
|
|
178
|
-
const lines = text.split(/\r?\n/);
|
|
179
|
-
const close = lines.indexOf("---", 1);
|
|
180
|
-
if (lines[0] !== "---" || close < 0)
|
|
181
|
-
return false;
|
|
182
|
-
return lines.slice(1, close).includes(skill.frontmatter) && (lines.slice(close + 1).find(line => line.trim()) ?? "").startsWith(skill.opening);
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* Rewrites what the old kit named in the app's own files — `package.json`,
|
|
186
|
-
* `vite.config.*`, `src/`, `jobs/` and the retained checks — on every `--upgrade`:
|
|
187
|
-
* the SDK's previous package name (and, in `package.json`, the tarball path the
|
|
188
|
-
* old kit staged it under), its six exported type names, and the environment
|
|
189
|
-
* names the kit and the platform export to a check, a job and the Vite config.
|
|
190
|
-
* Whole words only, so an author's own `HarbourUserProfile` is theirs. Idempotent:
|
|
191
|
-
* an app that names none of them is left untouched and unreported.
|
|
192
|
-
*/
|
|
193
|
-
async function rewriteLegacyReferences(root, sdkPackage, result) {
|
|
194
|
-
const rewrite = (text) => text.replaceAll(`"${LEGACY.sdkPackage}"`, `"${sdkPackage}"`).replaceAll(`'${LEGACY.sdkPackage}'`, `'${sdkPackage}'`).replace(LEGACY.typeNames, "Isomorph$1").replace(LEGACY.envNames, "ISOMORPH_$1");
|
|
195
|
-
for (const path of ["package.json", "vite.config.ts", "vite.config.js", "vite.config.mjs", ...(await appSourceFiles(root))]) {
|
|
196
|
-
const current = await readFile(join(root, path), "utf8").catch(() => undefined);
|
|
197
|
-
if (current === undefined)
|
|
198
|
-
continue;
|
|
199
|
-
const next = path === "package.json" ? rewrite(current).replaceAll(`file:${LEGACY_KIT_DIRECTORY}/local/sdk/`, `file:${KIT_DIRECTORY}/local/sdk/`) : rewrite(current);
|
|
200
|
-
if (next !== current) {
|
|
201
|
-
await writeFile(join(root, path), next);
|
|
202
|
-
result.updated.push(path);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
/** `src/**`, `jobs/**` and the retained checks, relative to the app root. */
|
|
207
|
-
async function appSourceFiles(root) {
|
|
208
|
-
const files = [];
|
|
209
|
-
const walk = async (directory) => {
|
|
210
|
-
for (const entry of await readdir(join(root, directory), { withFileTypes: true }).catch(() => [])) {
|
|
211
|
-
const path = `${directory}/${entry.name}`;
|
|
212
|
-
if (entry.isDirectory())
|
|
213
|
-
await walk(path);
|
|
214
|
-
else if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name))
|
|
215
|
-
files.push(path);
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
for (const directory of ["src", "jobs", `${KIT_DIRECTORY}/checks`])
|
|
219
|
-
await walk(directory);
|
|
220
|
-
return files.sort();
|
|
221
|
-
}
|
|
222
59
|
function isSupportedApp(pkg) {
|
|
223
60
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
224
61
|
return Boolean(deps.vite && deps.react);
|
|
@@ -256,13 +93,9 @@ export function managedBlock() {
|
|
|
256
93
|
MANAGED_START,
|
|
257
94
|
"## Isomorph development kit",
|
|
258
95
|
"",
|
|
259
|
-
"This app runs on Isomorph
|
|
260
|
-
"",
|
|
261
|
-
"1. Read `core.md` beside the skill's `SKILL.md` before the first edit.",
|
|
262
|
-
"2. Before you add a key under `\"connections\"` in `.isomorph/integrations.json`, read `integrations.md` there.",
|
|
263
|
-
"3. Before you call `isomorph.ai.*`, read `ai.md`; before you create `jobs/`, read `jobs.md`.",
|
|
96
|
+
"This app runs on Isomorph: use the `isomorph` skill (Claude Code `~/.claude/skills/isomorph/`, Codex `~/.codex/skills/isomorph/`; if it is missing, run `npx -y @isomorph.ai/cli agent-setup`). Read `core.md` there before the first edit.",
|
|
264
97
|
"",
|
|
265
|
-
"Commands: `isomorph dev --app-root
|
|
98
|
+
"Commands: `isomorph dev --app-root . --detach --json`, `isomorph check --app-root . --json`, `isomorph deploy --app-root . --json`.",
|
|
266
99
|
MANAGED_END
|
|
267
100
|
].join("\n");
|
|
268
101
|
}
|
|
@@ -272,10 +105,13 @@ export function managedBlock() {
|
|
|
272
105
|
* `--upgrade`. Nothing here is example content, because `write` only fills a
|
|
273
106
|
* gap: an app that deleted a file it does not want must not have it recreated.
|
|
274
107
|
*/
|
|
275
|
-
function kitFiles() {
|
|
108
|
+
function kitFiles(root) {
|
|
276
109
|
return {
|
|
110
|
+
// Name, description and audience: what `isomorph deploy` prints before it starts and records
|
|
111
|
+
// with the deployment. The folder name and an empty audience are only a starting point.
|
|
112
|
+
".isomorph/app.json": renderAppProfile(defaultAppProfile(root)),
|
|
277
113
|
// No connection. The starter calls no company system, and a connection the
|
|
278
|
-
// app does not call is a deploy that never happens: `isomorph
|
|
114
|
+
// app does not call is a deploy that never happens: `isomorph deploy`
|
|
279
115
|
// files the access request for every declared connection and refuses to
|
|
280
116
|
// start until IT has approved it. One is added when the app really calls it —
|
|
281
117
|
// the steps and the worked Slack and warehouse examples are in integrations.md
|
|
@@ -330,30 +166,26 @@ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in,
|
|
|
330
166
|
|
|
331
167
|
## Three commands
|
|
332
168
|
|
|
333
|
-
- \`isomorph dev --app-root
|
|
334
|
-
- \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks);
|
|
335
|
-
- \`isomorph
|
|
169
|
+
- \`isomorph dev --app-root . --detach --json\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
|
|
170
|
+
- \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); the output is the report.
|
|
171
|
+
- \`isomorph deploy --app-root . --json\` — deploys a private preview for the people named in \`.isomorph/app.json\`; \`isomorph promote\` makes it live for everyone once they have tried it.
|
|
336
172
|
|
|
337
173
|
## Where the rules are
|
|
338
174
|
|
|
339
|
-
The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` installs for Claude Code (\`~/.claude/skills/isomorph/\`) and Codex (\`~/.codex/skills/isomorph/\`): \`core.md\` (identity, data, files, realtime,
|
|
175
|
+
The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` installs for Claude Code (\`~/.claude/skills/isomorph/\`) and Codex (\`~/.codex/skills/isomorph/\`): \`core.md\` (identity, data, files, realtime, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md points there.
|
|
340
176
|
|
|
341
|
-
\`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start.
|
|
177
|
+
\`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start. \`.isomorph/app.json\` holds the app's name, description and audience.
|
|
342
178
|
`;
|
|
343
179
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
344
180
|
import react from "@vitejs/plugin-react";
|
|
345
181
|
|
|
346
|
-
// \`isomorph dev\` fronts Vite with one loopback origin and sets
|
|
347
|
-
// the proxy below keeps SDK calls working if Vite is opened directly.
|
|
348
|
-
const isomorphOrigin = process.env.ISOMORPH_LOCAL_ORIGIN ?? "http://127.0.0.1:4180";
|
|
349
|
-
|
|
182
|
+
// \`isomorph dev\` fronts Vite with one loopback origin (the printed link) and sets ISOMORPH_VITE_PORT.
|
|
350
183
|
export default defineConfig({
|
|
351
184
|
plugins: [react()],
|
|
352
185
|
server: {
|
|
353
186
|
host: "127.0.0.1",
|
|
354
187
|
port: Number(process.env.ISOMORPH_VITE_PORT ?? 5173),
|
|
355
|
-
strictPort: true
|
|
356
|
-
proxy: { "/_harbour": { target: isomorphOrigin, changeOrigin: false } }
|
|
188
|
+
strictPort: true
|
|
357
189
|
}
|
|
358
190
|
});
|
|
359
191
|
`;
|
|
@@ -390,7 +222,6 @@ export function App() {
|
|
|
390
222
|
const { data } = await isomorph.data.from<Note>("notes").select("*").order("created_at", { ascending: false });
|
|
391
223
|
setNotes(data);
|
|
392
224
|
}, []);
|
|
393
|
-
// files capability — this call and \`upload\` below are what .isomorph/checks/files-journey.mjs exercises.
|
|
394
225
|
const loadFiles = useCallback(async () => { setFiles((await isomorph.files.list("private/")) as StoredFile[]); }, []);
|
|
395
226
|
|
|
396
227
|
useEffect(() => {
|
|
@@ -408,10 +239,9 @@ export function App() {
|
|
|
408
239
|
};
|
|
409
240
|
const toggle = async (note: Note) => { await isomorph.data.from("notes").update({ done: !note.done }).eq("id", note.id); await loadNotes(); };
|
|
410
241
|
const remove = async (note: Note) => { await isomorph.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
|
|
411
|
-
const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
|
|
242
|
+
const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
|
|
412
243
|
|
|
413
|
-
// Company systems and AI are not part of the starter
|
|
414
|
-
// nothing and no isomorph.ai call exists, so nothing here waits on IT. Rules for adding
|
|
244
|
+
// Company systems and AI are not part of the starter. Rules for adding
|
|
415
245
|
// either: integrations.md and ai.md in the isomorph skill.
|
|
416
246
|
|
|
417
247
|
return (
|
|
@@ -420,12 +250,7 @@ export function App() {
|
|
|
420
250
|
<p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
|
|
421
251
|
{error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
|
|
422
252
|
|
|
423
|
-
{/* Notes —
|
|
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). */}
|
|
253
|
+
{/* Notes — schema in migrations/0001_notes.sql. Rules: core.md in the isomorph skill (data). */}
|
|
429
254
|
<section>
|
|
430
255
|
<h2>Notes</h2>
|
|
431
256
|
<form onSubmit={event => { event.preventDefault(); void addNote(); }}>
|
|
@@ -442,10 +267,7 @@ export function App() {
|
|
|
442
267
|
</ul>
|
|
443
268
|
</section>
|
|
444
269
|
|
|
445
|
-
{/* Private files
|
|
446
|
-
.isomorph/checks/files-journey.mjs exists: delete this section and \`isomorph check\` deletes
|
|
447
|
-
that check too (one you have edited is yours to delete, in the same edit). Rules: core.md
|
|
448
|
-
in the isomorph skill (retained checks). */}
|
|
270
|
+
{/* Private files: the app's own file store (isomorph.files.*). */}
|
|
449
271
|
<section>
|
|
450
272
|
<h2>Private files</h2>
|
|
451
273
|
<input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
|
|
@@ -469,6 +291,4 @@ DROP POLICY IF EXISTS notes_owner ON notes;
|
|
|
469
291
|
CREATE POLICY notes_owner ON notes
|
|
470
292
|
USING (owner_subject = current_setting('harbour.user_id', true))
|
|
471
293
|
WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
|
|
472
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
|
|
473
|
-
GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
|
|
474
294
|
`;
|