Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
98 changes: 78 additions & 20 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,7 +54,8 @@ USAGE
dreamlayer capabilities

OPTIONS
--out <file> Where to write the image. Default: dreamlayer-<n>.png
--out <file> New output file; never overwrites. Default: dreamlayer-<n>.png
Existing destinations are refused before paid submission.
--image <file> Attach an image when answering a question that asks for one
--aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
--action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
Expand All @@ -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
Expand All @@ -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<void> {
// 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<void> {
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<Uint8Array> {
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[] = [];
Expand Down Expand Up @@ -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());
}
Expand All @@ -184,14 +218,17 @@ async function upload(api: ManagedClient, file: string): Promise<string> {
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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -349,6 +386,10 @@ async function main(argv: string[]): Promise<number> {
}

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": {
Expand Down Expand Up @@ -413,9 +454,9 @@ async function main(argv: string[]): Promise<number> {
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;
}
Expand Down Expand Up @@ -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 <new-file>\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 },
};
Expand Down
27 changes: 16 additions & 11 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,22 +75,25 @@ 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;
}

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 = {
Expand Down Expand Up @@ -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});
Expand All @@ -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. */
Expand Down
Loading
Loading