diff --git a/README.md b/README.md index 53e6ace..c42c961 100644 --- a/README.md +++ b/README.md @@ -139,13 +139,31 @@ dreamlayer status EXECUTION_ID --json dreamlayer download EXECUTION_ID --out recovered.png --json ``` -`download` refuses to overwrite a file. Use `.zip` for sprite results. Generation errors +All output commands refuse to overwrite existing files, directories, or symlinks. +Paid commands check `--out` before uploads or `/v1/execute`: an existing destination +returns `output_exists` (exit 1) without submitting or charging a new job. A missing or +unwritable parent returns `output_unavailable` (exit 1). Re-running a batch with the +same output paths therefore stops on completed files before paid work. Choose a new +path only for intentionally new work. The final write is still exclusive: if another +process creates the destination during generation, use the saved execution ID to +recover the completed output with `download`. + +`download` also refuses to overwrite a file. Use `.zip` for sprite results. Generation errors in JSON include the available execution ID and idempotency key for recovery. Treat them as private identifiers. For file-based commands, rerunning uploads a new asset: the same local file is not an identical API request. Prefer `status` and `download`, or the -[journaled API examples](https://docs.dreamlayer.io/agent-api/examples). +[execution recovery guide](https://docs.dreamlayer.io/agent-api/jobs-and-events). [API overview](https://docs.dreamlayer.io/agent-api) · -[Limits](https://docs.dreamlayer.io/agent-api/limits) · -[Automation](https://docs.dreamlayer.io/cli/automation) · -[MCP tools](https://docs.dreamlayer.io/mcp/tools) +[CLI guide](https://docs.dreamlayer.io/cli) · +[MCP setup](https://docs.dreamlayer.io/mcp/index) + +Local client failures are separate from API generation failures. `local_output_failed` +(exit 1) means the completed output could not be written; fix the destination and run +`download` with the saved execution ID. `download_failed` or `output_not_ready` (exit 5) +also require recovery of existing work, not a new generation. `retryable: true` means +retry the indicated recovery action, never blindly repeat a paid command. +`local_input_failed` (exit 1) means no readable input was supplied; missing credentials +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. diff --git a/RELEASE.md b/RELEASE.md index dc6e20b..5504bf2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,3 +8,5 @@ Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable releas 4. Update pinned documentation examples only after the version exists on npm. 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. diff --git a/package.json b/package.json index 223be11..cc3d4a7 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,8 @@ "type": "git", "url": "git+https://github.com/TheDesignFounder/dreamlayer-cli.git" }, - "homepage": "https://docs.dreamlayer.io/cli" + "homepage": "https://docs.dreamlayer.io/cli", + "publishConfig": { + "tag": "beta" + } } diff --git a/src/cli.ts b/src/cli.ts index 3a13bad..4daa342 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,12 +16,14 @@ import { spriteCreditPrice } from "./client.js"; * 6 the run ended asking a question instead of producing an image */ import { randomUUID } from "node:crypto"; -import { openAsBlob, readFileSync } from "node:fs"; -import { stat, writeFile } from "node:fs/promises"; +import { constants, openAsBlob, readFileSync } from "node:fs"; +import { access, lstat, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { ApiError, + InputValidationError, + RecoveryRequiredError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, @@ -52,7 +54,8 @@ USAGE dreamlayer capabilities OPTIONS - --out Where to write the image. Default: dreamlayer-.png + --out New output file; never overwrites. Default: dreamlayer-.png + Existing destinations are refused before paid submission. --image Attach an image when answering a question that asks for one --aspect 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1 --action Sprite preset: walk, run, idle (walk if no custom prompt) @@ -72,7 +75,7 @@ EXIT CODES AUTOMATION Commands never prompt. Save a unique --idempotency-key before paid work. After uncertainty, use status then download; do not start a replacement job. - JSON output is documented at https://docs.dreamlayer.io/cli/automation + CLI guide: https://docs.dreamlayer.io/cli ENVIRONMENT DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io @@ -97,6 +100,41 @@ type Options = { }; class UsageError extends Error {} +class CommandError extends Error { + constructor(readonly reason: string, message: string, readonly exitCode: number, readonly retryable = false, readonly guidance?: string) { super(message); } +} +async function preflightOutput(target: string): Promise { + // lstat also detects dangling symlinks. The final exclusive write remains + // necessary because another process can create the destination during the job. + let exists = false; + try { + await lstat(target); + exists = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new CommandError("output_unavailable", "The output destination could not be checked. No generation was submitted.", 1); + } + } + if (exists) { + throw new CommandError("output_exists", "The output destination already exists. Refusing to overwrite; no generation was submitted.", 1, false, "Use the existing output or choose a new --out path for intentionally new work."); + } + try { + const parent = path.dirname(path.resolve(target)); + if (!(await stat(parent)).isDirectory()) throw new Error("not a directory"); + await access(parent, constants.W_OK | constants.X_OK); + } catch { + throw new CommandError("output_unavailable", "The output parent must be an existing writable directory. No generation was submitted.", 1); + } +} + +async function saveOutput(target: string, bytes: Uint8Array): Promise { + try { await writeFile(target, bytes, { flag: "wx" }); } + catch { throw new CommandError("local_output_failed", "The output could not be saved locally. Generation has already completed.", 1, true, "Fix the destination or choose a new path, then use dreamlayer download with the saved execution_id. Do not generate again."); } +} +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."); } +} function parseOptions(argv: string[]): { positional: string[]; options: Options } { const positional: string[] = []; @@ -166,11 +204,7 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options function client(): ManagedClient { const key = (process.env.DREAMLAYER_API_KEY ?? "").trim(); if (!key) { - throw new UsageError( - "DREAMLAYER_API_KEY is not set.\n" + - " export DREAMLAYER_API_KEY=dlr_live_...\n" + - " Get a key at https://platform.dreamlayer.io", - ); + throw new CommandError("authentication_failed", "DREAMLAYER_API_KEY is not set. Get a key at https://platform.dreamlayer.io", 2); } return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim()); } @@ -184,14 +218,17 @@ async function upload(api: ManagedClient, file: string): Promise { try { fileStat = await stat(resolved); } catch { - throw new UsageError(`cannot read ${file}`); + throw new CommandError("local_input_failed", "The local input file could not be read.", 1); } if (fileStat.size > MAX_SOURCE_BYTES) { throw new UsageError( `${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`, ); } - const asset = await api.uploadInput(await openAsBlob(resolved), path.basename(resolved)); + let blob: Blob; + try { blob = await openAsBlob(resolved); } + catch { throw new CommandError("local_input_failed", "The local input file could not be read.", 1); } + const asset = await api.uploadInput(blob, path.basename(resolved)); return asset.input_asset_id; } @@ -235,15 +272,15 @@ async function run( const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id)); if (terminal) throw terminal; } - if (options.json) process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`); - else process.stderr.write(`Run ended as ${outcome.status}. Check the execution before retrying.\n`); - return 5; + if (outcome.status === "cancelled") throw new CommandError("execution_cancelled", "The execution was cancelled.", 4); + if (outcome.status === "failed") throw new CommandError("generation_failed", "The execution failed. Read canonical state for details.", 4); + throw new RecoveryRequiredError("The execution has no completed output yet. Read its saved state."); } progress.set("Downloading"); - const bytes = await api.download(outcome.asset.download_url); + const bytes = await downloadOutput(api, outcome.asset.download_url); const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut()); - await writeFile(target, bytes); + await saveOutput(target, bytes); progress.stop(); if (options.json) { @@ -349,6 +386,10 @@ async function main(argv: string[]): Promise { } const { positional, options } = parseOptions(rest); + if (["generate", "edit", "cutout", "upscale", "sprite", "answer"].includes(command)) { + options.out ??= command === "sprite" ? `dreamlayer-${Date.now()}.zip` : defaultOut(); + await preflightOutput(options.out); + } switch (command) { case "sprite": { @@ -413,9 +454,9 @@ async function main(argv: string[]): Promise { recovery = { execution_id: executionId }; const execution = await api.getExecution(executionId); const assets = execution.image_job?.finished_assets; - if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new UsageError("The execution has no finished asset. Check status before downloading."); - const bytes = await api.download(assets[0].download_url); - await writeFile(options.out, bytes, { flag: "wx" }); + if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new CommandError("output_not_ready", "The execution has no single finished asset. Check status before downloading.", 5, true, "Read the saved execution; do not generate again."); + const bytes = await downloadOutput(api, assets[0].download_url); + await saveOutput(options.out, bytes); process.stdout.write(options.json ? `${JSON.stringify({ execution_id: executionId, file: path.resolve(options.out), bytes: bytes.length })}\n` : `${options.out}\n`); return 0; } @@ -458,12 +499,29 @@ main(process.argv.slice(2)) process.exitCode = code; }) .catch((error: unknown) => { + 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; + 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`); + if (identity.idempotency_key) process.stderr.write(`Idempotency key: ${identity.idempotency_key}\n`); + } + process.exitCode = known.exitCode; + return; + } if (process.argv.slice(2).includes("--json")) { const partial = (error as { partialOutcome?: { execution_id?: string | null } } | null)?.partialOutcome; const temporary = error instanceof StreamIdleError || error instanceof UploadTimeoutError; const envelope = error instanceof ApiError ? error.toPublicEnvelope() : { error: { code: error instanceof UsageError ? "VALIDATION_FAILED" : temporary ? "SERVICE_UNAVAILABLE" : "INTERNAL_ERROR", - reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "generation_failed", + reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "client_error", message: error instanceof UsageError ? "Check command arguments and local input or output files; use --help." : temporary ? "The request timed out. Check the saved execution before retrying." : "The command could not complete. Check saved execution state and local output access.", retryable: temporary, request_id: null }, }; diff --git a/src/client.ts b/src/client.ts index 6c30966..150992c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -75,8 +75,11 @@ export type ManagedExecuteInput = { max_credits?: number; }; +export class InputValidationError extends Error {} +export class RecoveryRequiredError extends Error {} + export function spriteCreditPrice(frameCount: number): number { - if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100) throw new Error("frame_count must be an integer from 7 to 100"); + if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100) throw new InputValidationError("frame_count must be an integer from 7 to 100"); const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0); return Math.ceil(cents * 10 / 17) / 10; } @@ -84,13 +87,13 @@ export function spriteCreditPrice(frameCount: number): number { export function validateSpriteInput(input: ManagedExecuteInput): void { if (input.operation !== "sprite_sheet") return; const options = input.options; - if (!options || (options.action === undefined) === (options.animation_prompt === undefined)) throw new Error("Sprite requests require exactly one of options.action or options.animation_prompt"); - if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action)) throw new Error("Invalid sprite preset"); - if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000)) throw new Error("animation_prompt must contain 1–4000 characters"); - if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode)) throw new Error("animation_mode must be loop or once"); + if (!options || (options.action === undefined) === (options.animation_prompt === undefined)) throw new InputValidationError("Sprite requests require exactly one of options.action or options.animation_prompt"); + if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action)) throw new InputValidationError("Invalid sprite preset"); + if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000)) throw new InputValidationError("animation_prompt must contain 1–4000 characters"); + if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode)) throw new InputValidationError("animation_mode must be loop or once"); const price = spriteCreditPrice(options.frame_count ?? 12); - if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size)) throw new Error("frame_size must be 32, 64, 128, 256, 512, 720 or 1080"); - if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100) throw new Error(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`); + if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size)) throw new InputValidationError("frame_size must be 32, 64, 128, 256, 512, 720 or 1080"); + if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100) throw new InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`); } export type ManagedInputAsset = { @@ -701,14 +704,16 @@ export class ManagedClient { failures = 0; } catch (error) { if (error instanceof StreamIdleError) throw error; - if (!executionId || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)) || ++failures > 5) 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) throw new Error("Execution stream ended before an identifier was received; reuse your idempotency key."); + if (!executionId) throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key."); const state = await this.getExecution(executionId); if (["completed", "failed", "cancelled"].includes(state.status)) { if (state.status === "completed") { const assets = state.image_job?.finished_assets; - if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new Error(`Execution ${executionId} has no downloadable asset yet.`); + if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new RecoveryRequiredError(`Execution ${executionId} has no downloadable asset yet.`); yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url }); } yield managedEvent("done", null, {status: state.status}); @@ -717,7 +722,7 @@ export class ManagedClient { await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures))); stream = this.events(executionId, cursor); } - throw new Error(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`); + throw new RecoveryRequiredError(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`); } /** Resume a stream after a drop. Pass the last event id you actually processed. */ diff --git a/test/cli.test.mjs b/test/cli.test.mjs index c919a98..1178a28 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -9,7 +9,8 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, symlink } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -125,6 +126,7 @@ function fakeApi(behaviour) { request.on("end", () => { const body = raw ? JSON.parse(raw) : {}; calls[calls.length - 1].body = body; + behaviour.onExecute?.(); // The real request model is CLOSED: an unknown key is a 422, not a field the // server quietly ignores. Mirror that, or a client that invents a field passes @@ -597,7 +599,7 @@ test("a bad file is rejected locally, before anything is uploaded or charged", a api.close(); assert.equal(result.code, 1); - assert.match(result.stderr, /cannot read/); + assert.match(result.stderr, /local input file could not be read/); assert.equal(touched, 0, "an unreadable file must never reach the API"); }); @@ -1017,6 +1019,145 @@ test("download recovers existing assets using GET only and refuses overwrite", a assert.ok(handler.calls.every(call => call.method === 'GET')); const duplicate = await runCli(['download', 'owned', '--out', destination, '--json'], { DREAMLAYER_API_URL: api.url }); assert.equal(duplicate.code, 1); + const failure = JSON.parse(duplicate.stderr).error; + assert.equal(failure.reason, 'local_output_failed'); + assert.equal(failure.execution_id, 'owned'); + assert.equal(failure.retryable, true); + assert.match(failure.guidance, /dreamlayer download/); assert.deepEqual(await readFile(destination), PNG); } finally { api.close(); } }); + +for (const json of [false, true]) { + test(`destination appearing during generation preserves download recovery (json=${json})`, async () => { + const directory = await mkdtemp(path.join(tmpdir(), "dl-output-failed-")); + const destination = path.join(directory, "result.png"); + const api = await listen(fakeApi({ onExecute: () => writeFileSync(destination, 'another process'), 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', '--out', destination, '--idempotency-key', 'saved-request', ...(json ? ['--json'] : [])], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + if (json) { + const error = JSON.parse(result.stderr).error; + assert.equal(error.reason, 'local_output_failed'); + assert.equal(error.retryable, true); + assert.equal(error.execution_id, started.data.execution_id); + assert.equal(error.idempotency_key, 'saved-request'); + assert.equal(await readFile(destination, 'utf8'), 'another process'); + } else { + assert.match(result.stderr, /dreamlayer download/); + assert.ok(result.stderr.includes(started.data.execution_id)); + } + assert.equal(api.calls.filter(c => c.method === 'POST' && c.url === '/v1/execute').length, 1); + } finally { api.close(); } + }); +} + +test('missing key and unreadable input have distinct sanitized errors before network work', async () => { + const api = await listen(fakeApi({events: []})); + try { + const auth = await runCli(['balance', '--json'], {DREAMLAYER_API_KEY: '', DREAMLAYER_API_URL: api.url}); + assert.equal(auth.code, 2); + assert.equal(JSON.parse(auth.stderr).error.reason, 'authentication_failed'); + const file = await runCli(['edit', '/private/customer-secret-photo.png', 'a real prompt', '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(file.code, 1); + assert.equal(JSON.parse(file.stderr).error.reason, 'local_input_failed'); + assert.doesNotMatch(file.stderr, /customer-secret-photo/); + assert.equal(api.calls.length, 0); + } finally { api.close(); } +}); + +for (const state of [{status: 'running'}, {status: 'completed', image_job: {finished_assets: [{download_url: 'one'}, {download_url: 'two'}]}}]) { + test(`download refuses ${state.status} without one output, and does not generate`, async () => { + const api = await listen(fakeApi({events: [], execution: state})); + try { + const result = await runCli(['download', 'owned', '--out', 'unused.png', '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 5); + assert.equal(JSON.parse(result.stderr).error.reason, 'output_not_ready'); + assert.deepEqual(api.calls.map(c => c.method), ['GET']); + } finally { api.close(); } + }); +} + +test('cancelled execution is a nonretryable stderr result', async () => { + const api = await listen(fakeApi({events: [started, {event: 'done', data: {status: 'cancelled'}}]})); + try { + const result = await runCli(['generate', 'a tree', '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 4); + assert.equal(result.stdout, ''); + assert.equal(JSON.parse(result.stderr).error.reason, 'execution_cancelled'); + assert.equal(JSON.parse(result.stderr).error.retryable, false); + } finally { api.close(); } +}); + +test('stream without an identifier is uncertain rather than a failed generation', async () => { + const api = await listen(fakeApi({events: []})); + try { + const result = await runCli(['generate', 'a tree', '--idempotency-key', 'saved-request', '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 5); + const error = JSON.parse(result.stderr).error; + assert.equal(error.reason, 'temporarily_unavailable'); + assert.equal(error.idempotency_key, 'saved-request'); + assert.equal(api.calls.filter(c => c.method === 'POST').length, 1); + } finally { api.close(); } +}); + +for (const command of [ + ['generate', 'a tree'], ['edit', 'missing.png', 'make it blue'], + ['cutout', 'missing.png'], ['upscale', 'missing.png'], + ['sprite', 'missing.png', '--max-credits', '100'], ['answer', 'conversation', 'yes'] +]) { + test(`${command[0]} refuses an existing output before any network request`, async () => { + const api = await listen(fakeApi({events: []})); + const dir = await mkdtemp(path.join(tmpdir(), 'dl-preflight-')); + const out = path.join(dir, 'existing.png'); await writeFile(out, 'completed'); + try { + const result = await runCli([...command, '--out', out, '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 1); + const error = JSON.parse(result.stderr).error; + assert.equal(error.reason, 'output_exists'); + assert.equal(error.retryable, false); + assert.match(error.message, /no generation was submitted/i); + assert.equal(api.calls.length, 0); + assert.equal(await readFile(out, 'utf8'), 'completed'); + } finally { api.close(); } + }); +} + +for (const kind of ['directory', 'dangling symlink', 'missing parent']) { + test(`paid output preflight refuses ${kind} before submission`, async () => { + const api = await listen(fakeApi({events: []})); + const dir = await mkdtemp(path.join(tmpdir(), 'dl-preflight-kind-')); + let out = dir; + if (kind === 'dangling symlink') { out = path.join(dir, 'link'); await symlink(path.join(dir, 'missing'), out); } + if (kind === 'missing parent') out = path.join(dir, 'missing', 'out.png'); + try { + const result = await runCli(['generate', 'a tree', '--out', out, '--json'], {DREAMLAYER_API_URL: api.url}); + assert.equal(result.code, 1); + assert.equal(JSON.parse(result.stderr).error.reason, kind === 'missing parent' ? 'output_unavailable' : 'output_exists'); + assert.equal(api.calls.length, 0); + } finally { api.close(); } + }); +} + +test('repeating a successful command with the same output does not submit a second paid job', async () => { + const api = await listen(fakeApi({events: [started, + {event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}}, + {event:'done',data:{status:'completed'}}]})); + const dir = await mkdtemp(path.join(tmpdir(), 'dl-repeat-')); + const out = path.join(dir, 'same.png'); + try { + const args = ['generate', 'a tree', '--out', out, '--json']; + const first = await runCli(args, {DREAMLAYER_API_URL: api.url}); + assert.equal(first.code, 0, first.stderr); + const second = await runCli(args, {DREAMLAYER_API_URL: api.url}); + assert.equal(second.code, 1); + assert.equal(JSON.parse(second.stderr).error.reason, 'output_exists'); + assert.equal(api.calls.filter(c => c.method === 'POST' && c.url === '/v1/execute').length, 1); + assert.deepEqual(await readFile(out), PNG); + const help = await runCli(['--help'], {}); + assert.match(help.stdout, /Existing destinations are refused before paid submission/); + } finally { api.close(); } +}); diff --git a/test/packaged-install.test.mjs b/test/packaged-install.test.mjs index fcabb29..3158774 100644 --- a/test/packaged-install.test.mjs +++ b/test/packaged-install.test.mjs @@ -57,6 +57,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: const packageJson = JSON.parse( 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.bin.dreamlayer, "./dist/cli.js"); diff --git a/tsconfig.json b/tsconfig.json index eaa8e9d..a93463b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,10 @@ "forceConsistentCasingInFileNames": true, "noUncheckedIndexedAccess": true, "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "removeComments": true }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts" + ] }