From 4b38ef3f5df3358f8c7c7f40f6889566e9ca4245 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Tue, 22 Sep 2026 23:57:44 -0700 Subject: [PATCH] Polish CLI recovery errors and help parsing for beta.3 --- README.md | 4 +++ RELEASE.md | 4 +-- package.json | 2 +- src/cli.ts | 31 ++++++++++---------- src/client.ts | 11 ++++++- test/cli.test.mjs | 53 ++++++++++++++++++++++++++++++++++ test/packaged-install.test.mjs | 4 +-- 7 files changed, 87 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c42c961..dcef5d4 100644 --- a/README.md +++ b/README.md @@ -167,3 +167,7 @@ retry the indicated recovery action, never blindly repeat a paid command. use `authentication_failed` (exit 2). A cancelled run uses `execution_cancelled`, exit 4, and a JSON error on stderr. Unknown client failures use `client_error`; they do not prove that the server-side generation failed. + +## Error handling updates in beta.3 + +Transport failures include a safe connection category without exposing URLs or credentials. Cancelled executions recommend a status check; download authentication failures retain exit code 2. Help flags are parsed as options so a literal `-h` option value is preserved. diff --git a/RELEASE.md b/RELEASE.md index 5504bf2..90216fc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,6 @@ # Agent readiness release -Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable release is deliberately selected. +Prepared version: `0.4.0-beta.3`. Keep `latest` on `0.3.0` until a stable release is deliberately selected. 1. Pass CI, review and merge the change. 2. With npm publisher authentication, run `pnpm test` and `npm publish --tag beta`. @@ -9,4 +9,4 @@ Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable releas No paid generation is necessary to verify installation or help output. -The package defaults to `publishConfig.tag: beta`; still pass `--tag beta` explicitly. Re-review the recovery fixes before publication. README links use existing pages; the beta.2-only tool/automation pages are held outside the docs site until the packages are published. Restore those pages in a later docs change with the stated minimum version. +The package defaults to `publishConfig.tag: beta`; still pass `--tag beta` explicitly. Verify the exact published artifact and retain `latest` at `0.3.0`. The beta.2 documentation remains compatible with beta.3. diff --git a/package.json b/package.json index cc3d4a7..153f157 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dreamlayer", - "version": "0.4.0-beta.2", + "version": "0.4.0-beta.3", "description": "Generate and edit images from your terminal, over local files, with one API key.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 4daa342..0c74666 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -96,6 +96,7 @@ type Options = { aspect: string; json: boolean; quiet: boolean; + help: boolean; idempotencyKey: string | null; }; @@ -133,7 +134,7 @@ async function saveOutput(target: string, bytes: Uint8Array): Promise { } async function downloadOutput(api: ManagedClient, url: string): Promise { try { return await api.download(url); } - catch { throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again."); } + catch (error) { if (error instanceof ApiError && [401, 403].includes(error.status)) throw error; throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again."); } } function parseOptions(argv: string[]): { positional: string[]; options: Options } { @@ -146,11 +147,13 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options aspect: "1:1", json: false, quiet: false, + help: false, idempotencyKey: null, }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; - if (token === "--json") options.json = true; + if (token === "--help" || token === "-h") options.help = true; + else if (token === "--json") options.json = true; else if (token === "--quiet") options.quiet = true; else if (token === "--out" || token === "-o") { const value = argv[++i]; @@ -376,16 +379,13 @@ async function main(argv: string[]): Promise { process.stdout.write(USAGE); return command ? 0 : 1; } - if (rest.includes("--help") || rest.includes("-h")) { - process.stdout.write(USAGE); - return 0; - } if (command === "--version" || command === "-v") { process.stdout.write(`${PACKAGE_VERSION}\n`); return 0; } const { positional, options } = parseOptions(rest); + if (options.help) { process.stdout.write(USAGE); return 0; } if (["generate", "edit", "cutout", "upscale", "sprite", "answer"].includes(command)) { options.out ??= command === "sprite" ? `dreamlayer-${Date.now()}.zip` : defaultOut(); await preflightOutput(options.out); @@ -502,15 +502,18 @@ main(process.argv.slice(2)) const known = error instanceof CommandError ? error : error instanceof InputValidationError ? new CommandError("invalid_request", error.message, 1) : error instanceof RecoveryRequiredError - ? new CommandError("temporarily_unavailable", "Execution state is uncertain. Read saved state before retrying.", 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null; + ? new CommandError("temporarily_unavailable", error.message, 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null; if (known) { const partial = (error as { partialOutcome?: { execution_id?: string | null } }).partialOutcome; const identity = { ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) }; const envelope = { error: { code: "CLIENT_ERROR", reason: known.reason, message: known.message, retryable: known.retryable, request_id: null, guidance: known.guidance, ...identity } }; if (process.argv.slice(2).includes("--json")) process.stderr.write(`${JSON.stringify(envelope)}\n`); else { - process.stderr.write(`${known.message}\n${known.guidance ?? ""}\n`); - if (identity.execution_id) process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n dreamlayer download ${identity.execution_id} --out \n`); + process.stderr.write(`${known.message}\n${known.guidance ? known.guidance + "\n" : ""}`); + if (identity.execution_id) { + process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n`); + if (["local_output_failed", "download_failed"].includes(known.reason)) process.stderr.write(` dreamlayer download ${identity.execution_id} --out \n`); + } if (identity.idempotency_key) process.stderr.write(`Idempotency key: ${identity.idempotency_key}\n`); } process.exitCode = known.exitCode; @@ -541,13 +544,9 @@ main(process.argv.slice(2)) : error.retryable ? "Temporary. Retry with --idempotency-key to avoid paying twice." : ""; - if (process.argv.slice(2).includes("--json")) { - process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`); - } else { - process.stderr.write( - `${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`, - ); - } + process.stderr.write( + `${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`, + ); process.exitCode = exitCodeFor(error); return; } diff --git a/src/client.ts b/src/client.ts index 150992c..1752cbe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -706,7 +706,16 @@ export class ManagedClient { if (error instanceof StreamIdleError) throw error; if (error instanceof InputValidationError || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status))) throw error; if (!executionId && error instanceof ApiError) throw error; - if (!executionId || ++failures > 5) throw new RecoveryRequiredError("Execution state is uncertain. Read saved state before retrying."); + if (!executionId || ++failures > 5) { + const code = (error as { cause?: { code?: string }; code?: string } | null)?.cause?.code ?? (error as { code?: string } | null)?.code; + const causes: Record = { + ECONNREFUSED: "Connection refused", ENOTFOUND: "Host lookup failed", EAI_AGAIN: "Host lookup temporarily failed", + ECONNRESET: "Connection reset", ETIMEDOUT: "Connection timed out", UND_ERR_CONNECT_TIMEOUT: "Connection timed out", + UND_ERR_SOCKET: "Connection closed", CERT_HAS_EXPIRED: "TLS certificate expired", DEPTH_ZERO_SELF_SIGNED_CERT: "TLS certificate is untrusted", + }; + const cause = code && causes[code] ? `${causes[code]} (${code}). ` : "Transport connection failed. "; + throw new RecoveryRequiredError(cause + "Execution state is uncertain. Read saved state before retrying."); + } } if (!executionId) throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key."); const state = await this.getExecution(executionId); diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 1178a28..9c8123b 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -116,6 +116,11 @@ function fakeApi(behaviour) { return; } if (request.url === "/asset.png") { + if (behaviour.assetStatus) { + response.writeHead(behaviour.assetStatus, { "content-type": "application/json" }); + response.end(JSON.stringify({ detail: "private upstream details" })); + return; + } response.writeHead(200, { "content-type": "image/png" }); response.end(PNG); return; @@ -1161,3 +1166,51 @@ test('repeating a successful command with the same output does not submit a seco assert.match(help.stdout, /Existing destinations are refused before paid submission/); } finally { api.close(); } }); + +for (const json of [false, true]) { + test(`transport diagnostics retain a safe cause and request key (json=${json})`, async () => { + const probe = createServer(); + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${probe.address().port}`; + await new Promise(resolve => probe.close(resolve)); + const result = await runCli(['generate', 'PRIVATE PROMPT', '--idempotency-key', 'saved', '--quiet', ...(json ? ['--json'] : [])], {DREAMLAYER_API_URL: url}); + assert.equal(result.code, 5); + assert.match(result.stderr, /Connection refused.*ECONNREFUSED/); + assert.match(result.stderr, /saved/); + assert.doesNotMatch(result.stderr, /PRIVATE PROMPT|127\.0\.0\.1|dlr_live_test/); + }); + for (const status of [401, 403]) { + test(`asset HTTP ${status} remains an access error (json=${json})`, async () => { + const api = await listen(fakeApi({assetStatus: status, events: [started, {event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}}, {event:'done',data:{status:'completed'}}]})); + try { + const result = await runCli(['generate','a tree','--quiet', ...(json ? ['--json'] : [])], {DREAMLAYER_API_URL:api.url}); + assert.equal(result.code, 2, result.stderr); + assert.match(result.stderr, status === 401 ? /authentication_failed/ : /access_denied/); + assert.doesNotMatch(result.stderr, /download_failed|private upstream details/); + assert.equal(api.calls.filter(c=>c.method==='POST').length, 1); + if(json) assert.equal(JSON.parse(result.stderr).error.execution_id, started.data.execution_id); + } finally {api.close();} + }); + } +} +test('cancelled text output recommends status without download or blank guidance', async () => { + const api = await listen(fakeApi({events:[started,{event:'done',data:{status:'cancelled'}}]})); + try { + const result=await runCli(['generate','a tree','--quiet'],{DREAMLAYER_API_URL:api.url}); + assert.equal(result.code,4); assert.match(result.stderr,/dreamlayer status/); + assert.doesNotMatch(result.stderr,/dreamlayer download|\n\n/); + } finally {api.close();} +}); +test('help-like option values do not short-circuit a command', async () => { + const api=await listen(fakeApi({events:[started,{event:'done',data:{status:'cancelled'}}]})); + try { + const result=await runCli(['generate','a tree','--idempotency-key','-h','--quiet'],{DREAMLAYER_API_URL:api.url}); + assert.equal(result.code,4); assert.equal(api.calls.find(c=>c.method==='POST').headers['idempotency-key'],'-h'); + assert.doesNotMatch(result.stdout,/USAGE/); + } finally {api.close();} +}); + +test('help after positional arguments requires no key or network', async () => { + const result = await runCli(['generate', 'a tree', '--help'], {DREAMLAYER_API_KEY:'', DREAMLAYER_API_URL:'http://127.0.0.1:1'}); + assert.equal(result.code,0); assert.match(result.stdout,/USAGE/); +}); diff --git a/test/packaged-install.test.mjs b/test/packaged-install.test.mjs index 3158774..6432814 100644 --- a/test/packaged-install.test.mjs +++ b/test/packaged-install.test.mjs @@ -25,7 +25,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: assert.equal(packed.status, 0, packed.stderr); const metadata = JSON.parse(packed.stdout)[0]; assert.equal(metadata.name, "dreamlayer"); - assert.equal(metadata.version, "0.4.0-beta.2"); + assert.equal(metadata.version, "0.4.0-beta.3"); assert.ok(metadata.integrity.startsWith("sha512-")); assert.deepEqual( metadata.files.map(({ path: file }) => file).sort(), @@ -58,7 +58,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"), ); assert.equal(packageJson.publishConfig.tag, "beta"); - assert.equal(packageJson.version, "0.4.0-beta.2"); + assert.equal(packageJson.version, "0.4.0-beta.3"); assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js"); const env = { ...process.env, DREAMLAYER_API_KEY: "" };