diff --git a/.env.example b/.env.example index 16de923..8b8647a 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,20 @@ -# Required: your Meshy API key (https://www.meshy.ai/api) +# Your Meshy API key (https://www.meshy.ai/settings/api). Read from the +# environment, or from an explicit file via `meshy ... --api-key-file ` +# (only this key is read from the file; nothing is executed). MESHY_API_KEY=YOUR_MESHY_API_KEY_HERE # Optional base URL overrides (defaults shown) # MESHY_BASE_URL_V1=https://api.meshy.ai/openapi/v1 # MESHY_BASE_URL_V2=https://api.meshy.ai/openapi/v2 +# MESHY_BASE_URL_CREATIVE_LAB=https://api.meshy.ai/openapi/creative-lab # default: derived from the v1 origin # Optional timeouts / polling # MESHY_CONNECT_TIMEOUT_MS=10000 -# MESHY_READ_TIMEOUT_MS=120000 +# MESHY_READ_TIMEOUT_MS=120000 # covers headers and body # MESHY_POLL_INTERVAL_MS=3000 # Optional log level: debug | info | warn | error | silent # MESHY_LOG_LEVEL=warn + +# Optional: where credentials, the operation journal and the update cache live +# MESHY_CONFIG_DIR=~/.config/meshy diff --git a/.gitattributes b/.gitattributes index e62788b..4f8d623 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -dist/** linguist-generated=true +# SSE fixtures end with the blank line that terminates their last event; that is protocol, not stray whitespace. +tests/fixtures/skill-parity/*.sse -whitespace diff --git a/README.md b/README.md index 5d54d6e..bd530e4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # meshy-cli -A small, well-structured command-line interface for the [Meshy AI API](https://docs.meshy.ai/) — text-to-3D, image-to-3D (standard and smart-topology), text-to-motion, remesh, convert, resize, rigging, animation, retexture, 2D image generation, multi-color print output, and the `balance` endpoint. Built for humans and AI agents. +A small, well-structured command-line interface for the [Meshy AI API](https://docs.meshy.ai/) — text-to-3D, image-to-3D (standard and smart-topology), text-to-motion, remesh, convert, resize, UV unwrap, rigging, animation, retexture, 2D image generation, multi-color print output, Creative Lab products, the public animation catalog, Enterprise showcases, and the `balance` endpoint — plus the local helpers a 3D-printing or agent workflow needs (selective downloads, project folders, face-count checks, OBJ print preparation, slicer launch, environment diagnosis). Built for humans and AI agents. Two layers. `meshy make` chains the documented flows so that one command produces one model. Underneath, a per-endpoint command for every resource -shares a uniform `create / get / list / wait / delete` verb surface, with a raw +shares a uniform `create / get / list / wait / stream / delete` verb surface, with a raw `api` passthrough for endpoints the CLI doesn't model yet and a `skills/` directory of agent-facing documentation. @@ -12,11 +12,12 @@ The flag surface is deliberately curated rather than a 1:1 mirror of the API: de ## Install -Requires Node 24+. +Requires Node 24+. No Python, no other runtime. ```bash npm i -g meshy-cli # installs `meshy-cli` and `meshy` meshy --help +meshy doctor # local diagnosis: versions, credential sources, base URLs (no network) ``` The same build is also published under the scoped alias @@ -35,6 +36,7 @@ pnpm install pnpm build node dist/index.js --help pnpm link --global # exposes `meshy` / `meshy-cli` on $PATH +pnpm typecheck && pnpm test # `pnpm test` rebuilds dist first (subprocess tests run it) ``` ## Auth @@ -61,22 +63,32 @@ Or keep it in the environment — unchanged, and still the right choice for CI: ```bash export MESHY_API_KEY=msy_your_key_here meshy-cli --api-key msy_... balance # or per-call +meshy balance --api-key-file ./keys.env # dotenv-style file; only MESHY_API_KEY is read ``` Get a key at . -**Resolution order:** `--api-key` › `MESHY_API_KEY` › the active stored profile. +**Resolution order:** `--api-key` › `MESHY_API_KEY` › `--api-key-file` › the active stored profile. The environment variable stays ahead of the stored credential on purpose, so a CI runner is never overridden by whatever a developer once logged into on that -machine. With none of the three, commands exit `3` and print the command that -fixes it. +machine. With none of the four, commands exit `3` and print the command that +fixes it. An empty or placeholder `--api-key` / `MESHY_API_KEY` counts as unset. + +**`--api-key-file`** reads exactly one variable, `MESHY_API_KEY`, from a +dotenv-style file (`export` prefix, quotes and `#` comments accepted; nothing is +expanded or executed, other keys are ignored). A file you name but that is +missing, malformed or key-less is an error — never a silent fall-through to +another account. There is no automatic `.env` discovery. The flag is not called +`--env-file` because Node.js itself intercepts that name anywhere in argv and +loads the whole file into the environment before the CLI starts. **Where it lives:** `~/.config/meshy/credentials.json`, mode `0600`, on every platform (`MESHY_CONFIG_DIR` or `MESHY_CREDENTIALS_PATH` move it). Writes go through a cross-process lock and a temp-file rename, because several agents driving this CLI at once is the normal case. A non-production `--base-url-v1` reads and writes `credentials.dev.json` instead, so staging cannot clobber a -production login. +production login. Stored profiles are only ever sent to the v1/v2 origins they +were resolved for (and to a Creative Lab base on the same origin). **OAuth token refresh:** when the stored OAuth access token is within 60 seconds of expiry (or already expired), the CLI silently refreshes it using the stored @@ -105,13 +117,16 @@ opinions spends someone else's credits. Compose anything else from the resource commands below. ```bash -meshy make "a red sports car" --dry-run # the steps and the estimate, no spend -meshy make "a red sports car" --max-credits 25 # refuse to start when over budget +meshy make "a red sports car" --dry-run # the steps and the estimate, no spend, no network +meshy make "a red sports car" --max-credits 25 # refuse to start when over budget +meshy make "a red sports car" --async # submit step 1 (one POST), return its id + pending_steps +meshy make "a red sports car" --stop-after-first # wait for step 1, then return the resume command ``` -Both guards run before the first task is created. If a later step fails, the -error's `hint` is the command that resumes from the finished step — running it -beats starting over, which would pay for that step twice. +All guards run before the first task is created. If a later step fails, the +error carries the finished step's task id and the command that resumes from it — +running that beats starting over, which would pay for the finished step twice. +`--async` and `--stop-after-first` are mutually exclusive. ## At a glance @@ -128,34 +143,140 @@ meshy-cli image-to-3d create --image-url https://example.com/cat.png # text → standalone motion clip (Prime/FBX by default; Swift produces BVH) meshy-cli text-to-motion create --prompt "a character waving" --duration 3 -meshy-cli text-to-motion create --prompt "a quick dodge to the left" --duration 2.5 --mode swift # smart topology: component-aware low-poly with a native polycount meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --target-polycount 10000 -# ultra: an extra Meshy 7 geometry pass for finer surface detail (standard mode, single image) -meshy-cli image-to-3d create --image-url cat.png --ultra-mode true - -# retexture from several views of the same object instead of one style reference -meshy-cli retexture create --input-task-id --multiview-image-urls front.png,side.png,back.png - -# fire-and-forget (--async): returns the task_id immediately, query later -TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async | jq -r .task_id) -meshy-cli text-to-image get "$TASK" -meshy-cli text-to-image wait "$TASK" -o /tmp/result.json +# fire-and-forget (--async): exactly one POST, returns the task_id, query later +TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async --output-schema v1 | jq -r .result.submission.task_id) +meshy-cli text-to-image get "$TASK" --output-schema v1 +meshy-cli text-to-image wait "$TASK" --output-schema v1 --save-json ./task.json +meshy-cli text-to-image stream "$TASK" --format ndjson --output-schema v1 # Server-Sent Events + +# UV unwrap and Creative Lab (photo → printable product, two stages) +meshy uv-unwrap create --input-task-id --async +meshy creative-lab figure prototype create --image-url ./photo.png --name demo --async +meshy creative-lab figure build create --input-task-id --async +meshy creative-lab lamp build create --input-task-id --model-format zip --options '{"diameter_mm":180}' + +# public animation catalog (no key) and Enterprise showcases (billed per request) +meshy animation-catalog list --category DailyActions --search wave +meshy showcases list --search car --page-size 3 --model-format glb + +# local helpers (no key, no network) +meshy download --task-json ./task.json --asset result.basic_animations.walking_glb_url --output walking.glb +meshy project init --root ./meshy_output --name demo +meshy inspect faces --task-json ./task.json --max-faces 300000 +meshy mesh prepare-print ./model.obj --height-mm 75 +meshy slicer detect +meshy doctor # raw passthrough for any endpoint meshy-cli api GET /balance meshy-cli api POST /text-to-3d --data '{"mode":"preview","prompt":"a cactus"}' ``` +## Stable machine output: `--output-schema v1` + +Existing commands keep their 0.2.0 output (`legacy`) unless told otherwise; the +commands added in this release always speak `v1`. Pass `--output-schema v1` to +get one envelope with six fixed keys on stdout, in `json` (default), `pretty` +or `ndjson`: + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "image-to-3d.get", + "ok": true, + "result": { + "task": { "task_id": "…", "resource": "image-to-3d", "status": "IN_PROGRESS", "progress": 52, + "face_count": null, "consumed_credits": null, "model_urls": {}, "task_error": null, "…": "…" }, + "submission": { "state": "accepted", "operation_id": null }, + "downloads": { "state": "not_requested", "files": [], "metadata_path": null }, + "saved_json": null + }, + "error": null, + "warnings": [] +} +``` + +`ok` says whether the CLI operation completed; `result.task.status` is the +server's task state. A `get` of a FAILED task is `ok:true` (the query worked); +a `wait` that ends on FAILED is `ok:false` with the whole task kept in `result`. +Fields the server did not send are `null` — a missing `face_count` is never `0`. +`--include-raw` adds the untouched response under `result.task.raw`; +`--save-json ` writes the raw API JSON (never the envelope) and refuses to +overwrite. Progress and update notices go to stderr, so stdout is always exactly +one JSON document (`ndjson` streams emit one line per event plus a final +`outcome` line). Errors are envelopes too: + +```json +{ "schema_version": "meshy.cli/v1", "command": "text-to-3d.create", "ok": false, + "result": { "submission": { "state": "unknown", "operation_id": "…" }, "task": null }, + "error": { "code": "submission_unknown", "message": "…", "http_status": null, "retryable": false, + "recovery": { "action": "reconcile", "automatic": false, "command": "meshy text-to-3d list …" } }, + "warnings": [] } +``` + +### Create, wait, stream and money + +- `create` sends **exactly one POST**, journaled locally before it leaves + (`~/.config/meshy/operations/.json`, no key material). A lost + response, a 5xx or a malformed success is `submission_unknown` (exit 10): the + server may have created the task, so the CLI never retries and never suggests + re-running the create. Reconcile with `list`, then decide. +- `--operation-id ` replays the recorded outcome of an identical earlier + request instead of submitting again; a different request under the same id is + refused (`operation_conflict`, exit 2, naming what differs). "Identical" means + the same resource, API origin, credential — a one-way digest of the API key, + or the OAuth account (its user id, else the login id `meshy auth login` mints + for the profile) — and payload, with every inline image or model hashed by + content. An OAuth profile saved before login ids existed carries no verifiable + identity: it can start operations but is refused a replay (exit 2, + `credential_unverified`) until you log in again. This is a local record, not a + server-side idempotency key. +- Local targets that would fail after the POST are checked before it: an + existing `--save-json` file, an `-o` path outside `--workspace`, a missing + `--project` all exit 11 with "nothing was submitted" and cost no request. +- Once the server has accepted a task, every later failure — saving JSON, + polling (a 503), downloading, recording, Ctrl-C — still reports + `result.task_id`, `result.submission` and `result.next` (the `get`/`wait`/ + `stream` commands that pick the task up). A bookkeeping problem never reads + as "no task was created". +- `--async` returns after the POST (no polling). `get` is a query: any status + exits 0. `wait --timeout N` polls with a monotonic deadline (`0` = one query) + that also bounds every in-flight GET: a response arriving after the deadline + is a timeout (exit 8, last status kept, `result.task` null when none arrived + in time), never a late success, and no request starts once the budget is + spent. `stream` follows Server-Sent Events with `--timeout` (total) and + `--idle-timeout` (silence, keep-alives reset it); `-o` downloads the assets + in every output format, and in `ndjson` the final `outcome` line carries the + download manifest. +- `-o` on `get`/`wait`/`stream`/`make` downloads every artifact of a SUCCEEDED + task; `result.downloads.files` is a per-file manifest (key, path, bytes, + sha256, status). When the second asset fails, the state is `partial`, the files + already written stay listed and on disk, and the error keeps the asset host's + class and HTTP status (a 503 is `network`, exit 7, not a local I/O error). The + same holds for a failure *after* the transfers — relinking, digesting or + publishing the `meta.json` sidecar: `downloads.failed_step` names the step, the + manifest carries the digests actually on disk. The sidecar itself is published + like an asset (exclusive, no symlink, inside the root), so a file that appears + at its path during the download is never overwritten. The legacy schema + reports the same failures with additive `task_id`/`operation_id` fields and the + resume command as `hint`, so the accepted task is never lost from a default + `create -o` error. +- Ctrl-C stops waiting, streaming or downloading (exit 130) and sends no DELETE; + the envelope carries the task id, the command that resumes and the files that + had already landed. An interrupted transfer leaves no temp file behind. + ## Resources One command per endpoint. They are all registered and all supported, but they are indexed by `meshy resources` rather than listed in `meshy --help` — that help text is read on every invocation (an agent pays for the whole surface each time), so it should not grow with the API. `meshy --help` documents -each one in full. +each one in full; `meshy resources --output-schema v1` also lists the query and +local commands with their kind. | Command | Meshy endpoint | Docs | |---|---|---| @@ -166,6 +287,7 @@ each one in full. | `remesh` | `/remesh` | [docs](https://docs.meshy.ai/en/api/remesh) | | `convert` | `/convert` | [docs](https://docs.meshy.ai/en/api/convert) | | `resize` | `/resize` | [docs](https://docs.meshy.ai/en/api/resize) | +| `uv-unwrap` | `/uv-unwrap` | [docs](https://docs.meshy.ai/en/api/uv-unwrap) | | `rigging` | `/rigging` | [docs](https://docs.meshy.ai/en/api/rigging) | | `animate` | `/animations` | [docs](https://docs.meshy.ai/en/api/animations) | | `text-to-motion` | `/text-to-motion` | [docs](https://docs.meshy.ai/en/api/text-to-motion) | @@ -175,14 +297,18 @@ each one in full. | `multi-color-print` | `/print/multi-color` | [docs](https://docs.meshy.ai/en/api/multi-color-print) | | `analyze-printability` | `/print/analyze` | [docs](https://docs.meshy.ai/en/api/analyze-printability) | | `repair-printability` | `/print/repair` | [docs](https://docs.meshy.ai/en/api/repair-printability) | +| `creative-lab prototype\|build` | `/openapi/creative-lab//v1/` | [figure](https://docs.meshy.ai/en/api/creative-lab-figure) · [lamp](https://docs.meshy.ai/en/api/creative-lab-lamp) · [keychain](https://docs.meshy.ai/en/api/creative-lab-keychain) · [fridge-magnet](https://docs.meshy.ai/en/api/creative-lab-fridge-magnet) | +| `animation-catalog list` | `GET /web/public/animations/resources` (no key) | [docs](https://docs.meshy.ai/en/api/animations) | +| `showcases list` | `GET /showcases` (Enterprise; every request is billed) | [docs](https://docs.meshy.ai/en/api/enterprise-api) | -Per-resource actions (all single-HTTP-call): +Per-resource actions (all single-HTTP-call except `wait`/`stream`): ``` -meshy-cli create [flags] [--data ] [--async] [--timeout ] -meshy-cli get +meshy-cli create [flags] [--data ] [--async] [--timeout ] [--operation-id ] +meshy-cli get [--save-json ] [--include-raw] [--project ] meshy-cli list [--page ] [--page-size ] [--sort-by ] meshy-cli wait [--timeout ] +meshy-cli stream [--timeout ] [--idle-timeout ] meshy-cli delete ``` @@ -191,61 +317,158 @@ Top-level shortcut: ``` meshy-cli delete # Meshy's DELETE is unified across resources, but GET is not, so - # `get`/`wait` live only on their resource. + # `get`/`wait`/`stream` live only on their resource. ``` `create` is **synchronous by default** — it polls until the task reaches a terminal status (`SUCCEEDED` / `FAILED` / `CANCELED`) or `--timeout` hits. Pass `--async` to return the `task_id` immediately; then call -` get ` or ` wait ` when you need the result. +` get `, `wait ` or `stream ` when you need the result. + +### Creative Lab + +Four products (`figure`, `lamp`, `keychain`, `fridge-magnet`), each with a +`prototype` stage (photo → styled concept image; the lamp prototype also yields +a lampshade GLB) and a `build` stage that consumes a SUCCEEDED prototype created +through this API with the same key. Build options are validated per product +before anything is sent: lamp `--options` (diameter, thickness, light-source +preset, rotations …) with `--model-format stl|zip`; keychain and fridge-magnet +relief options with `--model-format glb|obj|zip` — their `obj` output is a ZIP +bundle and is saved as `.zip`; figure has no options. Prototypes made in the +web app are rejected by the server (404). -## Saving artifacts with `-o` +## Saving artifacts with `-o` (legacy) and `meshy download` (selective) When `-o` is set on a `create`/`wait`/`get`, the CLI downloads every artifact -the task produced, writes a sidecar metadata file, and prints a status report -instead of JSON. Single-file outputs get a per-file `_meta.json` -(e.g. `front.jpeg` → `front_meta.json`) so two outputs can share one -directory; directory-mode outputs share a single `meta.json`. +the task produced, writes a sidecar metadata file, and (legacy schema) prints a +status report instead of JSON. Single-file outputs get a per-file +`_meta.json`; directory-mode outputs share a single `meta.json`. Under +`--output-schema v1` the same download is reported in `result.downloads`, and a +task that is not yet SUCCEEDED yields `downloads.state: "not_ready"` with exit 0. ```bash -# Single-artifact task — pass a file path. Extension is autocorrected -# against the response Content-Type; sharp transcodes between -# jpg/png/webp/gif/tiff/avif so the file truly has the requested format. -meshy-cli text-to-image create --ai-model nano-banana --prompt "a leaf" \ - -o assets/leaf.jpeg - -# Multi-artifact task (3D, multi-view 2D, animation) — pass a directory. -# Files land with role-based names: model.glb, thumbnail.png, -# texture_0_base_color.png, animation_glb.glb, … +meshy-cli text-to-image create --ai-model nano-banana --prompt "a leaf" -o assets/leaf.jpeg meshy-cli image-to-3d wait -o out/robot/ ``` -Existing files at the target abort with a clear `UsageError` — no silent -overwrite. Without `-o`, the CLI keeps its pre-download JSON behavior -(machine-readable summary on stdout). +`meshy download` is the selective, scriptable counterpart: + +```bash +meshy download --task-json ./task.json --list # what is there? +meshy download --task-json ./task.json --model-format glb --output ./model.glb +meshy download --task-json ./rig.json --asset result.basic_animations.walking_glb_url --output ./walking.glb +meshy download --task-json ./task.json --kind thumbnail --output-dir ./previews/ +meshy download --resource image-to-3d --task-id --all --output-dir ./out/ # one GET, then the assets +meshy download --url https://assets.meshy.ai/... --output ./file.glb +``` + +Sources are `--task-json` (an API task, a legacy `meta.json`, or a v1 envelope), +`--url`, or `--resource` + `--task-id`; selectors are `--asset ` (repeatable), +`--model-format`, `--kind`, `--all`. With several assets and no selector the +command lists the candidates and exits 2 instead of guessing. Selecting an OBJ +pulls its MTL and textures (`--geometry-only` to skip); once the set has landed +the OBJ's `mtllib` and the MTL's `map_*` references are rewritten to the names +actually saved (`model.mtl`, `texture_0_base_color.png`, …) so the model loads +from that directory. Textures are matched by the name the server served them +under (then by channel), one candidate only: with several material groups a +reference that could mean two files is left as written and reported as +ambiguous, and a reference that merely equals one of the CLI's generated names +(`texture_0_base_color.png`) while the server called that image something else +is ambiguous too — identity follows the source, never the file name on disk. +Channel fallbacks (a channel word in the reference, the MTL key's channel, the +only texture there is) are heuristics and compete on the texture they actually +reach: when two different references would both fall back to the same image, or +one reference would go to different images under different keys, they all stay +as written and are reported as ambiguous — the CLI never merges material groups +without evidence that they name the same file. Every link is listed under `result.downloads.material_links` +(`status: complete | incomplete`, the `newmtl` group of each map), rewritten +files carry `relinked: true` with their final sha256, and a reference that +matches no or several downloaded files stays as written and is warned +(`material_reference_unresolved` / `material_reference_ambiguous`). Files are published +exclusively (never overwritten without `--overwrite`), checked against the +content type and magic bytes, kept inside the output directory (or `--workspace`), +and listed with size and sha256 in `result.downloads.files`. With `--project ` +the files that landed inside the project are recorded in its `metadata.json`; +when that record fails after the transfer (metadata.json replaced by a symlink, +damaged, or locked) the command exits 11 with the complete `result` — manifest, +`saved_json`, `project.action: "failed"` — and `error.recovery.command` is the +one `meshy project record …` invocation that redoes the record — carrying the +original `--workspace`, so a recovery never writes further than the command that +failed; the assets stay where they landed. Problems visible before the transfer (no metadata.json, a +symlink or invalid JSON in its place, a blank `--stage`) are refused with no +request made. Asset hosts never +receive the API credential; an expired signed URL is refreshed once when the +task came from the API and reported as unrefreshable when it came from a file. + +## Projects (`meshy_output/`) + +The Skills' project layout, without Python: + +```bash +meshy project init --root ./meshy_output --name "demo" --task-id +meshy text-to-3d wait --project ./meshy_output/ # saves task_.json, records the stage +meshy project record --project ./meshy_output/ --task-id --resource text-to-3d --stage preview --file preview.glb +meshy project show --project ./meshy_output/ +meshy project list --root ./meshy_output +meshy project rebuild-index --root ./meshy_output +``` + +`metadata.json` (schema 2; legacy files are read as-is and migrated on the first +write with a backup) is the source of truth; `history.json` is a rebuildable +index. A repeat `(task_id, stage)` merges instead of duplicating. + +## Printing helpers + +```bash +meshy inspect faces --task-json ./task.json --max-faces 300000 # pass (0) | fail (12) | unknown (13) +meshy mesh prepare-print ./model.obj --height-mm 75 # writes ./model.print.obj +meshy mesh prepare-print ./model.obj --height-mm 80 --in-place +meshy slicer detect +meshy slicer open --slicer OrcaSlicer --file ./model.print.obj +``` + +`inspect faces` answers only whether the task's `face_count` is within the +limit you pass (`--max-faces` is required); a missing count is `unknown`, never +0, and a failing verdict only *describes* a remesh. `prepare-print` rotates a +Y-up OBJ to Z-up, scales it to the target height, centres it on XY and rests it +on Z=0, preserving faces, UVs, normals (rotated only) and material references; +it never overwrites without `--in-place`, and the MTL/texture copies it makes +beside the output are proven — on real paths, before any directory is created — +to lie inside the output directory (or `--workspace`), so a symlinked +`materials/` cannot redirect them. `slicer open` launches only a +registered slicer at its detected path with the file as a single argument — no +shell, no default-application fallback; `launch_requested` is not proof that +the import succeeded. ## Image and 3D-model inputs Flags that take a media source (`--image-url`, `--image-urls`, `--reference-image-urls`, `--texture-image-url`, `--image-style-url`, -`--multiview-image-urls`, `--model-url`) accept: +`--multiview-image-urls`, `--model-url`) — and the same fields inside `--data` — +accept: -- **http(s) URLs** — preflighted with HEAD so unreachable sources fail fast. +- **http(s) URLs** — preflighted with an unauthenticated HEAD so unreachable sources fail fast. - **Local file paths** — absolute or relative to cwd. MIME-sniffed via magic - bytes (with extension fallback) and inlined as `data:` URIs on the wire. + bytes (with extension fallback), size-capped, and inlined as `data:` URIs on the wire. +- **`data:` URIs** — validated (base64, MIME kind, size) and passed through. -You do not need to host files. `data:` URIs on the command line are rejected -explicitly — pass a local path instead. Missing files and 4xx/5xx responses -exit with code `2` and a flag-prefixed message before any task is created. +Missing files and 4xx/5xx preflights exit with code `2` and a flag-prefixed +message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` +`--model-url`) reject other formats locally. ## Global flags | Flag | Purpose | |------|---------| | `--api-key ` | Override `MESHY_API_KEY` | +| `--api-key-file ` | Read `MESHY_API_KEY` from an explicit dotenv-style file (only that key) | | `--base-url-v1 ` / `--base-url-v2 ` | Override endpoints (staging/proxy) | -| `--format json\|pretty\|ndjson` | Stdout format when `-o` is not set (default `json`) | -| `-o, --output ` | Download artifacts to a file/directory; write `meta.json`; switch stdout to a status report | +| `--base-url-creative-lab ` | Override the Creative Lab base (default: `/openapi/creative-lab`) | +| `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | +| `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | +| `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | +| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created. The boundary is frozen when the command starts (real path and directory identity): a workspace or project replaced by a symlink while a request is in flight is refused, never followed | +| `--no-update-check` | Skip the background npm version check in this process | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | @@ -253,44 +476,50 @@ exit with code `2` and a flag-prefixed message before any task is created. | Code | Meaning | |------|---------| -| 0 | success | -| 1 | generic / server | -| 2 | usage (flag parse error) | -| 3 | auth (`401`) | -| 4 | validation (`400`, `422`) | -| 5 | not found (`404`) | +| 0 | success (including `get` of any task status, empty lists, local checks that pass) | +| 1 | task ended FAILED/CANCELED while waiting, or an unclassified error | +| 2 | usage (flag parse error, conflicting or missing arguments) | +| 3 | auth (`401`, no usable credential) | +| 4 | validation (`400`, `422`, locally rejected payload) | +| 5 | not found (`404`; the cause is not guessed) | | 6 | rate limit (`429`) | -| 7 | network | -| 8 | timed out waiting for a task | +| 7 | network (read failures, stream disconnects) | +| 8 | timed out waiting for or streaming a task (the task keeps running) | | 9 | credit exhausted (`402`) | +| 10 | submission unknown — a create was sent but its outcome could not be confirmed | +| 11 | local I/O — refused overwrite, path outside the authorised root, journal/project write failure | +| 12 | check failed (`inspect faces` over the limit) | +| 13 | check unknown (`inspect faces` without a usable face count) | +| 130 | interrupted (Ctrl-C); nothing was deleted server-side | ## Environment variables | Variable | Default | |---|---| -| `MESHY_API_KEY` | — (required unless a profile is stored) | +| `MESHY_API_KEY` | — (required unless a profile is stored or `--api-key-file` is given) | | `MESHY_BASE_URL_V1` | `https://api.meshy.ai/openapi/v1` | | `MESHY_BASE_URL_V2` | `https://api.meshy.ai/openapi/v2` | +| `MESHY_BASE_URL_CREATIVE_LAB` | derived from the v1 origin (`/openapi/creative-lab`) | | `MESHY_OAUTH_AUTHORIZE_URL` | `https://www.meshy.ai/oauth/authorize` — override for staging/testing | | `MESHY_CLI_NO_BROWSER` | unset — set to `1` to suppress browser open (URL still printed to stderr) | -| `MESHY_CONNECT_TIMEOUT_MS` | `10000` | -| `MESHY_READ_TIMEOUT_MS` | `120000` | +| `MESHY_CONFIG_DIR` | `~/.config/meshy` (credentials, operation journal, update cache) | +| `MESHY_CONNECT_TIMEOUT_MS` | `10000` (read for compatibility; fetch has no separate connect timeout) | +| `MESHY_READ_TIMEOUT_MS` | `120000` — covers headers *and* body | | `MESHY_POLL_INTERVAL_MS` | `3000` | | `MESHY_LOG_LEVEL` | `warn` | | `MESHY_CLI_NO_UPDATE_NOTIFIER` | unset — any non-empty value disables update checks; CI envs (`CI`, `GITHUB_ACTIONS`, `BUILD_NUMBER`, `RUN_ID`) auto-skip | ## Update notifications -meshy-cli checks the npm registry for a newer version at most once per 24 hours. The result is cached at `~/.config/meshy/update-state.json`. The refresh runs in a detached background process so it can never slow down or fail a command. +meshy-cli checks the npm registry for a newer version at most once per 24 hours. The result is cached at `/update-state.json`. The refresh runs in a detached background process so it can never slow down or fail a command, and it never runs for local commands (`resources`, `project`, `inspect`, `mesh`, `slicer`, `doctor`, `download`, `animation-catalog`), for `make --dry-run`, or with `--no-update-check`. When a newer version is available: -- **JSON object outputs** (`--format json` when the result is an object) carry a top-level `_notice.update = { current, latest, message, command }` so agents can relay it to the user. -- **ndjson arrays** carry `_notice.update` on the **first line only** (the first element, if it is a plain object). -- **Plain JSON arrays** (`--format json` with an array result) are intentionally left untouched — there is no clean metadata slot in a JSON array without breaking the schema. +- **Legacy JSON object outputs** carry a top-level `_notice.update = { current, latest, message, command }` so agents can relay it to the user. +- **v1 envelopes are never decorated** — the six keys are the contract; humans get the hint on stderr. +- **ndjson arrays** (legacy) carry `_notice.update` on the **first line only**. - **Humans on an interactive terminal** get a single line on stderr after the command output. - **stdout is never polluted** — the notice never appears on stdout. -- **Skipped automatically** in CI environments and for development builds. ## Agent skills @@ -298,43 +527,50 @@ The `skills/` directory contains markdown-based skills for AI coding agents (Claude Code, etc.): - [`skills/meshy-cli`](skills/meshy-cli/SKILL.md) — a single skill covering - setup, `make`, the shared verb contract, the API constraints that produce - failed tasks when ignored, and the exit-code table. + setup, `make`, the shared verb contract, the v1 envelope, the API constraints + that produce failed tasks when ignored, the local helpers, and the exit-code table. It is deliberately short. A skill is loaded into an agent's context on every invocation, so length is a running cost; anything an agent can look up on demand (`meshy resources`, `meshy --help`, ) is linked rather than copied. +## Skill-parity documentation + +`docs/skill-parity/` records how this release covers the execution capabilities +of the Meshy Skills without Python: the frozen baseline, the endpoint contracts, +the capability matrix, design decisions, migration notes with every intentional +difference, and the verification record. + ## Project layout ``` meshy-cli/ ├── src/ -│ ├── index.ts # CLI entry +│ ├── index.ts # CLI entry: update-check policy, SIGINT, unified error exit │ ├── root.ts # root command + global flag wiring -│ ├── cmd/ # make + resources + api/balance/delete + one file per endpoint -│ ├── client/ # Meshy HTTP client (v1 + v2 fetchers, typed endpoints) +│ ├── cmd/ # make, auth, balance, resources, api, one file per endpoint, +│ │ # uv-unwrap, creative-lab, animation-catalog, showcases, +│ │ # download, project, inspect, mesh, slicer, doctor +│ ├── client/ +│ │ ├── resource-registry.ts # every resource: path, family, verbs, billing, media fields +│ │ ├── transport.ts # per-family HTTP transport (auth scope, deadlines, redirects) +│ │ ├── endpoints/ # TaskEndpoint + balance / catalog / showcases +│ │ └── types.ts # Zod task schemas │ └── internal/ -│ ├── config.ts # env + flag → runtime config -│ ├── runtime.ts # lazy client/config construction -│ ├── make-plan.ts # make's route choice + step list (pure, offline) -│ ├── pricing.ts # credit estimates for the chains make runs -│ ├── task-command.ts # create/get/list/wait/delete factory -│ ├── poll.ts # backoff polling until terminal -│ ├── file-input.ts # image + 3D-model flag resolution (URL or local) -│ ├── download.ts # -o artifact download, meta.json, extension fix -│ ├── report.ts # Status: SUCCESS / FAIL stdout formatter -│ ├── output.ts # json / pretty / ndjson writers -│ ├── payload.ts # --data parsing, mergePayload, dropNullish -│ ├── flags.ts # parseBool / parseInt10 / parseCsv helpers -│ ├── errors.ts # UsageError + exit-code mapping -│ ├── global-options.ts # mirror --format etc. onto subcommands -│ └── logger.ts # leveled stderr logger -├── tests/ # node:test unit + integration tests -├── skills/ # agent-facing skill: SKILL.md + bundled animation catalog (published) +│ ├── result.ts / errors.ts # v1 envelope, error codes, exit codes +│ ├── task-command.ts # create/get/list/wait/stream/delete factory +│ ├── operation-store.ts # submission journal (single POST, unknown outcomes) +│ ├── stream.ts / poll.ts # SSE parser + cancellable polling +│ ├── artifacts.ts / download.ts # asset keys, safe downloads, legacy -o +│ ├── project-store.ts # meshy_output metadata + history +│ ├── inspect.ts / obj-transform.ts / slicers.ts / doctor.ts +│ ├── config.ts / runtime.ts / env-file.ts / credentials.ts / oauth.ts +│ └── atomic-file.ts / paths.ts / lock.ts +├── tests/ # node:test unit, contract and black-box tests +├── skills/ # agent-facing skill (published) +├── docs/skill-parity/ # baseline, contracts, matrix, decisions, verification ├── package.json -├── tsconfig.json └── README.md ``` @@ -342,24 +578,22 @@ meshy-cli/ - **Two layers, one of them opinion-free.** `make` chains endpoints; the resource commands expose them one at a time. `make` picks its chain from the - input type alone and stops there. Every richer decision — going through an - image first, confirming a shape before texturing, a polycount that rigs well - — is a judgement about someone else's asset and someone else's credits, so it - stays with the caller rather than being baked into a default. + input type alone and stops there. - **Plan first, then spend.** `make` computes the whole chain before creating anything, so `--dry-run` and a real run share one code path and one estimate. - `--max-credits` refuses on that estimate; a budget enforced after step one has - billed is not a budget. -- **Two fetchers.** `text-to-3d` lives under `/openapi/v2`, everything else on - `/openapi/v1`. The client holds both and routes per endpoint. -- **Uniform verbs.** A single `buildResourceCommand` factory generates - `get`/`list`/`wait`/`delete` for every resource. Resource modules only - contribute their unique `create` flag shape. +- **One POST, journaled.** Every billable create is recorded before it is sent; + a lost answer is reported as unknown, never re-sent. +- **One registry.** `src/client/resource-registry.ts` is the only place a path, + API family, verb set or media field is declared; commands, the `resources` + index and the transport all read from it. +- **Credentials have a scope.** The API credential goes to the API origins it + was resolved for and nowhere else — not to asset hosts, not to the public + catalog, not across redirects. - **`--data` escape hatch.** Every `create` accepts a raw JSON object (or - `@file.json`) that merges with structured flags. Use this when Meshy ships a - new field before the CLI models it. -- **Stdout is reserved for command output.** Logs and errors go to stderr so - pipes (`| jq`, `-o file`) stay clean. + `@file.json`) that merges with structured flags (flags win, explicit `false` + and `0` survive). +- **Stdout is reserved for command output.** Logs, progress and errors' prose go + to stderr so pipes (`| jq`, `-o file`) stay clean. ## License diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md new file mode 100644 index 0000000..b1fd88c --- /dev/null +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -0,0 +1,345 @@ +# Meshy CLI S1 Review Handoff + +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. **第 8 轮 Codex 结论(2026-09-08):accepted** — L01/L02 关闭,前七轮 32 项 finding 在 `1d9f109` 未回退,G1-code 继续接受;证据 `…/reviews/cli-s1-1d9f109/`(0 findings)。随后所有者授权:分支经 PR 合并、通过 `release.yml` 发布 0.3.0,Windows x64 本次不验证;发布结果在发布后的 docs commit 中记录。本文件为 **Round 8**:第 7 轮 Codex review 已 **accepted**(G1-code,代码 `e567646`,docs `4da216d`,证据 `…/reviews/cli-s1-e567646/`);本轮是 **真实账号 live verification** 及其发现的 2 个缺陷的修复,交给 Codex 复审这两个修复并核对 live 证据。七处 reviewer 证据目录均未被改动;第 7 轮的 15 个脚本副本在新 HEAD 上重跑。 + +## 0. Live verification 结论与本轮修复 + +- 执行方式:账号所有者在自己的终端完成 `meshy auth login --with-key`(profile `default`,API key)与 `meshy auth login`(profile `oauth`,浏览器 PKCE),凭据落在默认的 `~/.config/meshy/credentials.json`;CLI 通过 `npm install -g ~/Downloads/meshy-cli-0.3.0.tgz` 全局安装(与真实用户一致,每次修复后重新安装);工作目录 `/meshy-live`;所有者授权计费且不设上限。凭据从未经过 agent;记录中凭据一律脱敏(`auth status` 自带遮蔽;`credentials.json` 只读取键名与哈希)。 +- 结果:**T-104 passed**(两种凭据、真实 token 端点无 `user_id` → login_id 身份、静默 refresh 观测到期后 expires_at 前移且 login_id 不变、journal 复用/凭据冲突/媒体指纹冲突真实复现);**T-110 passed**;**T-111 passed**(UV / 全部 Creative Lab 产品 / showcases 为 403 enterprise-only 记录);**T-112 passed**(真实 Bambu Studio 02.08.02.61 两次拉起);**T-109 partial**(macOS arm64 + Linux arm64/x64 通过,Windows x64 not_run)。 +- credits:开始 2806 → 结束 2479,共消耗 **327**(每步见 §4 表)。 +- 发现并修复 2 个真实缺陷(commit `1d9f109`,`tests/live-verification.test.ts`,修复后重新安装并在真实任务上复验): + +| ID | 严重度 | 现象(真实账号) | 修复 | 决策 | 复验 | +| --- | --- | --- | --- | --- | --- | +| L01 | P1(Creative Lab 全流程阻断) | Creative Lab 端点在 IN_PROGRESS 时返回 `finished_at: null`(v2 端点返回 0);schema 只接受 number → `creative-lab … get/wait` 每次轮询都以 "unexpected task shape"(`error.code=server`,HTTP 200,exit 1)失败,`wait` 永远轮询不到完成 | `progress/preceding_tasks/created_at/started_at/finished_at/expires_at` 接受 null 与缺省并归一为 0;v1 视图仍以 `null` 表示未发生 | D-059 | 真实捕获体作为 fixture;keychain prototype 创建后立即 `wait` 经 3 次 IN_PROGRESS 到 SUCCEEDED;lamp prototype get/wait 与 lamp build 完成 | +| L02 | P2(输出文件名错误) | 任务动词的 `-o`(legacy 布局)把 `model_urls` 键当扩展名:lamp build 落盘为 `model.lamp_stl`/`model.base_stl`;keychain 的 OBJ 实为 ZIP bundle 却存成 `model.obj` | legacy 枚举复用 artifacts.ts 的产品感知 `modelAsset` 映射得到文件名与格式(`lamp.stl`、`base.stl`、`bundle.zip`、`model.obj.zip`),slot key 与 relink 规则不变 | D-060 | 同一任务重新 `-o`:`lamp.stl`/`base.stl`(字节与误名文件一致)、`model.obj.zip`;fridge-magnet build 亦按映射命名 | + +- 未修复的观察(P3,供 Codex 判断是否立项):见 §6。 +- 回归:`pnpm test` **558/558**;typecheck 通过;`git diff --check` 在 `fd94490..HEAD`、`4da216d..HEAD`、工作树均 exit 0;poll ×12 12/12、round1 ×8 8/8、round2–6 ×3 3/3;第 7 轮 15 个 reviewer 脚本副本在 `1d9f109` 上:round1–6 探针 0 复现(C03/D03 signal_sent=true exit 130),verify 20/20、8/8、24/24、5/5,矩阵 16/16、10/10、20/20,context 5/5,stream 通过;tarball smoke 29/29(sha256 `0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e`)。 + +## 1. 代码定位 + +- 仓库路径 / remote:`/Users/ark/Dev/meshy-cli` / `https://github.com/meshy-dev/meshy-cli.git`;分支 `feat/skill-parity-s1`(本地,未推送) +- base SHA:`fd94490916376e691efcea51324ac4326b459e1f` +- 上一轮(accepted)代码 HEAD:`e567646d875e5f5a658b78e60b8cdfaed8b233e9`;docs HEAD:`4da216d3568fbd997bf85f8047ce3932672a25de` +- 本轮代码 HEAD:`1d9f10976b5f754502c81591c64c712e492188d1`(= live 修复 commit);本文件与 verification.json / capability-matrix.json / live-verification.json 在其后的 **docs-only commit** 中 +- 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) +- Node / pnpm / OS:Node v24.20.0 / pnpm 11.24.0 / macOS 26.6.2 arm64 +- PR / 发布:所有者 2026-09-08 授权后,本 commit 之后推送分支并创建 PR 合入 `main`;发布经 `.github/workflows/release.yml`(workflow_dispatch,main)执行,不做本地 `npm publish`;结果见发布后的 docs commit + +## 2. 完成状态 + +- G1-code:第 7 轮 accepted;第 8 轮 accepted(本轮 2 个 live 修复 L01/L02 已关闭,范围小:`src/client/types.ts` 6 个字段的 null 容忍;`src/internal/download.ts`+`artifacts.ts` 的文件名映射共享)。 +- G1-release:**尚未满足(发布进行中)**。已完成:Codex review(第 7、8 轮 accepted)、真实账号/UV/Creative Lab/showcases/切片器/macOS+Linux 验证、所有者发布授权(2026-09-08);未完成:正式发布与可追溯记录(PR 合并后经 release.yml 发布 0.3.0,随后记录);Windows x64 由所有者决定本次不验证,保持 not_run。 +- mandatory 能力 35/35 已实现;`capability-matrix.json` 每项新增 `live_verification`(33 项 live passed / passed_after_live_fixes,CAP-014 showcases 为账号门控 403 记录,CAP-034 打包为 partial(Windows not_run))。 +- 离线测试:`pnpm test` 558/558,0 跳过。 + +## 3. 本次具体改动 + +``` +1d9f109 fix(live): parse Creative Lab null timestamps; name Creative Lab parts and bundles in the legacy -o layout +4da216d docs(skill-parity): round-7 handoff after Codex review round 6 fixes +e567646 fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 +166492b docs(skill-parity): round-6 handoff after Codex review round 5 fixes +7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 +e7c0bbc docs(skill-parity): round-5 handoff after Codex review round 4 fixes +68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits +93e55bc fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 +9e43a77 docs(skill-parity): round-4 handoff after Codex review round 3 fixes +235d6de test(poll): judge the real-timer smoke with the loop's own clock reading +30536d8 fix(review): address Codex review round 3 findings R3-F01–R3-F06 +566f3bd docs(skill-parity): round-3 handoff after Codex review round 2 fixes +cf8905d fix(review): address Codex review round 2 findings R2-F01–R2-F07 +730132b docs(skill-parity): round-2 handoff after Codex review round 1 fixes +0388fe8 fix(review): address Codex review round 1 findings F01–F10 +6273d9a docs(skill-parity): verification record and review handoff for the 0.3.0 candidate +e7c26fc chore(release): 0.3.0 candidate — README, bundled skill, env example, origin-policy test +da7e1dc feat(local): B06-B08 inspect faces, OBJ prepare-print, slicers and doctor +96ee188 feat(project): B05 meshy_output project store, project command and --project bookkeeping +3935f35 feat(download): B04 asset enumeration, selective download and safe file placement +6784309 feat(tasks): B03 task lifecycle — v1 verbs, journaled single POST, SSE stream, make async, uv-unwrap, creative-lab +f05ff87 feat(client): B02 transport, resource registry, catalog and showcases +e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file and unified error exit +c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures +``` + +``` +1d9f109 fix(live): parse Creative Lab null timestamps; name Creative Lab parts and bundles in the legacy -o layout + + docs/skill-parity/decisions.md | 37 ++++++ + docs/skill-parity/migration-notes.md | 7 ++ + src/client/types.ts | 24 ++-- + src/internal/artifacts.ts | 8 +- + src/internal/download.ts | 14 ++- + .../creative-lab-lamp-prototype.in-progress.json | 17 +++ + tests/live-verification.test.ts | 128 +++++++++++++++++++++ + 7 files changed, 225 insertions(+), 10 deletions(-) +``` + +- `src/client/types.ts`:`nullableNumberOr0`(null/缺省 → 0)用于 6 个时间戳/计数字段。 +- `src/internal/artifacts.ts`:导出 `modelAsset`;`src/internal/download.ts`:legacy `enumerateArtifacts` 用产品感知映射得到 `filename`/`preferredExt`,`deriveFilename` 优先使用它。 +- `tests/live-verification.test.ts`:L01(fixture 解析 + get/wait 轮询)、L02(lamp/keychain build `-o` 命名);fixture `tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json`(真实捕获体,id/name 已替换)。 +- 文档:D-059、D-060;migration-notes §3.7;`docs/skill-parity/live-verification.json`(脱敏 live 记录:49 步 + Linux 双架构)。 + +## 4. Live verification 逐步记录(脱敏;完整字段见 `docs/skill-parity/live-verification.json`) + +| 步骤 | 凭据 | 内容 | credits | +| --- | --- | --- | --- | +| T104-01 | oauth | auth status (OAuth active) — masked credential, verified balance | | +| T104-02 | oauth | auth list — two profiles (api_key default, oauth) | | +| T104-03 | oauth | credentials.json shape: oauth profile has access/refresh tokens and login_id, no user_id (the real token endpoint did not return one) | | +| T104-04 | oauth | silent refresh observed: expires_at advanced, login_id kept, tokens rotated; refreshed token works | | +| T104-05 | api_key | journal replay with the same key: operation_replayed, no new task, balance unchanged | | +| T104-06 | oauth | same operation id under the OAuth profile → operation_conflict (credential), exit 2, nothing submitted | | +| T104-07 | api_key | same operation id with a different image → operation_conflict (payload); same bytes under another file name → replayed | | +| T104-08 | oauth | OAuth bearer on v2 get and v1 asset download — same bytes as the API-key download | | +| T110-01 | api_key | project init (real workspace ~/meshy-live) | | +| T110-02 | api_key | text-to-3d create --mode preview --async --project --save-json | 20 | +| T110-03 | api_key | stream (ndjson) on the finished preview: task + outcome, sequence contiguous | | +| T110-04 | api_key | wait -o preview into the project (model.glb + thumbnail, meta.json, snapshot merged) | | +| T110-05 | api_key | download --list on the preview | | +| T110-06 | api_key | text-to-3d create --mode refine (glb,obj,fbx) --async | 10 | +| T110-07 | api_key | stream (ndjson) during the refine: 30 progress events + 1 outcome, contiguous sequence | | +| T110-08 | api_key | wait -o refine: 9 files incl. OBJ+MTL+4 textures; real MTL map_Kd texture_0.png → texture_0_base_color.png by source_name; material_links complete | | +| T110-09 | api_key | download --model-format obj (selective, dependencies) — material_links complete | | +| T110-10 | api_key | download --asset thumbnail.primary --output file | | +| T110-11 | api_key | image-to-3d create from a local synthetic PNG (data URI) — server-side FAILED, 0 credits, task_failed relayed, FAILED recorded in the project | 0 | +| T110-12 | api_key | download --list / get on the FAILED task (not_ready + task_not_ready warning; task_error relayed) | | +| T110-13 | api_key | image-to-3d retry with the real refine thumbnail: SUCCEEDED, OBJ/MTL relinked | 30 | +| T110-14 | api_key | text-to-3d list --page-size 3 | | +| T110-15 | oauth | image-to-3d delete (the FAILED task) then get → not_found 404 exit 5 | | +| T111-01 | api_key | showcases list → 403 enterprise-only (account-gated): error.code server, http 403, exit 1 | | +| T111-02 | api_key | animation-catalog list (public, free) | | +| T111-03 | api_key | uv-unwrap on the 1.9M-face refine → API 400 (44k limit) mapped to validation exit 4; on the remeshed model SUCCEEDED | 5 | +| T111-04 | api_key | remesh (8000 faces, glb,obj) SUCCEEDED; OBJ set relinked | 5 | +| T111-05 | api_key | retexture (text style prompt, PBR) SUCCEEDED | 10 | +| T111-06 | api_key | analyze-printability SUCCEEDED (0 credits): report-only, meta.json written, status warning (degenerate faces) | 0 | +| T111-07 | api_key | rigging on the teapot → API 400 (face limit) then 422 (pose estimation failed) → validation exit 4 | | +| T111-08 | api_key | text-to-motion: CLI requires --duration (2–10 s); with --duration 4 SUCCEEDED, motion.fbx | 10 | +| T111-09 | api_key | humanoid text-to-3d preview for the rig chain | 20 | +| T111-10 | oauth | remesh the humanoid to 30k faces (rigging refused 1.95M faces with 400) | 5 | +| T111-11 | oauth | rigging SUCCEEDED: rigged glb/fbx + walking/running clips (8 files) | 5 | +| T111-12 | oauth | animate create --action-id 28 (Big Wave Hello) on the rig: stream 10 events + outcome; wait -o glb+fbx | 3 | +| T111-13 | oauth | make 'a low-poly cactus…' -o: preview → refine, two journal records, downloads | 30 | +| T111-14 | oauth | creative-lab lamp prototype create; get/wait on the IN_PROGRESS task FAILED with 'unexpected task shape' (finished_at: null) → live finding L01 | 30 | +| T111-15 | oauth | after the L01 fix (reinstalled CLI): lamp prototype get/wait SUCCEEDED (lampshade glb + concept image) | | +| T111-16 | oauth | keychain prototype create + immediate wait polled through 3 IN_PROGRESS states to SUCCEEDED (L01 verified live) | 6 | +| T111-17 | oauth | lamp build SUCCEEDED — legacy -o named the parts model.lamp_stl/model.base_stl → live finding L02 | 6 | +| T111-18 | oauth | meshy download --list/--all on the lamp build names lamp.stl / base.stl (selective path was right) | | +| T111-19 | oauth | keychain builds: default (glb) and --model-format obj (ZIP bundle); legacy -o saved the bundle as model.obj → L02; selective path: model.obj.zip | 60 | +| T111-20 | oauth | after the L02 fix (reinstalled CLI): -o on the same builds → lamp.stl/base.stl (byte-identical) and model.obj.zip | | +| T111-21 | oauth | figure prototype SUCCEEDED; first figure build FAILED server-side (0 credits, task_failed relayed); retry SUCCEEDED with OBJ/MTL relinked | 36 | +| T111-22 | oauth | fridge-magnet prototype + build (exposed product) SUCCEEDED with the fixed CLI: bundle named by the shared mapping | 36 | +| T112-01 | none | slicer detect finds Bambu Studio 02.08.02.61; slicer open on a prepared print OBJ launches it (macOS open -a, pid observed) | | +| T112-02 | none | mesh prepare-print on the real remeshed OBJ (60 mm) → print OBJ + copied MTL/texture; slicer open again on that file | | +| LOCAL-01 | none | project show/list on the real project: 11+ task entries with snapshots and operation ids; history index clean | | +| LOCAL-02 | none | inspect faces on real task snapshots → check_unknown (13): the API task JSON carries no face_count | | + +Linux(OrbStack,`node:24-bookworm`):linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 → 29/29,sharp 0.35.4 (libvips 8.18.6); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 → 29/29,sharp 0.35.4 (libvips 8.18.6)。 + +### 4.1 关键实际输出(脱敏) + +L01 修复前(lamp prototype `wait`,IN_PROGRESS): + +```json +{ + "ok": false, + "error.code": "server", + "error.http_status": 200, + "message_head": "unexpected task shape from GET /lamp/v1/prototype/01a07f79-e9f7-7347-89c7-c46fb7a11d06: [\n {\n \"expected\": \"number\",\n" +} +``` + +L01 修复后(keychain prototype 创建后立即 `wait`): + +```json +{ + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "result.wait.polls": 4, + "files": [ + { + "key": "image_0", + "bytes": 477978, + "status": "written", + "sha256_12": "e4eab2f32244" + } + ] +} +``` + +L02 修复前/后(lamp build 与 keychain obj build 的 `-o` 文件名): + +```json +{ + "before_lamp": [ + { + "key": "model_base_stl", + "bytes": 360284, + "status": "written", + "sha256_12": "5cbf2af2866c" + }, + { + "key": "model_lamp_stl", + "bytes": 15162184, + "status": "written", + "sha256_12": "94f5809a85df" + } + ], + "before_keychain_legacy": [ + { + "key": "model_obj", + "bytes": 46871892, + "status": "written", + "sha256_12": "bf665456914a" + } + ], + "selective_download_was_right": [ + { + "key": "model.obj", + "relative_path": "keychain-build-selective/model.obj.zip", + "format": "zip", + "container_format": "zip" + } + ], + "after_fix": { + "lamp": [ + [ + "model_base_stl", + "base.stl" + ], + [ + "model_lamp_stl", + "lamp.stl" + ] + ], + "keychain": [ + [ + "model_obj", + "model.obj.zip" + ] + ] + } +} +``` + +OAuth refresh 观测(T-104): + +```json +{ + "original_expires_at_iso": "2026-09-08T05:52:10.945000+00:00", + "expires_at_now_iso": "2026-09-08T06:51:13.984000+00:00", + "refreshed": true, + "login_id_present": true, + "login_id_sha": "ee55f2134c19", + "created_at_unchanged": true, + "balance_after": 2515 +} +``` + +真实 refine 的材质重链接(T-110): + +```json +{ + "result.downloads.material_links.status": "complete", + "result.downloads.material_links.texture_maps": [ + { + "line": 12, + "material": "Material.005", + "reference": "texture_0.png", + "resolved_to": "texture_0_base_color.png", + "method": "source_name" + } + ], + "files": [ + { + "key": "model_glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" + }, + { + "key": "model_fbx", + "bytes": 99468604, + "status": "written", + "sha256_12": "915454648561" + }, + { + "key": "model_obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" + }, + { + "key": "model_mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "thumbnail", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + }, + { + "key": "texture_0_base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture_0_metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture_0_normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + }, + { + "key": "texture_0_roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" + } + ] +} +``` + +## 5. 实际验证记录(绑定 `1d9f10976b5f754502c81591c64c712e492188d1`) + +| 检查 | 结果 | +| --- | --- | +| `pnpm install --frozen-lockfile` / `pnpm typecheck` / `pnpm build` | exit 0 / 0 / 0 | +| `pnpm test` | **558/558**(45 s) | +| `git diff --check` fd94490 / 4da216d / 工作树 | 0 / 0 / 0 | +| poll ×12 / round1 ×8 / round2–6 ×3 | 12/12 / 8/8 / 3/3 | +| 第 7 轮 reviewer 脚本副本(15 个) | round1–6 探针复现 0/0/0/0/0/0;verify 20/20、8/8、24/24、5/5;矩阵 16/16、10/10、20/20;context 5/5;stream passed | +| tarball smoke(macOS) | **29/29**,`meshy-cli-0.3.0.tgz` sha256 `0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e`,398 files | + +## 6. 未修复的观察(供复审判断) + +- **OBS-1**(P3):403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer +- **OBS-2**(P3):downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644 +- **OBS-3**(P3):task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller +- **OBS-4**(P3):download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet') +- **OBS-5**(P3):auth status/list/use ignore --output-schema v1 (legacy shape only) +- **OBS-6**(P3):real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs +- **OBS-7**(P3):text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default + +## 7. 尚未完成 / not_run + +- T-109 Windows x64:无主机。 +- `MESHY_API_KEY` 环境变量 / `--api-key-file` 对真实 API 的路径:仅离线测试覆盖(live 使用了存储的 API key profile)。 +- showcases 内容:账号非 enterprise(403 已记录)。 +- `auth logout/revoke`:留给所有者。 +- 正式发布:未授权、未执行。 + +## 8. 交给 Codex 的复审提示词 + +> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 8 轮 review:复审 live verification 发现的两个修复。代码 HEAD `1d9f10976b5f754502c81591c64c712e492188d1`(上一轮 accepted 代码 `e567646`);docs HEAD 为其后的 docs-only commit。请核对:(1) `src/client/types.ts` 的 null 容忍是否只影响 6 个"未发生"字段、v1 视图输出不变,`tests/live-verification.test.ts` L01 是否用真实捕获体(fixture)覆盖 schema 与 get/wait 轮询;(2) `src/internal/download.ts` + `artifacts.ts` 的命名共享是否保持 legacy slot key、relink 规则与既有测试(round1 R07、N04、C05、D01、E02)不变,L02 是否覆盖 lamp 与 keychain bundle;(3) `docs/skill-parity/live-verification.json` 与 verification.json/capability-matrix.json 的 live 结论是否与证据一致且无凭据/签名 URL 泄露;(4) 前七轮 32 项 finding 在 `1d9f109` 上仍保持通过(副本重跑记录见 §5)。禁止付费调用、发布、Skill/MCP 迁移;Windows 与发布仍为 not_run。 diff --git a/docs/skill-parity/baseline-delta.md b/docs/skill-parity/baseline-delta.md new file mode 100644 index 0000000..952469f --- /dev/null +++ b/docs/skill-parity/baseline-delta.md @@ -0,0 +1,16 @@ +# Baseline delta + +Checked 2026-09-07 before any change: + +- `meshy-dev/meshy-cli` remote `main` = `fd94490916376e691efcea51324ac4326b459e1f`, + identical to the implementation-package baseline. No mapping of plan line references + is needed; the symbols named in IMPLEMENTATION_PLAN.md §2.1 exist as described + (`makeFetcher` in `src/client/index.ts`, `buildResourceCommand` / + `emitTerminalOutcome` in `src/internal/task-command.ts`, `runChain` in + `src/cmd/make.ts`, `enumerateArtifacts` / `downloadArtifact` in + `src/internal/download.ts`, `buildRuntime` cache in `src/internal/runtime.ts`, + `refreshCache()` first in `src/index.ts`). +- `meshy-dev/meshy-3d-agent` remote HEAD = `b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610`, + identical to the package baseline. +- Work happens on branch `feat/skill-parity-s1` in a fresh clone; the user's research + checkout under `meshy-agent-integrations-research/sources/` is untouched. diff --git a/docs/skill-parity/baseline.json b/docs/skill-parity/baseline.json new file mode 100644 index 0000000..8139475 --- /dev/null +++ b/docs/skill-parity/baseline.json @@ -0,0 +1,89 @@ +{ + "schema_version": 1, + "recorded_at": "2026-09-07T06:22:37Z", + "implementation_package": { + "version": "2026-09-07 / v1", + "source": "meshy-cli-implementation (IMPLEMENTATION_PLAN.md, COMMAND_CONTRACTS.md, TEST_PLAN.md, capability-matrix.json)" + }, + "repositories": { + "cli": { + "url": "https://github.com/meshy-dev/meshy-cli", + "plan_baseline_sha": "fd94490916376e691efcea51324ac4326b459e1f", + "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "remote_main_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "delta_from_plan_baseline": "none", + "package_version_at_start": "0.2.0", + "work_branch": "feat/skill-parity-s1", + "workspace": "fresh clone; no pre-existing uncommitted changes", + "repo_instructions": "no AGENTS.md / CLAUDE.md present at baseline" + }, + "skills": { + "url": "https://github.com/meshy-dev/meshy-3d-agent", + "plan_baseline_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610", + "remote_head_at_start": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610", + "delta_from_plan_baseline": "none", + "claude_manifest_version": "0.4.1", + "usage": "read-only capability baseline; not modified" + }, + "meshyd_read_only_crosscheck": { + "commit": "69b7ff5dd29f7a75a3220987985037ecb5c13c2e", + "files_consulted": [ + "pkg/server/server.go (public route registration: every task API group registers POST '', GET '', GET /:id, GET /:id/stream, DELETE /:id; openapi /v1/showcases; /web/public/animations/resources)", + "pkg/server/api_stream_handlers.go (SSE: event 'message' carries task DTO; event 'error' carries {message,status_code}; keep-alive re-emits 'message' every 10s; stream closes after terminal status)", + "pkg/server/internal/httpapi/response.go (APIStreamErrorResponse = {message, status_code})", + "pkg/server/internal/httpapi/dto.go (task DTO: model_urls object keys glb/fbx/gltf/usdz/obj/mtl/vox/blend/stl/3mf/pre_remeshed_glb; thumbnail_urls is an object keyed by view; alpha_thumbnail_url; consumed_credits optional; no face_count field)", + "pkg/server/creativelab/openapi_routes.go + api_creative_lab_{figure,lamp,keychain,fridge_magnet}_handlers.go (request structs)", + "pkg/model/task_creative_lab_{lamp,keychain,fridge_magnet}.go (options/output enums, artifact keys)", + "pkg/server/api.go (ListShowcasesForOpenAPIRequest: format oneof glb fbx usdz obj; showcase_type oneof all animate static; GetShowcaseForOpenAPIResult fields; ListAnimationsResourcesRequest: q/category/subCategory; AnimationResource fields)" + ], + "notes": [ + "internal source copies are not included in this repository", + "production deployment state, account gates and billing were not tested" + ] + } + }, + "official_sources_checked": [ + "https://docs.meshy.ai/en/api/uv-unwrap", + "https://docs.meshy.ai/en/api/creative-lab-figure", + "https://docs.meshy.ai/en/api/creative-lab-lamp", + "https://docs.meshy.ai/en/api/creative-lab-keychain", + "https://docs.meshy.ai/en/api/creative-lab-fridge-magnet", + "https://docs.meshy.ai/en/api/enterprise-api", + "https://docs.meshy.ai/en/api/rigging" + ], + "environment": { + "os": "macOS 26.6 (Darwin 25.6.0) arm64", + "node": "v24.20.0 (installed via fnm; repo .node-version = 24; engines >=24)", + "pnpm": "11.24.0 (corepack, package.json#packageManager)", + "python": "not required by the CLI; not used by any test" + }, + "baseline_checks_before_changes": [ + { "command": "pnpm install --frozen-lockfile", "exit_code": 0, "duration_s": 10, "notes": "prepare script built dist/" }, + { "command": "pnpm typecheck", "exit_code": 0, "duration_s": 0 }, + { "command": "pnpm test", "exit_code": 0, "duration_s": 41, "tests": 363, "pass": 363, "fail": 0, "skipped": 0 }, + { "command": "pnpm build", "exit_code": 0, "duration_s": 1 } + ], + "legacy_script_copies": { + "scripts/src/meshy_task.py": "5c5dc16337580ccd97cee49c657d5395fb20ab9f1b66c8b7c41e364c545e7b2d", + "skills/*/scripts/meshy_task.py (3 identical copies)": "ba436fb36b2412db2f199f39804a0b788dd8c2d39aefd9e7153da2c671158c4d", + "skills/meshy-3d-printing/scripts/fix_obj.py": "2fa2ac5a7c26856f2818e23791ef5b82501411a94c82e7448e1253abc330be1c", + "skills/meshy-openclaw/scripts/fix_obj.py": "50e9fc00a83bc85067955fef2c61d94a8163e0002012961fbae9bad1e0cf1b35", + "skills/meshy-3d-printing/scripts/slicers.py": "343934ac49b43183bce364be86a801e03ecf40e4b722156e0da8d322cd18388d", + "skills/meshy-openclaw/scripts/slicers.py": "fe28032870de5a7de134278e8a9a39d3a7997e9bcc1ff2db9abc2a1c4f261683", + "note": "the distributed copies differ from scripts/src only by the generated header comment; no extra executable capability was found in any copy" + }, + "fixtures": { + "tests/fixtures/skill-parity/box-y-up.obj": "beb379900c2c9a204db4a2cbeadd536292f30ac70ce849adc0762c9a88b5949b", + "tests/fixtures/skill-parity/box.mtl": "a11dcb465d7f74b03c4c007a5fbcba08233a392d4d526a82e2b513412aa2a556", + "tests/fixtures/skill-parity/box-height-80.expected.json": "f887f40c18d068145bbc1ffb03fc5c42f73f056ecf30b622a65a01edd7f2ed5d", + "tests/fixtures/skill-parity/task-rigging.synthetic.json": "8dca5b6abed0ae161d2cb0140d68d4c38341cfa48841ecdabd001427b77d039e", + "tests/fixtures/skill-parity/task-error.synthetic.sse": "b9542722988c854626a3007a0d7ec771a308a54945331f97788036f687c95300", + "origin": "synthetic fixtures copied from the implementation package; none are live API records" + }, + "environment_limits": [ + "no Meshy API credential is configured in this session; live API checks are not_run", + "only macOS arm64 is available; Windows/Linux slicer detection is covered by platform fixtures only", + "no slicer application is verified on this host", + "Enterprise showcases, UV Unwrap and Creative Lab live calls are billable/gated and are not_run" + ] +} diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json new file mode 100644 index 0000000..b19a037 --- /dev/null +++ b/docs/skill-parity/capability-matrix.json @@ -0,0 +1,2181 @@ +{ + "schema_version": 1, + "title": "Meshy CLI S1 capability matrix — implementation tracking", + "date": "2026-09-08", + "status": "G1-code accepted (Codex round 7 on e567646, round 8 on 1d9f109 — live fixes L01/L02 closed, 0 findings); live verification on the owner's real account done 2026-09-08 — every mandatory capability exercised live except Windows install (not_run, skipped by the owner's decision) and enterprise showcases content (account-gated 403 recorded); release authorised 2026-09-08, publish via release.yml after the PR merge → G1-release recorded once the publish is verified", + "baseline": { + "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", + "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" + }, + "completion_rule": "All mandatory capabilities need implementation and evidence for G1-code. G1-release additionally requires review, applicable live/OS checks and a released pinned version. Not-run is not pass.", + "capabilities": [ + { + "id": "CAP-001", + "title": "任务创建与异步单次提交", + "area": "create", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "legacy scripts/src/meshy_task.py:create_task/_cmd_create", + "target": "meshy create --async", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-040", + "T-043", + "T-044", + "T-045" + ], + "evidence": [ + "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044", + "tests/codex-review-round1.test.ts R05/F01, R06/F01", + "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)", + "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)", + "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F01" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R05/F01, R06/F01" + ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)" + ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)" + ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-02", + "T110-06", + "T110-13", + "T111-09", + "T111-13" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-002", + "title": "完整任务查询与字段", + "area": "get", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "meshy_task.py:_cmd_get", + "target": "meshy get --include-raw --save-json", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-006", + "T-008", + "T-010" + ], + "evidence": [ + "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010", + "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)", + "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)", + "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)", + "tests/codex-review-round5.test.ts E03 (get)", + "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F01", + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F01", + "R3-F02", + "R3-F04", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)" + ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (get)" + ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "T110-12", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-003", + "title": "有界等待与恢复", + "area": "wait", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "meshy_task.py:poll_task/_cmd_poll", + "target": "meshy wait", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-007", + "T-047", + "T-048" + ], + "evidence": [ + "poll.ts monotonic deadline, cancellable sleep, --timeout 0 single query; tests T-007/T-047/T-048", + "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", + "tests/poll.test.ts deadline tests", + "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", + "tests/poll.test.ts deterministic deadline tests", + "tests/codex-review-round3.test.ts C06 (legacy wait -o)", + "tests/codex-review-round5.test.ts E03 (wait)", + "tests/codex-review-round6.test.ts (wait)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F01", + "F07" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", + "tests/poll.test.ts deadline tests" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F04", + "R2-F05", + "R2-F07" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", + "tests/poll.test.ts deterministic deadline tests" + ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy wait -o)" + ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (wait)" + ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (wait)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "T110-08", + "T111-03", + "T111-04", + "T111-05", + "T111-11", + "T111-12" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-004", + "title": "SSE 状态流", + "area": "stream", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "skills/*/reference.md:SSE examples", + "target": "meshy stream", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-049", + "T-050", + "T-051", + "T-052", + "T-053" + ], + "evidence": [ + "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s", + "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053", + "tests/codex-review-round1.test.ts R10/F10", + "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)", + "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)", + "tests/codex-review-round5.test.ts E03 (stream)", + "tests/codex-review-round6.test.ts (stream)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F10" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R10/F10" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F03", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)" + ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (stream)" + ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (stream)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-03", + "T110-07", + "T111-12" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-005", + "title": "余额查询、列表与删除", + "area": "list/delete", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "meshy_task.py:balance; Skill REST list/delete examples", + "target": "meshy balance; meshy list|delete", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-001", + "T-011", + "T-020", + "T-103" + ], + "evidence": [ + "balance/list/delete v1 + legacy; rigging list enabled per registry; tests cli-contract T-001, T-011" + ], + "intentional_differences": [ + "rigging list enabled (0.2.0 marked it unsupported; official docs and server routes provide it)" + ], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-01", + "T110-14", + "T110-15" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-006", + "title": "旧工作流模型参数和默认值对齐", + "area": "payload", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "skills/*/references/pipelines.md", + "target": "existing commands with explicit supported payload", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-028", + "T-033", + "T-034" + ], + "evidence": [ + "payload merge defaults < --data < flags with explicit false/0 preserved; tests T-028; surface.test.ts unchanged", + "tests/codex-review-round1.test.ts R04/F08" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-06", + "T111-04", + "T111-05", + "T111-08" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-007", + "title": "本地媒体/URL/data URI", + "area": "media", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "pipelines.md:base64 Python snippets", + "target": "typed media flags and final payload normalization", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-029", + "T-030", + "T-031" + ], + "evidence": [ + "file-input.ts normalizeMediaPayload on merged payload: URL preflight (no auth), data URI, local file; tests T-029/T-030", + "tests/codex-review-round1.test.ts R02/F06", + "tests/operation-store.test.ts F06" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F06" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R02/F06", + "tests/operation-store.test.ts F06" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-11", + "T110-13", + "T104-07" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-008", + "title": "UV Unwrap", + "area": "uv-unwrap", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:UV Unwrap API", + "target": "meshy uv-unwrap create/get/list/wait/stream/delete", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-021", + "T-020" + ], + "evidence": [ + "docs.meshy.ai/en/api/uv-unwrap (input_task_id|model_url, GLB only, 40k faces, Statsig-gated 404)", + "meshyd server.go: /openapi/v1/uv-unwrap registers POST, GET list, GET :id, GET :id/stream, DELETE :id", + "src/cmd/uv-unwrap.ts: exclusive source, GLB-only, 404 reported as not_found; tests T-021" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-03" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-009", + "title": "Creative Lab Figure", + "area": "creative-lab.figure", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab figure prototype|build ", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-figure (prototype image_urls; build glb/obj/mtl + texture base_color; webapp prototypes 404)", + "src/cmd/creative-lab.ts figure prototype/build; tests T-022..T-025" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-21" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-010", + "title": "Creative Lab Lamp", + "area": "creative-lab.lamp", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab lamp prototype|build ", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-lamp (image_subject; prototype model_urls.glb; build options/output stl|zip; lamp_stl/base_stl/bundle_zip)", + "meshyd pkg/model/task_creative_lab_lamp.go artifact keys and enums", + "src/cmd/creative-lab.ts lamp: image_subject, text rejected, options/output stl|zip, include_result_json rule; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (lamp)" + ], + "intentional_differences": [ + "deprecated lamp `text` prototype input is rejected before submission; official lamp page documents image_url" + ], + "external_verification": "passed_after_live_fixes", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (lamp)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_after_live_fixes", + "evidence": [ + "T111-14", + "T111-15", + "T111-17", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-011", + "title": "Creative Lab Keychain", + "area": "creative-lab.keychain", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab keychain prototype|build ", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-keychain (relief options; output glb|obj|zip; obj is a ZIP bundle)", + "meshyd pkg/model/task_creative_lab_keychain.go", + "src/cmd/creative-lab.ts keychain relief options, output glb|obj|zip; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (keychain)" + ], + "intentional_differences": [], + "external_verification": "passed_after_live_fixes", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (keychain)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_after_live_fixes", + "evidence": [ + "T111-16", + "T111-19", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-012", + "title": "Creative Lab Fridge Magnet", + "area": "creative-lab.fridge-magnet", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab fridge-magnet prototype|build ", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-fridge-magnet (own defaults: rounded-rect, 60 mm, 3.3 mm relief, 2.0 mm base)", + "meshyd pkg/model/task_creative_lab_fridge_magnet.go", + "src/cmd/creative-lab.ts fridge-magnet relief options; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-22" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-013", + "title": "动画目录", + "area": "animation-catalog", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "pipelines.md:/web/public/animations/resources", + "target": "meshy animation-catalog list", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-026" + ], + "evidence": [ + "Skill pipelines.md: GET /web/public/animations/resources?category=…; response {result:{total,list}}", + "meshyd api.go AnimationResource + ListAnimationsResourcesRequest (q, category, subCategory)", + "src/cmd/animation-catalog.ts: public transport, no credential, local search; tests T-026" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-014", + "title": "Enterprise showcases", + "area": "showcases", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Enterprise API", + "target": "meshy showcases list", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-027", + "T-111" + ], + "evidence": [ + "docs.meshy.ai/en/api/enterprise-api (1 credit per request; page_size 1-10; sort_by; format; showcase_type)", + "meshyd api.go ListShowcasesForOpenAPIRequest: showcase_type oneof all animate static (docs say animated)", + "src/cmd/showcases.ts: single billable GET, alias warning, no retry; tests T-027" + ], + "intentional_differences": [ + "showcase_type accepts docs spelling \"animated\" and sends the server enum \"animate\" with a warning (decisions D-007)" + ], + "external_verification": "live_account_gated_403", + "live_verification": { + "date": "2026-09-08", + "status": "account_gated_403_recorded", + "evidence": [ + "T111-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-015", + "title": "完整资产枚举", + "area": "artifacts", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "pipelines.md:rig/basic_animations/thumbnail", + "target": "download asset registry with role and dependencies", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-060", + "T-062", + "T-071" + ], + "evidence": [ + "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071", + "tests/codex-review-round1.test.ts R07/F09", + "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)", + "tests/codex-review-round3.test.ts C05 (source identity vs generated names)", + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases", + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09" + ] + }, + "review_round_2": { + "findings": [ + "R2-F02" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F03" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C05 (source identity vs generated names)" + ] + }, + "review_round_4": { + "findings": [ + "R4-F01" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + ] + }, + "review_round_5": { + "findings": [ + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-05", + "T111-18", + "T111-19" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-016", + "title": "选择性下载与缩略图", + "area": "selection", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:_cmd_download/_cmd_thumbnail", + "target": "meshy download --asset/--kind/--model-format", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-061", + "T-063" + ], + "evidence": [ + "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063", + "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)", + "tests/codex-review-round2.test.ts N04", + "tests/codex-review-round3.test.ts C05", + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases", + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix", + "tests/codex-review-round6.test.ts (download × task-json/API × leaf/parent symlink → exit 11, manifest kept, project.failed, outside tree byte-identical)" + ], + "intentional_differences": [ + "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" + ], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)" + ] + }, + "review_round_2": { + "findings": [ + "R2-F02" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N04" + ] + }, + "review_round_3": { + "findings": [ + "R3-F03" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C05" + ] + }, + "review_round_4": { + "findings": [ + "R4-F01" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + ] + }, + "review_round_5": { + "findings": [ + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + ] + }, + "review_round_6": { + "findings": [ + "R6-F01" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (download × task-json/API × leaf/parent symlink → exit 11, manifest kept, project.failed, outside tree byte-identical)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-09", + "T110-10", + "T111-18" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-017", + "title": "下载最终路径与中断恢复", + "area": "download", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:download; CLI existing download.ts", + "target": "safe download with actual final-path manifest", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-064", + "T-065", + "T-066", + "T-067", + "T-068", + "T-069", + "T-070" + ], + "evidence": [ + "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070", + "tests/codex-review-round1.test.ts R07/F09, R09/F03", + "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08", + "tests/codex-review-round3.test.ts C01+C02, C03, C05", + "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight", + "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02", + "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F03", + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09, R09/F03" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F02", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08" + ] + }, + "review_round_3": { + "findings": [ + "R3-F01", + "R3-F03", + "R3-F04", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C01+C02, C03, C05" + ] + }, + "review_round_4": { + "findings": [ + "R4-F01", + "R4-F02" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight" + ] + }, + "review_round_5": { + "findings": [ + "R5-F01", + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02" + ] + }, + "review_round_6": { + "findings": [ + "R6-F01", + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-08", + "T110-09", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-018", + "title": "完整 JSON 保存", + "area": "save-json", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:_cmd_get/_cmd_poll", + "target": "--save-json and project task snapshots", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-010", + "T-063" + ], + "evidence": [ + "--save-json on task verbs/list/balance/api/catalog/showcases (raw JSON, never the envelope); project task_.json snapshots; tests T-010, T-063, project-store tests", + "tests/codex-review-round1.test.ts R05/F01", + "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F01" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R05/F01" + ] + }, + "review_round_2": { + "findings": [ + "R2-F03" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-02", + "T104-08", + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-019", + "title": "项目初始化", + "area": "project.init", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:get_project_dir", + "target": "meshy project init", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-072", + "T-077" + ], + "evidence": [ + "src/internal/project-store.ts initProject + src/cmd/project.ts init; tests/project-store.test.ts T-072/T-077", + "tests/codex-review-round1.test.ts R08/F03" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-020", + "title": "任务历史记录", + "area": "project.record", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:record_task", + "target": "meshy project record/show", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "rounds 1–6 findings fixed; re-review pending", + "test_ids": [ + "T-073", + "T-074", + "T-075" + ], + "evidence": [ + "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075", + "tests/codex-review-round1.test.ts R08/F03", + "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)", + "tests/codex-review-round3.test.ts C04 (aliased project records files)", + "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery", + "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay", + "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F06" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C04 (aliased project records files)" + ] + }, + "review_round_4": { + "findings": [ + "R4-F02" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery" + ] + }, + "review_round_5": { + "findings": [ + "R5-F01", + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay" + ] + }, + "review_round_6": { + "findings": [ + "R6-F01", + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-021", + "title": "项目索引与恢复", + "area": "project.index", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:record_task history.json", + "target": "meshy project list/rebuild-index", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-073", + "T-075", + "T-076", + "T-077" + ], + "evidence": [ + "project list (index vs folders, index_dirty) and rebuild-index (backup, symlink/escape skipped); tests T-073/T-075/T-076/T-077", + "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)", + "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)" + ] + }, + "review_round_2": { + "findings": [ + "R2-F01" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-022", + "title": "面数检查", + "area": "inspect.faces", + "mandatory": true, + "phase": "S1", + "task": "C05", + "source_hint": "meshy_task.py:_cmd_check_faces", + "target": "meshy inspect faces", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-080", + "T-081", + "T-082" + ], + "evidence": [ + "meshyd httpapi/dto.go: public task DTO has no face_count → unknown semantics required", + "src/internal/inspect.ts judgeFaceCount/judgeTask + src/cmd/inspect.ts (--max-faces required; unknown exit 13; fail exit 12; remesh only described); tests/inspect.test.ts T-080..T-082" + ], + "intentional_differences": [ + "missing face_count is unknown (exit 13), never 0 (legacy check-faces passed unmeasured models); --max-faces has no default" + ], + "external_verification": "passed_check_unknown_on_real_tasks", + "live_verification": { + "date": "2026-09-08", + "status": "passed_check_unknown_on_real_tasks", + "evidence": [ + "LOCAL-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-023", + "title": "OBJ 打印变换", + "area": "mesh.prepare-print", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "fix_obj.py:fix_obj_for_printing", + "target": "meshy mesh prepare-print", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-083", + "T-084", + "T-085", + "T-086", + "T-087" + ], + "evidence": [ + "src/internal/obj-transform.ts two-pass streaming transform + src/cmd/mesh.ts prepare-print; fixture oracle box-height-80.expected.json; tests/obj-transform.test.ts T-083..T-087", + "tests/codex-review-round1.test.ts R12/F04", + "tests/obj-transform.test.ts T-086 (planned paths reported)" + ], + "intentional_differences": [ + "default output is .print.obj, in-place only with --in-place; NaN/Infinity/degenerate input is a validation error instead of silent scale 1.0 output" + ], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F04" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R12/F04", + "tests/obj-transform.test.ts T-086 (planned paths reported)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-024", + "title": "七种切片器检测", + "area": "slicer.detect", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "slicers.py:detect_slicers/SLICER_MAP", + "target": "meshy slicer detect", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-088", + "T-091" + ], + "evidence": [ + "src/internal/slicers.ts detectSlicers (darwin/win32/linux rules, seven registered, multicolor flag) + src/cmd/slicer.ts detect; tests/slicers.test.ts T-088" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-025", + "title": "切片器启动", + "area": "slicer.open", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "slicers.py:open_in_slicer", + "target": "meshy slicer open", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-089", + "T-090", + "T-091", + "T-112" + ], + "evidence": [ + "src/internal/slicers.ts openInSlicer (detected path only, shell:false, single argv, no default-app fallback) + slicer open; tests T-089..T-091" + ], + "intentional_differences": [ + "no default-application fallback (legacy used os.startfile / xdg-open); Windows launches the detected absolute exe, not a PATH lookup" + ], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01", + "T112-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-026", + "title": "环境诊断", + "area": "doctor", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "meshy_task.py:_cmd_check_env", + "target": "meshy doctor", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-102", + "T-103" + ], + "evidence": [ + "src/internal/doctor.ts runDoctorDetailed + src/cmd/doctor.ts (local default; --check-api one GET /balance; --check-slicers); tests/doctor.test.ts T-102/T-103" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "smoke doctor checks (macOS + Linux)" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-027", + "title": "env-file 与凭据优先级", + "area": "config", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "meshy_task.py:load_api_key", + "target": "--env-file without automatic dotenv/shell scanning", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-100", + "T-101" + ], + "evidence": [ + "config.ts + env-file.ts (--api-key-file); tests env-file.test.ts, T-100", + "tests/codex-review-round1.test.ts R01/F05 (API key digest)" + ], + "intentional_differences": [ + "flag is --api-key-file, not --env-file: Node.js intercepts --env-file anywhere in argv (decisions D-025)" + ], + "external_verification": "passed_stored_profiles", + "review_round_1": { + "findings": [ + "F05" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05 (API key digest)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_stored_profiles", + "evidence": [ + "T104-01", + "T104-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-028", + "title": "现有 Key/OAuth 登录兼容", + "area": "auth", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "CLI auth/config/credentials/runtime", + "target": "preserve existing auth commands and token protocol", + "baseline_status": "existing_needs_regression", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-104", + "T-105" + ], + "evidence": [ + "auth commands untouched; runtime refresh path unchanged; tests/auth-headless.test.ts, oauth.test.ts, device*.test.ts, credentials.test.ts, runtime.test.ts still pass (regression)", + "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)", + "tests/codex-review-round2.test.ts N07 (login_id binding, refresh keeps it, unverified refused)", + "tests/auth-headless.test.ts (device login mints login_id)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F05" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)" + ] + }, + "review_round_2": { + "findings": [ + "R2-F06" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N07 (login_id binding, refresh keeps it, unverified refused)", + "tests/auth-headless.test.ts (device login mints login_id)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-01", + "T104-03", + "T104-04", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-029", + "title": "稳定 JSON 与退出码", + "area": "output", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "CLI task-command/output/errors", + "target": "--output-schema v1; legacy compatibility", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-001", + "T-002", + "T-003", + "T-004", + "T-005", + "T-011" + ], + "evidence": [ + "result.ts envelope, errors.ts codes/exits, Commander errors → exit 2; tests result.test.ts, cli-contract T-002..T-005", + "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)", + "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_2": { + "findings": [ + "R2-F03", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F02", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-01", + "T110-11", + "T110-15", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-030", + "title": "make async 语义", + "area": "make", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "CLI cmd/make.ts:runChain", + "target": "make --async / --stop-after-first", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-041", + "T-042" + ], + "evidence": [ + "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042", + "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o", + "tests/codex-review-round2.test.ts N06 (make -o partial manifest)", + "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)", + "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F01", + "F02", + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o" + ] + }, + "review_round_2": { + "findings": [ + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N06 (make -o partial manifest)" + ] + }, + "review_round_3": { + "findings": [ + "R3-F02", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)" + ] + }, + "review_round_4": { + "findings": [ + "R4-T01" + ], + "status": "test_gap_closed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-13" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-031", + "title": "本地 operation 恢复记录", + "area": "operations", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "old workflows persist task ids; new recovery requirement", + "target": "operation journal; never claim server idempotency", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", + "test_ids": [ + "T-043", + "T-044", + "T-045", + "T-046", + "T-048" + ], + "evidence": [ + "operation-store.ts journal with lock, replay and conflict; tests operation-store.test.ts, T-046", + "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", + "tests/operation-store.test.ts (barrier race, F05, F06)", + "tests/codex-review-round2.test.ts N07, credentialBinding unit test" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "review_round_1": { + "findings": [ + "F05", + "F06" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", + "tests/operation-store.test.ts (barrier race, F05, F06)" + ] + }, + "review_round_2": { + "findings": [ + "R2-F06" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N07, credentialBinding unit test" + ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-05", + "T104-06", + "T104-07" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-032", + "title": "认证 transport 与公共 fetch 隔离", + "area": "transport", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "CLI client/index.ts and file-input/download", + "target": "explicit origin and no auth on public/assets", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-031", + "T-032", + "T-105", + "T-106" + ], + "evidence": [ + "transport.ts path validation, redirect refusal, public transport; config.ts origin policy; tests transport.test.ts" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-08", + "T111-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-033", + "title": "无 Key/无网络本地运行", + "area": "local-runtime", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "CLI runtime.ts/index.ts plus local Skill helpers", + "target": "local runtime; update-check control", + "baseline_status": "missing", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-012", + "T-072", + "T-102" + ], + "evidence": [ + "runtime.ts buildLocalRuntime; index.ts update-check policy; tests T-102, T-012" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01", + "LOCAL-01", + "LOCAL-02" + ], + "record": "docs/skill-parity/live-verification.json" + } + }, + { + "id": "CAP-034", + "title": "打包与无 Python 安装", + "area": "packaging", + "mandatory": true, + "phase": "S1", + "task": "C08", + "source_hint": "CLI package.json/bin and Skill install metadata", + "target": "candidate tarball; both bins; Node24; no Python", + "baseline_status": "existing_needs_regression", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-107", + "T-108", + "T-109" + ], + "evidence": [ + "npm pack → meshy-cli-0.3.0.tgz sha256 8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367; installed into a temp prefix; both bins run; no Python; see verification.json package_smoke" + ], + "intentional_differences": [], + "external_verification": "live_partial_windows_not_run", + "live_verification": { + "date": "2026-09-08", + "status": "partial_windows_not_run", + "evidence": [ + "T-109 linux arm64/x64 29/29", + "macOS 29/29" + ], + "record": "docs/skill-parity/live-verification.json", + "note": "Windows x64 not_run: skipped for this release by the owner's decision (2026-09-08); macOS arm64 and Linux arm64/x64 passed" + } + }, + { + "id": "CAP-035", + "title": "帮助与能力发现", + "area": "discovery", + "mandatory": true, + "phase": "S1", + "task": "C08", + "source_hint": "CLI root/resources; CLI bundled skill", + "target": "registry-driven help and capability/version metadata", + "baseline_status": "partial", + "implementation_status": "implemented", + "review_status": "not_run", + "test_ids": [ + "T-020", + "T-108" + ], + "evidence": [ + "resources index generated from resource-registry.ts (kind task|query|local, verbs, endpoints); root/verb help updated; skills/meshy-cli/SKILL.md rewritten; tests/resource-registry.test.ts, smoke resources_v1" + ], + "intentional_differences": [], + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "smoke help/resources checks" + ], + "record": "docs/skill-parity/live-verification.json" + } + } + ], + "knowledge_only": [ + { + "id": "KN-001", + "title": "Webhook 网页配置和领域流程知识", + "reason": "No executable webhook management API in the frozen Skill baseline; keep documentation, do not implement a daemon." + } + ], + "deferred_new_scope": [ + { + "id": "DF-001", + "title": "基线 Skill 未使用的新 Creative Lab 产品、Usage API、托管 Agent/MCP/A2A", + "reason": "Do not expand current parity scope based solely on newer server/docs discovery." + } + ], + "implementation": { + "repo": "https://github.com/meshy-dev/meshy-cli", + "branch": "feat/skill-parity-s1", + "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "package_version_at_start": "0.2.0", + "updated_at": "2026-09-08T08:34:23.922875Z", + "code_head": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "review_rounds": [ + { + "round": 1, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-6273d9a", + "reviewed_head": "6273d9aa6ef396cf1cc26838e2c0d09ab176f595", + "verdict": "changes_requested", + "findings": [ + "F01", + "F02", + "F03", + "F04", + "F05", + "F06", + "F07", + "F08", + "F09", + "F10" + ], + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "regression_tests": "tests/codex-review-round1.test.ts (R01–R12 as positive assertions) + tests/operation-store.test.ts + tests/poll.test.ts", + "probe_rerun": "reviews/cli-s1-6273d9a/reproduce.mjs on 0388fe8: 0/12 defects reproduce" + }, + { + "round": 2, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-730132b", + "reviewed_head": "730132bd99a471ed41d6bbf6b200219f13f569ba", + "verdict": "changes_requested", + "findings": [ + "R2-F01", + "R2-F02", + "R2-F03", + "R2-F04", + "R2-F05", + "R2-F06", + "R2-F07" + ], + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "regression_tests": "tests/codex-review-round2.test.ts (N01–N08 as positive assertions) + tests/poll.test.ts (deterministic clock) + tests/auth-headless.test.ts (login_id)", + "probe_rerun": "reviews/cli-s1-730132b/round2-probes.mjs on cf8905d: 0/8 defects reproduce; round1-probes.mjs: 0/12" + }, + { + "round": 3, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-cf8905d", + "reviewed_head": "566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a", + "verdict": "changes_requested", + "findings": [ + "R3-F01", + "R3-F02", + "R3-F03", + "R3-F04", + "R3-F05", + "R3-F06" + ], + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "regression_tests": "tests/codex-review-round3.test.ts (C01–C06 as positive assertions, relink abort unit tests, make interrupt)", + "probe_rerun": "reviews/cli-s1-cf8905d/round3-probes.mjs on 235d6de: 0/6 defects reproduce; round2 0/8; round1 0/12; reviewer's verify-original-regressions.py 20/20" + }, + { + "round": 4, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-235d6de", + "reviewed_head": "9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4", + "verdict": "changes_requested", + "findings": [ + "R4-F01", + "R4-F02", + "R4-T01 (test gap)" + ], + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "regression_tests": "tests/codex-review-round4.test.ts (D01 both orders + arbitration cases, D02 three failure modes + recovery replay, preflight, R4-T01 make identity); C06 keeps create/wait/get; R03 asserts D-044 invariants", + "probe_rerun": "reviews/cli-s1-235d6de/round4-probes.mjs copy on 68690f9: D01/D02 no longer reproduce, D03 positive (signal_sent=true, exit 130); round3 0/6 (fs.watch C03 trigger missed, not counted), round2 0/8, round1 0/12; verify-original-regressions.py 20/20; round4-context-checks 5/5; stream check passed" + }, + { + "round": 5, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-68690f9", + "reviewed_head": "e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59", + "verdict": "changes_requested", + "findings": [ + "R5-F01", + "R5-F02", + "R5-F03" + ], + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "regression_tests": "tests/codex-review-round5.test.ts (E01 verbatim replay with --workspace, E02 both orders + 8x2 matrix, E03 20-entry matrix + verbatim replay, escaped-project boundary); round4 D02 replay verbatim; R4-T01 SIGINT manifest exact", + "probe_rerun": "reviews/cli-s1-68690f9 copies on 7b7c24c: round5 E01/E02x2/E03 no longer reproduce; material matrix 16/16; project entries 20/20 record_recovery; round4 0/3 (D03 positive), round3 0/6, round2 0/8, round1 0/12; verify-original-regressions 20/20; verify-round3-4 8/8; context 5/5; stream passed" + }, + { + "round": 6, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-7b7c24c", + "reviewed_head": "166492ba9691f093b302c1c114e782bceee14a1d", + "verdict": "changes_requested", + "findings": [ + "R6-F01", + "R6-F02", + "R6-T01 (test gap)" + ], + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "regression_tests": "tests/codex-review-round6.test.ts (R6-F01 4 download scenarios + control; R6-F02 20-entry mutable-workspace matrix + stable-alias control); codex-review-round5 E03 exact sequences and E02 line-bound candidates (R6-T01)", + "probe_rerun": "reviews/cli-s1-7b7c24c copies on e567646: round6 0/4 reproduce (all exit 11, outside bytes unchanged); round5 0/4; round4 0/3 (D03 positive); round3 0/6; round2 0/8; round1 0/12; verify-original 20/20; verify-round3-4 8/8; verify-round5-positive 24/24; material matrix 16/16; round6 material 10/10; project entries 20/20; context 5/5; stream passed" + }, + { + "round": 7, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-e567646", + "reviewed_head": "4da216d3568fbd997bf85f8047ce3932672a25de", + "reviewed_code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "verdict": "accepted", + "findings": [], + "note": "R6-F01/R6-F02/R6-T01 closed; 32 findings from rounds 1-6 not regressed; G1-code accepted" + }, + { + "round": 8, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-1d9f109", + "reviewed_head": "c70b06cc3509b6e36b18a51783b368d816c964b4", + "reviewed_code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "verdict": "accepted", + "findings": [], + "note": "live fixes L01/L02 closed; 32 prior findings not regressed on 1d9f109; 255 prior evidence files unchanged; OBS-1..7 judged non-blocking P3 observations; G1-code continues accepted" + } + ], + "live_verification_run": { + "date": "2026-09-08", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "record": "docs/skill-parity/live-verification.json", + "fixes": [ + { + "id": "L01", + "decision": "D-059", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "Creative Lab in-progress bodies carry finished_at: null; schema now reads null timestamps/counts as 0; verified live afterwards" + }, + { + "id": "L02", + "decision": "D-060", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "legacy -o names Creative Lab parts/bundles via the shared modelAsset mapping (lamp.stl, base.stl, model.obj.zip); verified live afterwards" + } + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ], + "reviewer_script_reruns_on_this_head": { + "round1": 0, + "round2": 0, + "round3": 0, + "round4": 0, + "round5": 0, + "round6": 0, + "verify_original": "20/20", + "verify_round3_4": "8/8", + "verify_round5": "24/24", + "verify_round6": "5/5", + "material_matrix": "16/16", + "round6_material": "10/10", + "project_entries": "20/20", + "context": "5/5", + "stream": true + } + }, + "release": { + "authorised_by_owner_on": "2026-09-08", + "integration": "feat/skill-parity-s1 → main via pull request (owner's decision); merge commit, as for the previous releases", + "mechanism": ".github/workflows/release.yml (workflow_dispatch, run on main after the merge) publishes meshy-cli and the scoped alias @meshy-ai/cli at package.json version 0.3.0 with the repository's NPM_TOKEN; no local npm publish", + "windows_x64": "not_run — the owner decided on 2026-09-08 to skip it for this release (no host)", + "status": "pending — recorded with the published version, tag and registry digest in a docs commit after the publish is verified" + } +} diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md new file mode 100644 index 0000000..9b7c993 --- /dev/null +++ b/docs/skill-parity/decisions.md @@ -0,0 +1,736 @@ +# Skill-parity S1 — design decisions + +Each entry records a choice that is not derivable from the code alone, the evidence +behind it, and what a reviewer should check. IDs are stable; append, do not renumber. + +## D-001 Workspace and toolchain + +- Fresh clone of `meshy-dev/meshy-cli` at `fd94490` (main == plan baseline, no delta), + branch `feat/skill-parity-s1`. Skills baseline `b9db44b` is read-only. +- Node 24.20.0 via fnm (`.node-version` = 24, `engines >=24`); pnpm 11.24.0 via corepack + from `package.json#packageManager`. Baseline `pnpm test` = 363 pass / 0 fail before any change. +- No AGENTS.md / CLAUDE.md exist in the repo; the existing conventions (TypeScript ESM, + Commander, Zod, node:test + tsx, no lint script) are followed. + +## D-002 `--output-schema v1` is a root-parsed global flag + +- Commander parses options declared on the root command wherever they appear in argv + (positional options are disabled), so `meshy text-to-3d get ID --output-schema v1` + and `meshy --output-schema v1 text-to-3d get ID` resolve identically. Verified + empirically on 0.2.0 (`resources --format pretty` renders pretty). +- Existing commands default to `legacy`; new commands (uv-unwrap, creative-lab, + animation-catalog, showcases, download, project, inspect, mesh, slicer, doctor) + always emit v1. Passing `--output-schema legacy` to a v1-only command is a usage + error (exit 2) rather than a silent no-op. +- `--json` keeps its existing priority over `--format`. + +## D-003 Commander parse errors are usage errors (exit 2) + +- Unknown options/commands, missing arguments and invalid choices are routed through + `exitOverride()` into the unified error exit: exit 2, error payload on stdout + (legacy shape or v1 envelope depending on the resolved schema), human line on stderr. +- 0.2.0 exited 1 for these while README already documented `2 usage`; this is a + documented bug fix, listed in migration-notes. +- `--help` / `--version` remain plain text with exit 0. + +## D-004 `get` never fails on task status + +- `get` returning a valid task exits 0 in both schemas (0.2.0 exited 1 for + PENDING/IN_PROGRESS — the probe in the implementation package reproduces it). +- Legacy `get` of a FAILED/CANCELED task keeps exit 1 (existing consumers may rely on + it). v1 `get` is a query: `ok:true`, exit 0, `task.status` carries the server state. +- `wait`/`stream`/sync `create` ending in FAILED/CANCELED exit 1 with the full task in + `result.task`. + +## D-005 `make --async` returns after the first POST + +- `--async` submits step 1 and returns `accepted` + `pending_steps`; zero polling. +- The previous behaviour (poll step 1, then stop) is available as `--stop-after-first`. + Both flags together are a usage error before any request. +- Default sync behaviour is unchanged (text: preview → refine; image: textured). + +## D-006 Registry-driven resources; `rigging list` enabled + +- `src/client/resource-registry.ts` is the single source for command paths, API + base, relative path, supported verbs, billing and media fields. `docs/skill-parity/ + endpoint-contracts.json` documents the same data and a test asserts they agree. +- 0.2.0 marked `rigging list` unsupported. The official rigging page lists + `GET /openapi/v1/rigging` and the server route table registers it, so `list` is + enabled. Recorded as an intentional difference from 0.2.0, not from the Skills. + +## D-007 `showcases --showcase-type animated` is sent as `animate` + +- The public docs and the Skill reference say `all | animated | static`; the server + binding (checked read-only, meshyd `ListShowcasesForOpenAPIRequest`) accepts + `all | animate | static` and would reject `animated` with 400. +- The CLI accepts both spellings, sends `animate`, and adds a warning + `showcase_type_alias` so the translation is visible. Live verification is not_run. + +## D-008 Animation catalog search is local + +- The frozen Skill baseline only uses `?category=`. The server also accepts `q` and + `subCategory`, but those are outside the baseline and are not exposed in S1. +- `--search` filters the fetched batch case-insensitively over `name`, `key` and + `subCategory`; the result says `search_scope: "local"` so nobody mistakes it for a + server-side search. No pagination exists on this endpoint (`total` == list length). + +## D-009 `face_count` is unknown unless the server sends it + +- The public task DTO has no `face_count` field (meshyd `httpapi/dto.go`, read-only + check). `inspect faces --resource … --task-id …` therefore usually yields + `verdict: unknown`, exit 13. The legacy `check-faces` defaulted a missing value to 0 + and printed a passing line for a model it never measured; that is not reproduced. +- `--task-json` files that carry `face_count` (e.g. saved tasks from a future server + version, or fixtures) produce pass/fail normally. + +## D-010 Operation journal + +- Location: `/operations/.json`, + private mode, written atomically under a lock (`operations/locks/`). Tests inject a + root through the existing `MESHY_CONFIG_DIR` variable — no new env var. +- States: `started` → `accepted | rejected | unknown | not_submitted`. Records hold + resource, endpoint, API origin, credential fingerprint (sha256 of profile/origin, + never the key), payload fingerprint (sha256 of canonical JSON with media data URIs + replaced by their length), timestamps and, when known, task id / request id. +- A second invocation with the same `--operation-id` and identical fingerprints returns + the stored record without a new POST; a mismatch is `operation_conflict` (exit 2). + This is a local record only and is never described as server idempotency. + +## D-011 Exclusive file publish + +- No-overwrite writes go to a temp file in the target directory, then + `fs.linkSync(tmp, target)` (atomic EEXIST failure on POSIX and NTFS) followed by + unlinking the temp. Where hard links are unsupported (EPERM/ENOTSUP/EXDEV) the + fallback opens the target with `wx` and copies — still exclusive, not atomic, and + reported in `warnings`. +- `--overwrite` uses `rename` over a target that `lstat` reports as a regular file; + directories and symlinks are never replaced. + +## D-012 Multi-file downloads are per-file atomic with a manifest + +- Every asset is published individually; the envelope lists each file with + `status: written | skipped | failed`. A failure after some files were written + returns `ok:false` with the completed list — no rollback deletes user data and no + cross-file transaction is claimed. + +## D-013 Credential origin policy + +- Stored profiles (OAuth or API key from `credentials*.json`) are sent only to the v1 + origin they were resolved for, to the v2 origin, and to a creative-lab base whose + origin equals the v1 origin. A creative-lab override on a different origin requires + an explicit key (`--api-key`, `MESHY_API_KEY`, or `--api-key-file`). +- Pre-existing behaviour: a `--base-url-v2` on a different origin than v1 already + receives the stored profile. Unchanged in S1 and listed as a compatibility + difference for review. +- Public catalog, media preflight and asset downloads use a transport with no + Authorization header and refuse cross-origin redirects. + +## D-014 Update-check policy + +- The background npm check is skipped when `--no-update-check` is given, when the + existing `MESHY_CLI_NO_UPDATE_NOTIFIER` / CI variables are set, for local commands + (`resources`, `project`, `inspect`, `mesh`, `slicer`, `doctor`, `download` from a + local source, `animation-catalog`), and for `make --dry-run`. The decision is taken + from argv before `refreshCache()` runs. + +## D-015 SSE handling + +- Parser follows the WHATWG EventSource algorithm: UTF-8 across chunks, CR/LF/CRLF + line endings, multi-line `data:` joined with `\n`, comments ignored, `event`, `id`, + `retry` fields honoured for bookkeeping only. +- `event: message` → task; `event: error` → `{message,status_code}` mapped like an + HTTP status; other event names → warning. No reconnect in S1; recovery is `get` or + `wait`. +- `--timeout` is the total deadline; `--idle-timeout` (default 60 s) is reset by any + bytes including keep-alives. Terminal status aborts the reader immediately. + +## D-016 `--api-key-file` (the contract's `--env-file`) + +- Only `MESHY_API_KEY` is read. Grammar: optional `export `, `KEY=value`, `#` comments, + blank lines, single/double quotes (inner text verbatim), unquoted `#` after + whitespace starts a comment. `${…}`, backticks and `$(…)` are never expanded; a key + containing them is invalid. Duplicate `MESHY_API_KEY` lines are an error. +- An unreadable or malformed explicit file is an error even when a higher-priority key + is present. No `.env` auto-discovery, ever. +- The flag is named `--api-key-file`, not `--env-file` — see D-025. + +## D-017 Timeouts + +- `requestJson` keeps the deadline armed until the body is fully consumed. +- `MESHY_CONNECT_TIMEOUT_MS` is still read for compatibility but fetch offers no + separate connect timeout; the README says so instead of claiming one. + +## D-018 Engineering limits + +- Media 50 MiB, task JSON 16 MiB, SSE event 1 MiB, download/OBJ 2 GiB, 5 redirects, + preflight 10 s, download 300 s, lock wait 10 s. Enforced while reading, never by + truncation. Tests inject smaller values through function parameters. + +## D-019 v1 envelope identity + +- `schema_version: "meshy.cli/v1"`. `command` is the dotted command path plus the verb + (`text-to-3d.get`, `creative-lab.figure.prototype.create`, `animation-catalog.list`, + `project.record`, `inspect.faces`, `mesh.prepare-print`, `slicer.open`, `doctor`). +- Fixed keys: `schema_version, command, ok, result, error, warnings`. Stream ndjson adds + `event` and `sequence`. + +## D-020 Tests build `dist/` first + +- Subprocess tests spawn `dist/index.js` (the existing pattern in runtime.test.ts). + A `pretest` script runs `tsc` so `pnpm test` always exercises the current sources. + +## D-021 Project store + +- `metadata.json` gains `schema_version: 2` plus `resource`, `endpoint`, + `parent_task_id`, `status`, `task_json`, `operation_id` per task; legacy files + without `schema_version` are read as v1, migrated on first write with a + `metadata.json.bak-` copy, unknown fields preserved. +- `history.json` keeps `{version: 1, projects: [...]}` and is an index only; + `rebuild-index` regenerates it from the project folders. The CLI download `meta.json` + is a third format and is never written to either of the other two paths. +- Locking: project lock (`/.meshy.lock`) → commit metadata → release → root + lock (`/.meshy-history.lock`) → update index. Never nested the other way. + +## D-022 OBJ transform + +- Two passes over the file: pass 1 computes the rotated bounding box, pass 2 rewrites + lines through a stream. Default output is `.print.obj` beside the input; + `--in-place` is an explicit replacement via temp file + rename; `--output` to another + directory requires the referenced `mtllib` files to be copied alongside (done for + local relative references) or `--geometry-only`. +- Numbers use the fixture oracle tolerance `1e-5 mm + 1e-9 * height`. + +## D-023 Slicers + +- Seven registered slicers with the legacy `multicolor` capability flag. macOS checks + `/Applications` and `~/Applications` bundles; Windows checks `%ProgramFiles%` and + `%ProgramFiles(x86)%` including glob suffixes; Linux checks PATH for the three + registered executables only (others report `unsupported_on_platform`). +- `open` spawns the detected path with `shell: false`, detached, and reports + `launch_requested` + pid. No default-application fallback, no shell strings. + +## D-024 doctor + +- Default is fully local: versions, command inventory, config sources present (without + reading secrets), workspace writability. `--check-api` performs one `GET /balance`; + `--check-slicers` runs detection. Nothing else is contacted. + +## D-025 `--env-file` cannot be offered: Node.js intercepts it + +- Verified on Node 22.23.2 and 24.20.0 (`node script.js balance --env-file X`): Node + scans the *whole* argv for `--env-file`, even after the script name. When the file + exists Node loads every variable into `process.env` before the CLI starts + (`NODE_OPTIONS` included, which Node then honours); when it is missing Node exits 9 + with its own message and the CLI never runs. Both contradict the contract ("only + `MESHY_API_KEY`, never executed, no process reconfiguration"). +- Therefore the CLI flag is `--api-key-file ` with exactly the contract's + semantics. `--env-file` stays registered as a hidden option whose only behaviour is a + usage error pointing at `--api-key-file`; the CLI cannot undo what Node already did, + so the error also explains that. S2 Skill examples must use `--api-key-file`. +- Empty or placeholder `--api-key` / `MESHY_API_KEY` values keep meaning "unset" + (0.2.0 behaviour that CI and the runtime tests depend on); only the explicit key + file is strict. + +## D-026 Asset URL policy + +- Asset downloads (`meshy download`, legacy `-o`) accept `https:` to any host and + `http:` only to loopback hosts (127.0.0.0/8, `localhost`, `::1`) so local test + servers work without a global `--insecure`. Private-network literals (10/8, + 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10) are refused, also as + redirect targets; an https → http downgrade redirect is refused. Host names are + not resolved before the request, so a DNS name pointing at a private address is + not detected — documented limitation, not a claim. +- No Authorization or Cookie header is ever sent to an asset host; the API + credential belongs to the API origins only. + +## D-027 `--project` bookkeeping on task verbs and download + +- `create/get/wait/stream --project ` record the task in `metadata.json` + (stage from `--stage`, else the payload `mode`, else the Creative Lab stage, else + the task type suffix preview/refine/prototype/build, else the resource id) and + save `task_.json` whenever a full task is known. Async create records the + id immediately without a snapshot. `download --project ` defaults the + output directory to the project and records the written files. +- A bookkeeping failure is `local_io` (exit 11) with the task id kept in + `result` — it never reads as "no task was created". + +## D-028 Tests that drive commands in-process + +- The node test runner reports to its parent over the same stdout the CLI writes + to, so in-process command tests forward non-string chunks (the runner's binary + frames) to the real stdout and capture only the CLI's string writes. Black-box + behaviour is still asserted through `dist/index.js` subprocesses wherever exit + codes or stderr matter. + +## D-029 Credential identity in the operation journal binds to the account + +- Codex review round 1 (F05): `credential_fingerprint` hashed only + source/profile/kind/origin, so two API keys exported through the same + `MESHY_API_KEY` were one identity and a repeated `--operation-id` replayed the + other account's task. It now includes a one-way digest of the API key + (`sha256("meshy-cli/credential-binding/v1|")`) for static keys, and the + stable OAuth subject (`user_id` of the stored profile) for browser logins — never + the access token, so a routine refresh keeps the identity while a different user + under the same profile name does not. A profile without `user_id` binds to the + profile name only (documented limitation). No key material is stored; the + conflict message names what differs (`result.conflict`). + +## D-030 Media content is part of the payload fingerprint + +- Review F06: data URIs were reduced to `;len=`, so two images of equal + encoded length collided and a changed picture reused the old task. The + fingerprint now hashes the *decoded bytes* of every data URI + (`data:;sha256=`): the same file re-inlined (even with different + base64 line wrapping) matches, different content of any length does not, and the + journal still never holds the content. The previous test asserting the collision + was wrong and was replaced. + +## D-031 The wait deadline bounds every request + +- Review F07: `pollUntilTerminal` passed only the abort signal to each GET and + judged the deadline after the response, so a reply arriving late could be + reported as an in-time SUCCEEDED and one more GET could start after the budget. + Each request now carries `timeoutMs = min(remaining budget, read timeout)`; a + deadline-bound request that times out *is* the timeout (exit 8), the sleep never + overshoots, and no request starts once the budget is spent. `PollResult.task` is + `null` only when no response arrived in time — the caller still knows the task id + and reports it (`result.task_id`, `result.next`, legacy `{id, timed_out:true}`). + `--timeout 0` keeps its single-query semantics bounded by the transport read + timeout, not by a zero budget. + +## D-032 `--workspace` is the root of every write, checked before the POST + +- Review F03: the v1 `-o` path handed the legacy downloader the output directory + as its own root, `project` ignored the flag, and `make` never looked at it. + `downloadArtifacts` now takes the workspace as root (directory, every planned + file and the sidecar are proven inside it before `mkdir`), `project + init/record/rebuild-index` confine `--root`/`--project`, task verbs confine + `--project`, `mesh prepare-print` confines its output and every copied + dependency (D-033), `download --project` confines the project. Without a + workspace the command's own root (output directory / project directory) applies + as before. `create` and `make` check `-o`, `--save-json` and `--project` *before* + the billable POST (containment, symlink leaf, existing file, initialised + project): a detectable conflict is exit 11 with "nothing was submitted" and zero + requests. + +## D-033 Dependency copies are proven inside the write root on real paths + +- Review F04: `copyDependency` only lstat'ed the leaf, so `/materials` + being a symlink to another directory let `materials/a.mtl` land outside the + workspace. Every planned copy target is now resolved with `resolveWithinRoot` + against the write root (workspace, else the output directory) — deepest existing + ancestor realpath'd, no symlink leaf — before any directory is created, and again + immediately before the copy is published. The report keeps the planned path + (beside the output) so `copied` stays consistent with `output`. + +## D-034 An accepted task survives every later failure; one submission state machine + +- Review F01/F02: after the server returned a task id, `--save-json` on an existing + file, a 503 while polling, and (in `make`) a journal write failure all produced + `result: null` or `submission_unknown`. Every post-acceptance step now runs in + the task's context (`withTaskContext`): the thrown error keeps its own + classification (code, HTTP status, hint, recovery, exit code) and its partial + result (files written, a failed download manifest), and always carries + `result.task_id`, `result.submission` and `result.next`. `make` uses the same + `submitCreate` primitive as the resource commands, so accepted / rejected / + unknown / journal-failure-after-acceptance (`local_io`, exit 11, id kept) are + decided in exactly one place. + +## D-035 Nested option objects merge field by field + +- Review F08: `mergePayload` is a shallow, later-wins merge (arrays and scalars + replace wholesale, by design), so `--options` replaced the whole + `--data.options` object and silently dropped the user's other settings before a + billable build. A resource may declare `nestedObjectKeys` (Creative Lab build: + `options`, `output`); those keys merge field by field across + defaults < `--data` < flags, typed flags win, explicit `false`/`0` survive, and + validation sees the combined object. Nothing else became a recursive merge. + +## D-036 Downloaded OBJ sets are relinked, not renamed + +- Review F09: the downloader saved `model.obj`/`model.mtl`/`texture__.png` + while the OBJ still said `mtllib box.mtl` and the MTL named the server's texture + files, so a complete download did not load. After a set has landed the CLI + rewrites `mtllib` to the saved MTL and each `map_*` reference to the saved + texture (exact name → channel word in the name → channel of the MTL key + (`map_Kd` → base color) → the only texture when there is exactly one reference); + what it cannot resolve stays as written and is reported + (`material_reference_unresolved`). Only the two text files the CLI just wrote are + touched, rewritten manifest entries carry `relinked: true` with their final + digest, and `result.downloads.material_links` lists every link. ZIP bundles + (keychain / fridge-magnet OBJ) and `--geometry-only` downloads are never + rewritten. Stable file names were kept over "preserve the server's names" so + scripts and the download manifest stay predictable. + +## D-037 `stream -o` downloads in every output format + +- Review F10: the ndjson branch printed the outcome and returned before the + download ran, so changing `--format` changed the command's side effects. The + download (and the project record) now happen before the format branch; the + ndjson `outcome` line carries the manifest, a download failure is one `ok:false` + outcome (exit code of the failure, task kept), and json/pretty behave the same. + +## D-038 The write root also covers report-only tasks and the implicit history root + +- Codex review round 2 (R2-F01): `-o` on a report-only task (analyze-printability) + returned through `saveReportOnly` before the workspace check; `project record` + with `--workspace` equal to the project directory wrote `history.json` into the + parent; `downloadAssets` created the output directory before refusing it. Now + `saveReportOnly` receives the workspace and proves the file/`meta.json` path + inside it before any `mkdir`; `downloadAssets` checks directory and planned leaves + first and creates the directory after; `indexRootFor` decides the history root + (explicit `--root`, else the project's parent) and, when it resolves outside the + workspace, the project verbs (`project record`, task `--project`, `download + --project`) record `metadata.json` and skip the index with + `index.updated=false` + the reason (`index_dirty` warning) — nothing is created, + locked or temp-filed outside the boundary. An explicit `--root` outside the + workspace stays a refusal before any write. + +## D-039 Material relinking resolves by source name and never guesses between groups + +- R2-F02: `byChannel` kept the first texture per channel, so two `newmtl` groups' + `map_Kd` lines both pointed at `texture_0_base_color.png`. Every downloaded + texture now carries the name the server served it under (`basenameOfUrl`), and a + reference resolves only when exactly one texture matches, in this order: saved + name; source name (case-insensitive); source stem (extension and directories + ignored); channel word in the reference; channel of the MTL key; the only texture + when the MTL has one distinct reference. Several matches for a rule are an + ambiguity: the reference stays as written, the candidates are listed on the + `texture_maps` entry (`method: "ambiguous"`), `material_links.status` is + `incomplete` and `material_reference_ambiguous` is warned. The `newmtl` group of + every map is recorded. The legacy `-o` layout uses the same resolver. + +## D-040 Bookkeeping failures after a stream are part of its terminal outcome + +- R2-F03: a `--save-json` conflict or `--project` failure after the SSE stream had + emitted task events surfaced as a bare error envelope without `event`/`sequence`. + `streamAndReport` now runs save/record inside the same terminal handling: the + failure becomes the single `outcome` (ndjson, next sequence) or the single + envelope (json/pretty), keeps the task context, and when the stream itself ended + in a failure the bookkeeping error rides along as a `bookkeeping_failed` warning. + +## D-041 Task `-o` downloads carry a per-file manifest and keep the failure class + +- R2-F04: `maybeDownloadV1`/`make` reported `files: []` on any download failure + and the legacy downloader turned every fetch error into a plain `Error`, so an + asset host 503 read as `local_io` without status while `model.glb` sat on disk. + `downloadArtifacts` now returns `files` (key, path, bytes, sha256, status) and, + on failure, throws a CliError with the *original* code/HTTP status/recovery and + `result.downloads = { state: partial|failed, files }`; `maybeDownloadV1` and + `make` merge that manifest instead of replacing it. Legacy `-o` error payloads + therefore now carry `code`, `status` and `result.downloads` (they used to be + `{name:"Error", message}`); the file layout and success output are unchanged. + +## D-042 Task `-o` downloads are cancellable + +- R2-F05: the legacy downloader never received the abort signal, so Ctrl-C during + an asset transfer printed "interrupted" and then finished the download with + exit 0. The signal now travels from every task verb, `make` and the legacy + reporter into `fetchToTemp`; an abort stops the transfer, deletes the temp file, + skips relink/sidecar and surfaces as `interrupted` (130) with the task id, + submission, `next` and the files already committed. `index.ts` re-wraps a + post-SIGINT failure as `interrupted` *without* dropping `result`/`recovery`. + +## D-043 OAuth logins are identified by user id or a per-login id — never "unknown" + +- R2-F06: D-029 bound OAuth profiles without `user_id` to the profile name, so a + different account logged into the same profile replayed the old journal record. + `meshy auth login` now mints a random `login_id` on every OAuth profile; a silent + refresh preserves it, a new login replaces it. The journal identity is + `subject:` when the token endpoint reported one, else + `login:`. A profile with neither (written before login ids existed) has + no verifiable identity: it may start new operations, but `beginOperation` refuses + to replay an existing record for it (`operation_conflict`, `result.conflict: + ["credential_unverified"]`, recovery `meshy auth login`). Migration is a + re-login; nothing secret (token, login id) is written to the journal — only the + one-way fingerprint. Supersedes the "profile name only" limitation in D-029. + +## D-044 Deadline tests are deterministic + +- R2-F07: the round-1 test "one GET within a 250 ms budget" used real timers and + failed intermittently when `setTimeout` woke a fraction early. `pollUntilTerminal` + already injects `now`/`sleep`; the tests now drive a fake clock (exact expiry, + early wake, late wake, deadline-bound request timeout, read-timeout-bound failure) + and the one real-timer smoke asserts only the invariant a real clock can prove: + no GET *starts* after the deadline. The subprocess tests with slow headers/bodies + (R03) are kept. +- Round 4 (test-only): the R03 subprocess check "expiry during the sleep" capped + the poll count at two, but the final budget-cut sleep may wake a fraction before + the deadline and issue one more deadline-bound GET — the early-wake case above — + so it failed about once in six runs. It now asserts what a real clock can prove: + every GET the server saw started within the budget, the second waited the full + interval, a third can only be the deadline wake-up, and the counted polls match + the GETs seen (at most one cut off by the deadline). + +## D-045 The legacy sidecar is published like an asset + +- Codex review round 3 (R3-F01): `writeMeta` wrote `meta.json` / `_meta.json` + with a truncating `writeFileSync` after a preflight that could be minutes old, + so a symlink or file planted in the output directory during the transfer was + followed or overwritten — even outside `--workspace`. The sidecar now goes + through the same rules as every asset: the real path is re-proven inside the + root at publication time (symlink leaf refused), the JSON is published + exclusively and atomically (`writeJsonFile` → `link`), and an existing file, + symlink or directory is a `local_io` refusal that keeps the committed model in + the manifest. Preflight remains an early exit, never a substitute for the + publication check. `saveReportOnly` (directory mode) uses the same path. + +## D-046 Legacy-schema post-processing runs in the task's context + +- R3-F02: only the v1 `maybeDownloadV1` wrapped download failures in the task + context; the legacy reporter (`-o` on a default-schema `create`, `wait`, `get`, + `stream`, `make`) let the raw error escape, so a paid create whose asset host + answered 503 printed an error without the accepted task id. Every legacy + post-processing call is now wrapped with `withTaskContext` / `wrapWithResult`: + the legacy error payload keeps its shape (`name`, `message`, `code`, `status`, + `hint`, `result`) and gains additive `task_id` and `operation_id` fields, the + `result` carries the real `submission`, `next` and the partial manifest, and the + `hint` (printed on stderr) is the resume/download command, which names the task. + +## D-047 Source identity beats a generated file name + +- R3-F03: a saved-name match ran before the source-name match, so an MTL that + referenced `texture_1_base_color.png` — the *server's* name for the image the + CLI saved as `texture_0_base_color.png` — kept pointing at the wrong file and + was reported `unchanged`/`complete`. The order is now: the server-side source + name; then a saved file of that name *only if* its own source is unknown or the + same name (a generated name that belongs to a different source is an ambiguity + with an explanatory `note`); then source stem, channel word, MTL-key channel and + the single-texture rule. Channel rules additionally refuse to decide when two + distinct references compete for the only texture of that channel. The + conservative fallback without source evidence (unit callers) is unchanged. + +## D-048 Download finalisation shares the transfer failure handling + +- R3-F04: relink, digest refresh and sidecar publication ran outside the + per-artifact `try`, so a failure there reached the caller as a bare error and + the manifest collapsed to `files: []` although every asset was on disk. The + three steps now run under one handler (`finalisationFailure`): the thrown + CliError keeps the original class, re-takes every committed file's digest from + disk (a relink may have rewritten some), marks `relinked` from the digest + change, reports `downloads.state = "partial"` with the full `files` list and + names the step in `downloads.failed_step` (`relink` | `digest` | `sidecar`). + `downloadAssets` (`meshy download`) does the same for its relink step. + +## D-049 The material rewrite is cooperative with SIGINT + +- R3-F05: the abort signal stopped at the HTTP transfer; the asynchronous relink + that followed did not receive it, so a Ctrl-C during a multi-megabyte OBJ + rewrite printed "interrupted" and then exited 0 with a published sidecar. + `relinkMaterials` now takes the signal and `rewriteLines` checks it before the + first read, after every chunk and before publication, removing its temp file + and throwing `interrupted`; `downloadArtifacts`/`downloadAssets` check it again + before the sidecar. The result is exit 130 with the task id, `next`, the + committed files (digests re-taken) and `failed_step: "relink"`; the OBJ on disk + is either the original or the fully rewritten file, never a partial one. + +## D-050 Project file records are computed in the real-path frame + +- R3-F06: `download --project` compared the downloader's real paths with the + project directory as given, so a project reached through an alias (a symlinked + parent, macOS `/var` → `/private/var`) produced `../…` relative paths that were + filtered out: the asset was inside the project but `metadata.tasks[].files` + stayed empty and a false `files_outside_project` warning appeared. Both sides + are now resolved with `realpathLenient` before the containment test and the + relative path (as `saveTaskSnapshot` already did); the user-facing paths in the + result are unchanged. Files genuinely outside the project are still not recorded. + +## D-051 Material heuristics compete on the texture they actually reach + +- R4-F01: channel competition was computed on the channel each reference + *declared* (a channel word in its name, else the MTL key's channel), while + resolution fell through: `map_Kd body_normal.png` declared normal, found no + normal texture and fell back to the key's base color — the very texture + `map_Kd eyes_diffuse.png` reached through its name — and the base-color + competition set had never counted it. Two material groups were rewritten to + one image and the report said `complete`. Resolution now runs in two passes + over the whole MTL: every distinct (key, reference) pair is resolved on its + own (`source_name` / `exact` / `source_stem` are *identity* evidence, the + channel and only-texture rules are *heuristics*), then `arbitrate` vetoes a + heuristic hit whenever any other distinct reference contends for that + texture — by a hit of either kind, or as an ambiguity it could not decide — + and when the same reference would land on different textures under different + keys. Identity hits are never vetoed, so C05's source-name mapping, N04's + multi-material set and the round-1 distinct-channel fallbacks are unchanged; + a lone reference may still fall back by key. Vetoed references stay as + written with `method: "ambiguous"`, `candidates: []` and one `note` + shared by the group; `material_links.status` is `incomplete` and the single + `material_reference_ambiguous` warning states each reason once, naming the + material groups. The order of the references is irrelevant. + +## D-052 A project-record failure keeps the download result and says how to redo the record alone + +- R4-F02: in `meshy download --project` the realpath / `indexRootFor` / + `recordTask` phase ran after the download's try/catch, so a refused + metadata.json replacement (a symlink planted during the transfer), a damaged + metadata.json or a lock/permission failure surfaced as `local_io` with + `result: null` although every asset was on disk. The phase is now wrapped + (`projectRecordFailure`): the error keeps its class (code, exit code, HTTP + status), carries the complete result — `source`, `selection`, `downloads` + with the digests actually on disk, `unknown_urls`, `saved_json` — plus + `project: { action: "failed", stage, recorded_files: [], error, recovery }`, + and `error.recovery = { action: "record_project", automatic: false, command }` + (also the `hint`), where `command` is the exact `meshy project record …` + invocation with the task id, stage, resource, task type, status and the files + that landed inside the project (`projectRecordCommand`). Nothing is rolled + back, re-downloaded or re-submitted, and `index_dirty` keeps its meaning + (metadata committed, history index not). What can be seen before the + transfer is refused before it (`preflightProject`: metadata.json must exist, + be a regular file and parse as a project; `--stage` must not be blank) with + "nothing was downloaded" and no request. The task verbs' `--project` + attachment reports the same `record_project` recovery and hint on failure. + +## D-053 `make`'s reported identity is asserted against the journal (R4-T01) + +- Test-only. The round-3 C06 make scenarios had both POSTs return the same task + id and only checked `operation_id` for presence, so a future regression that + reported step 1's id would have passed. `tests/codex-review-round4.test.ts` + runs the two-step text chain with distinct step ids under both schemas, for an + asset 503 and for a SIGINT during the final download, and checks + `result.task_id`, `submission.operation_id`, `executed[-1].operation_id` and + the legacy top-level `operation_id` against the *last* accepted journal + record, `executed[0].operation_id` against step 1's record, the refine + payload's `preview_task_id` against step 1, and the request sequence + POST GET POST GET GET. C06 keeps the create/wait/get/sidecar/SIGINT scenarios. + +## D-054 A recovery command carries the original write boundary + +- R5-F01: `projectRecordCommand` emitted `meshy project record …` without the + invocation's `--workspace`. Replayed after a repair, the command wrote where + the original could not: with a workspace equal to the project, the original + records metadata and skips the parent's history index (`index_dirty`), while + the recovery refreshed that index and created its lock outside the boundary. + The helper now takes the resolved absolute workspace and appends + `--workspace ` (shell-quoted like every other argument); both callers — + `download`'s `projectRecordFailure` and the task verbs' `attachToProject` — + pass it. A recovery never reaches further than the command that failed; it is + not made to succeed by dropping a constraint. Without an explicit workspace + nothing is appended and the parent index is refreshed as before. The tests + replay the command verbatim (nothing appended), including a workspace path + with a space and a quote character. + +## D-055 One reference is one file: keys are reconciled before textures compete + +- R5-F02: the same reference under two MTL keys — `map_Kd shared.png` + (heuristic hit on the only base color) and `map_Bump shared.png` (ambiguous + between two normals) — had only its *hits* compared across keys, so the base + color line was rewritten while the normal line stayed, splitting one file into + two. `arbitrate` now runs in two steps. Step 1 reconciles every reference + across its keys: identity evidence (source name, safe saved name, source stem) + is key-independent and wins as before; otherwise each key's channel rule yields + a candidate set (a hit is a set of one, an ambiguity its candidates, a key + whose channel has no texture contributes nothing) and all sets must be the same + single texture — if not, every line of that reference stays as written with + `method: "ambiguous"`, its own candidates and one note that spells out what + each key would have made of it ("one reference names one file"). Step 2 is the + round-4 rule: a heuristic hit is vetoed when any other distinct reference + contends for the texture. The reviewer's 8 × 2 matrix (identity + heuristic, + ambiguous rival, same reference with two hits / hit + ambiguity / same identity + / same fallback, distinct channels, identity with an ambiguous rival) is a + repository test; D01, C05, N04 and the round-1 relink cases are unchanged. + +## D-056 The task verbs check the project inside the recovery context + +- R5-F03: `attachToProject` resolved `--project` (metadata present, inside the + workspace) before its try block, so a project whose metadata.json vanished + between the preflight and the record step failed with `local_io` and a bare + `wait` hint — task id, submission and journal intact, but no way to redo the + record — across legacy/v1 × get/wait/stream/create --async/sync create (the + damaged-JSON branch already had `record_project`). The recovery context (task + id, journal operation, stage, resolved workspace) is now built first, then two + distinct checks run inside the phase: a project that no longer resolves inside + the workspace (or became a symlink) is a *boundary* failure — the error names + the task and operation, says nothing was recorded and how to proceed, but hands + out no command that would write across the boundary; everything else + (`assertProjectMetadataPresent`: metadata.json missing or not a regular file + after the preflight, a damaged file, a lock, a full disk) is *repairable* and + gets the one `meshy project record …` command with `--operation-id` and the + original `--workspace` (also the `hint`, so the legacy payload carries it). + Nothing is re-submitted, no `project init` is suggested for a project that was + valid, and `index_dirty` still means only "metadata committed, history index + not". `meshy download --project` applies the same metadata assertion before its + record step, so a metadata.json that disappears during the transfer is + `local_io` with the record_project recovery rather than an API-style + `not_found`. + +## D-057 The write boundary is frozen before the first request + +- R6-F02: every containment check re-resolved the root (`--workspace`) with + realpath at check time. A workspace directory swapped for a symlink to another + tree while a request was in flight — or the alias a workspace was given + through re-pointed — made root and target move together, so the check passed + and the task snapshot, metadata and history landed outside the boundary the + user had authorised; `get` and `create --async` reported success. The root is + now an `AuthorisedRoot` frozen when the global flags are read (before any + request or write): its real path and the identity (device, inode) of the + physical directory behind it (`freezeRoot`). `resolveWithinRoot` given such a + root never resolves it again: it first proves that this very directory is still + at that path (a directory, same identity — a symlink or a different directory + there is refused, D-057 message "changed since the command started"), then that + the target's real path lies inside the frozen real path. The frozen root flows + through every write: `--save-json`, `-o` downloads (`downloadArtifacts`, + `downloadAssets`, sidecar, report-only), `make`, `mesh prepare-print`, + `project` commands, and the task verbs' project attachment, whose project is + written through its proven real path. Without a workspace the project directory + itself is frozen at the start of `get`/`wait`/`stream`/`create` (a project + that is not initialised is refused before any request) and of `download`. + Stable aliases (macOS `/var` → `/private/var`, a symlinked parent, a workspace + given through a symlink that keeps pointing at the same directory) resolve to + the same physical directory and pass as before; the recovery command still + carries the workspace as the user named it. + +## D-058 The download command re-validates the project inside the frozen boundary + +- R6-F01: `download --project` only checked that `metadata.json` behind the + project path was a regular file, so a project directory (or its parent) + replaced by a symlink to an outside project during the asset transfer was + followed: the outside metadata was rewritten and the command exited 0 with a + mere `files_outside_project` warning. Before any project lock, snapshot or + metadata write the project is now resolved against the frozen boundary (the + workspace, or the project directory itself when no workspace is given) and the + record is written through its real path. A failure there is a *boundary* + failure distinct from a repairable metadata problem: exit 11 / `local_io`, the + complete download result (source, selection, manifest with on-disk digests, + saved_json) is kept, `project.action = "failed"` with the reason and + `recovery: null` — no `meshy project record` command is offered, because the + only one that would succeed is one that writes across the boundary — and + nothing is disguised as `index_dirty`. Assets already downloaded stay where + they landed. Missing/damaged metadata after the preflight keeps the + `record_project` recovery of D-052/D-056. + +## D-059 Timestamps and counts a server sends as null read as 0 + +- Live verification (real account, 2026-09-08): the Creative Lab endpoints + return `finished_at: null` (and may return null for the other "not yet" + fields) while a task is IN_PROGRESS, whereas the v2 endpoints return 0. The + task schema accepted only a number with a default for *absence*, so every + `creative-lab … get`/`wait` on a running task failed with "unexpected task + shape" (v1 `error.code: "server"`, HTTP 200) until the task had finished — a + `wait` could never poll through. `progress`, `preceding_tasks`, `created_at`, + `started_at`, `finished_at` and `expires_at` now accept null and absence alike + and normalise both to 0, the value the v2 endpoints already use; the v1 task + view keeps showing a timestamp that has not happened as `null`. The body + captured live is `tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json` + and `tests/live-verification.test.ts` (L01) replays it through the schema and + through `creative-lab lamp prototype get`/`wait`. Verified live afterwards: a + keychain prototype created and waited on immediately polled through three + IN_PROGRESS states to SUCCEEDED; the lamp prototype's `get`/`wait` and the lamp + build completed. + +## D-060 The legacy `-o` layout names Creative Lab parts and bundles like `meshy download` + +- Live verification (real account, 2026-09-08): the task verbs' `-o` downloader + (`downloadArtifacts`, the 0.2.0 layout) derived every `model_urls` file name + as `model.`, which is right for format keys (`glb`, `obj`) but wrong for + Creative Lab builds: the lamp parts landed as `model.lamp_stl` / + `model.base_stl` and the keychain build's OBJ — which the server delivers as + a ZIP bundle (model.obj + model.mtl + texture.png) — as `model.obj`, an + extension no slicer or loader accepts. `meshy download` already named them + `lamp.stl`, `base.stl`, `model.obj.zip` through the product-aware + `modelAsset` mapping in artifacts.ts; that mapping is now shared: the legacy + enumerator asks it for the file name and expected format (product taken from + the task type), keeps its own slot keys (`model_lamp_stl`, `model_obj`) in + `saved_files`/the manifest, and a ZIP bundle is neither relinked nor renamed + by content type. `tests/live-verification.test.ts` (L02) covers both products; + the same tasks re-downloaded live produce `lamp.stl`/`base.stl` (byte-identical + to the mis-named files) and `model.obj.zip`. diff --git a/docs/skill-parity/endpoint-contracts.json b/docs/skill-parity/endpoint-contracts.json new file mode 100644 index 0000000..36d658e --- /dev/null +++ b/docs/skill-parity/endpoint-contracts.json @@ -0,0 +1,120 @@ +{ + "schema_version": 1, + "title": "Meshy CLI S1 endpoint contracts (frozen 2026-09-07)", + "notes": [ + "This file is documentation of the frozen contract. src/client/resource-registry.ts is the executable source; tests/resource-registry.test.ts asserts both agree.", + "billing 'may-charge' means the operation can consume credits on success; 'none' means the request is free. automaticRetry is false everywhere in S1.", + "Relative paths are appended to the base URL of their API family. Default bases: v1 https://api.meshy.ai/openapi/v1, v2 https://api.meshy.ai/openapi/v2, creative-lab /openapi/creative-lab, public-web /web/public." + ], + "bases": { + "v1": { "default": "https://api.meshy.ai/openapi/v1", "env": "MESHY_BASE_URL_V1", "flag": "--base-url-v1", "auth": "bearer" }, + "v2": { "default": "https://api.meshy.ai/openapi/v2", "env": "MESHY_BASE_URL_V2", "flag": "--base-url-v2", "auth": "bearer" }, + "creative-lab": { "default": "derived: /openapi/creative-lab; explicit override required when v1 is a custom proxy path", "env": "MESHY_BASE_URL_CREATIVE_LAB", "flag": "--base-url-creative-lab", "auth": "bearer", "stored_profile_policy": "stored OAuth/API-key profiles are only sent when the creative-lab origin equals the v1 origin; a different origin requires an explicit --api-key / MESHY_API_KEY / --env-file key" }, + "public-web": { "default": "derived: /web/public", "auth": "none", "notes": "never carries Authorization or cookies" } + }, + "task_resources": [ + { "id": "text-to-3d", "commandPath": ["text-to-3d"], "base": "v2", "relativePath": "/text-to-3d", "legacyEndpoint": "/openapi/v2/text-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "texture_image_url", "kind": "image", "many": false } ], "taskTypes": ["text-to-3d-preview", "text-to-3d-refine"], "billing": { "create": "may-charge" } }, + { "id": "image-to-3d", "commandPath": ["image-to-3d"], "base": "v1", "relativePath": "/image-to-3d", "legacyEndpoint": "/openapi/v1/image-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false } ], "taskTypes": ["image-to-3d"], "billing": { "create": "may-charge" } }, + { "id": "multi-image-to-3d", "commandPath": ["multi-image-to-3d"], "base": "v1", "relativePath": "/multi-image-to-3d", "legacyEndpoint": "/openapi/v1/multi-image-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_urls", "kind": "image", "many": true } ], "taskTypes": ["multi-image-to-3d"], "billing": { "create": "may-charge" } }, + { "id": "remesh", "commandPath": ["remesh"], "base": "v1", "relativePath": "/remesh", "legacyEndpoint": "/openapi/v1/remesh", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["remesh"], "billing": { "create": "may-charge" } }, + { "id": "convert", "commandPath": ["convert"], "base": "v1", "relativePath": "/convert", "legacyEndpoint": "/openapi/v1/convert", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["convert"], "billing": { "create": "may-charge" } }, + { "id": "resize", "commandPath": ["resize"], "base": "v1", "relativePath": "/resize", "legacyEndpoint": "/openapi/v1/resize", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["resize"], "billing": { "create": "may-charge" } }, + { "id": "rigging", "commandPath": ["rigging"], "base": "v1", "relativePath": "/rigging", "legacyEndpoint": "/openapi/v1/rigging", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false, "formats": ["glb"] }, { "path": "texture_image_url", "kind": "image", "many": false } ], "taskTypes": ["rig"], "billing": { "create": "may-charge" }, "notes": "CLI 0.2.0 marked list unsupported; official docs (rigging page) and the server route table both provide GET /rigging, so list is enabled in S1 (see decisions D-006)." }, + { "id": "animate", "commandPath": ["animate"], "base": "v1", "relativePath": "/animations", "legacyEndpoint": "/openapi/v1/animations", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["animation"], "billing": { "create": "may-charge" } }, + { "id": "retexture", "commandPath": ["retexture"], "base": "v1", "relativePath": "/retexture", "legacyEndpoint": "/openapi/v1/retexture", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false }, { "path": "image_style_url", "kind": "image", "many": false }, { "path": "multiview_image_urls", "kind": "image", "many": true } ], "taskTypes": ["retexture"], "billing": { "create": "may-charge" } }, + { "id": "text-to-image", "commandPath": ["text-to-image"], "base": "v1", "relativePath": "/text-to-image", "legacyEndpoint": "/openapi/v1/text-to-image", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["text-to-image"], "billing": { "create": "may-charge" } }, + { "id": "text-to-motion", "commandPath": ["text-to-motion"], "base": "v1", "relativePath": "/text-to-motion", "legacyEndpoint": "/openapi/v1/text-to-motion", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["text-to-motion"], "billing": { "create": "may-charge" } }, + { "id": "image-to-image", "commandPath": ["image-to-image"], "base": "v1", "relativePath": "/image-to-image", "legacyEndpoint": "/openapi/v1/image-to-image", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "reference_image_urls", "kind": "image", "many": true } ], "taskTypes": ["image-to-image"], "billing": { "create": "may-charge" } }, + { "id": "multi-color-print", "commandPath": ["multi-color-print"], "base": "v1", "relativePath": "/print/multi-color", "legacyEndpoint": "/openapi/v1/print/multi-color", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-multi-color"], "billing": { "create": "may-charge" } }, + { "id": "analyze-printability", "commandPath": ["analyze-printability"], "base": "v1", "relativePath": "/print/analyze", "legacyEndpoint": "/openapi/v1/print/analyze", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-analyze"], "billing": { "create": "none" }, "notes": "free endpoint (0 credits) but still creates a server task; never retried automatically" }, + { "id": "repair-printability", "commandPath": ["repair-printability"], "base": "v1", "relativePath": "/print/repair", "legacyEndpoint": "/openapi/v1/print/repair", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-repair"], "billing": { "create": "may-charge" } }, + { "id": "uv-unwrap", "commandPath": ["uv-unwrap"], "base": "v1", "relativePath": "/uv-unwrap", "legacyEndpoint": "/openapi/v1/uv-unwrap", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false, "formats": ["glb"] } ], "taskTypes": ["uv-unwrap"], "billing": { "create": "may-charge" }, "create_rules": { "sources": ["input_task_id", "model_url"], "exclusive": true, "server_precedence_if_both": "input_task_id (CLI refuses both instead of relying on it)", "model_format": "glb only", "face_ceiling": 40000, "rollout": "docs describe accounts gated by a Statsig flag receiving 404; the CLI reports 404 as not_found without guessing the cause" } }, + { "id": "creative-lab.figure.prototype", "commandPath": ["creative-lab", "figure", "prototype"], "base": "creative-lab", "relativePath": "/figure/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/figure/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-figure-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.figure.build", "commandPath": ["creative-lab", "figure", "build"], "base": "creative-lab", "relativePath": "/figure/v1/build", "legacyEndpoint": "/openapi/creative-lab/figure/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-figure-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.lamp.prototype", "commandPath": ["creative-lab", "lamp", "prototype"], "base": "creative-lab", "relativePath": "/lamp/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/lamp/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-lamp-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.lamp.build", "commandPath": ["creative-lab", "lamp", "build"], "base": "creative-lab", "relativePath": "/lamp/v1/build", "legacyEndpoint": "/openapi/creative-lab/lamp/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-lamp-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.keychain.prototype", "commandPath": ["creative-lab", "keychain", "prototype"], "base": "creative-lab", "relativePath": "/keychain/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/keychain/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-keychain-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.keychain.build", "commandPath": ["creative-lab", "keychain", "build"], "base": "creative-lab", "relativePath": "/keychain/v1/build", "legacyEndpoint": "/openapi/creative-lab/keychain/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-keychain-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.fridge-magnet.prototype", "commandPath": ["creative-lab", "fridge-magnet", "prototype"], "base": "creative-lab", "relativePath": "/fridge-magnet/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/fridge-magnet/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-fridge-magnet-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.fridge-magnet.build", "commandPath": ["creative-lab", "fridge-magnet", "build"], "base": "creative-lab", "relativePath": "/fridge-magnet/v1/build", "legacyEndpoint": "/openapi/creative-lab/fridge-magnet/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-fridge-magnet-build"], "billing": { "create": "may-charge" } } + ], + "creative_lab_products": { + "figure": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true, "media": "image jpg/jpeg/png/webp URL or data URI" }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "consumed_credits": "integer", "type": "creative-lab-figure-prototype" }, + "meaning": "styled concept image (2D)" + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true, "rule": "SUCCEEDED prototype of the same product created through this API with the same key; web-app prototypes return 404" }, "name": { "type": "string", "maxLength": 100 } }, + "response": { "model_urls": ["glb", "obj", "mtl"], "thumbnail_url": "string", "texture_urls": "[{ base_color }]", "type": "creative-lab-figure-build" }, + "assets": "independent GLB, OBJ, MTL and base-color texture; OBJ depends on MTL + texture" + }, + "pricing_evidence": { "skill_reference": "prototype 6 / build 30", "official_figure_page": "prototype 6 / build 20", "status": "conflicting sources; the CLI does not price Creative Lab" } + }, + "lamp": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "image_subject": { "type": "string", "enum": ["character", "landscape"], "default": "character" }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false }, "text": { "deprecated": true, "cli": "rejected before submission" } }, + "response": { "image_urls": "array of 1 concept image URL", "model_urls": ["glb"], "thumbnail_url": "string", "type": "creative-lab-lamp-prototype" }, + "meaning": "concept image AND a hollow matte-white lampshade GLB" + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "diameter_mm": { "type": "number", "range": "[50,400]", "default": 150 }, "thickness_mm": { "type": "number", "range": "(0,10]", "default": 1 }, "cut_amount_percent": { "type": "number", "range": "[1,100]", "default": 1 }, "light_source_preset": { "type": "string", "enum": ["bambu_mh001_60mm", "none"], "default": "bambu_mh001_60mm" }, "fixture_offset_x_mm": { "type": "number", "range": "[-80,80]", "default": 0 }, "fixture_offset_z_mm": { "type": "number", "range": "[-80,80]", "default": 0 }, "rotate_x_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "rotate_y_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "rotate_z_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "include_result_json": { "type": "boolean", "default": false, "rule": "requires output.format=zip" } }, "output": { "format": { "type": "string", "enum": ["stl", "zip"], "default": "stl" } } }, + "response": { "model_urls": { "lamp_stl": "stl (format=stl)", "base_stl": "stl (format=stl and light_source_preset != none)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-lamp-build" }, + "assets": "part keys are not file extensions: lamp_stl/base_stl are STL files, bundle_zip is a ZIP" + } + }, + "keychain": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "type": "creative-lab-keychain-prototype" } + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "badge_shape": { "enum": ["circle", "rounded-rect", "hexagon", "shield", "star"], "default": "circle" }, "size_mm": { "range": "(0,400]", "default": 40 }, "relief_height_mm": { "range": "[0,20]", "default": 2.2 }, "relief_offset_mm": { "range": "[0,20]", "default": 0 }, "base_thickness_mm": { "range": "[0,20]", "default": 0.1 }, "has_closed_back": { "type": "boolean", "default": true }, "relief_curve": { "enum": ["linear", "gamma", "s-curve"], "default": "linear" }, "curve_param": { "range": "(0,10]", "default": 1.0 }, "invert_depth": { "type": "boolean", "default": false }, "smoothing": { "range": "[0,10]", "default": 0.24 }, "relief_scale": { "range": "(0,10]", "default": 1.0 }, "depth_threshold": { "range": "[0,1]", "default": 0.1 }, "remove_background": { "type": "boolean", "default": true, "note": "distinct from the prototype remove_background" }, "export_resolution": { "type": "integer", "range": "[64,2048]", "default": 512 } }, "output": { "format": { "enum": ["glb", "obj", "zip"], "default": "glb" } } }, + "response": { "model_urls": { "glb": "glb (format=glb)", "obj": "ZIP container holding model.obj + model.mtl + texture.png (format=obj)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-keychain-build" } + } + }, + "fridge-magnet": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "type": "creative-lab-fridge-magnet-prototype" } + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "badge_shape": { "enum": ["circle", "rounded-rect", "hexagon", "shield", "star"], "default": "rounded-rect" }, "size_mm": { "range": "(0,400]", "default": 60 }, "relief_height_mm": { "range": "[0,20]", "default": 3.3 }, "relief_offset_mm": { "range": "[0,20]", "default": 0 }, "base_thickness_mm": { "range": "[0,20]", "default": 2.0 }, "has_closed_back": { "type": "boolean", "default": true }, "relief_curve": { "enum": ["linear", "gamma", "s-curve"], "default": "linear" }, "curve_param": { "range": "(0,10]", "default": 1.0 }, "invert_depth": { "type": "boolean", "default": false }, "smoothing": { "range": "[0,10]", "default": 0.24 }, "relief_scale": { "range": "(0,10]", "default": 1.0 }, "depth_threshold": { "range": "[0,1]", "default": 0.1 }, "remove_background": { "type": "boolean", "default": true }, "export_resolution": { "type": "integer", "range": "[64,2048]", "default": 512 } }, "output": { "format": { "enum": ["glb", "obj", "zip"], "default": "glb" } } }, + "response": { "model_urls": { "glb": "glb (format=glb)", "obj": "ZIP container holding model.obj + model.mtl + texture.png (format=obj)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-fridge-magnet-build" } + } + } + }, + "query_resources": [ + { "id": "balance", "commandPath": ["balance"], "base": "v1", "method": "GET", "relativePath": "/balance", "auth": "bearer", "billing": "none", "response": { "balance": "number" } }, + { "id": "animation-catalog", "commandPath": ["animation-catalog", "list"], "base": "public-web", "method": "GET", "relativePath": "/animations/resources", "auth": "none", "billing": "none", "query": { "category": "string (WalkAndRun | BodyMovements | DailyActions | Fighting | Dancing); verified in the frozen Skill baseline" }, "cli_only": { "search": "case-insensitive local match on name/key/subCategory over the fetched batch; not a server-side search" }, "response": { "result": { "total": "integer", "list": [ { "id": "integer (= action_id)", "key": "string", "name": "string", "category": "string", "subCategory": "string", "previewUrl": "string", "rigType": "string", "isDefault": "boolean", "isFree": "boolean" } ] } }, "pagination": "none observed; total equals list length" }, + { "id": "showcases", "commandPath": ["showcases", "list"], "base": "v1", "method": "GET", "relativePath": "/showcases", "auth": "bearer", "billing": "may-charge (1 credit per request; Enterprise tier only, 403 otherwise)", "automaticRetry": false, "query": { "page_size": { "type": "integer", "range": "[1,10]", "default": 3 }, "sort_by": { "enum": ["+created_at", "-created_at", "+updated_at", "-updated_at", "+downloads", "-downloads"], "default": "-created_at" }, "search": "string", "format": { "enum": ["glb", "fbx", "obj", "usdz"], "default": "glb", "cli_flag": "--model-format" }, "showcase_type": { "server_enum": ["all", "animate", "static"], "docs_enum": ["all", "animated", "static"], "cli": "accepts both; 'animated' is sent as 'animate' with a warning (decisions D-007)" } }, "response": { "result": "array of items: id, result_id, name, object_prompt, style_prompt, neg_prompt, art_style, resolution, seed, texture_richness, ai_model, model_url, conversion_status ('' | success | failed), community_url, thumbnail_url, solid_thumbnail_url, alpha_thumbnail_url, alpha_solid_thumbnail_url, image_urls, categories, tags, mode, topology, created_at, updated_at (items are passed through unchanged)" } } + ], + "local_tools": [ + { "commandPath": ["download"], "network": "asset GET only (no Authorization); one task GET when --resource/--task-id is used" }, + { "commandPath": ["project", "init|record|show|list|rebuild-index"], "network": "none" }, + { "commandPath": ["inspect", "faces"], "network": "one task GET when --resource/--task-id is used" }, + { "commandPath": ["mesh", "prepare-print"], "network": "none" }, + { "commandPath": ["slicer", "detect|open"], "network": "none" }, + { "commandPath": ["doctor"], "network": "none by default; --check-api performs one free GET /balance" } + ], + "sse_protocol": { + "request": { "method": "GET", "path": "//stream", "headers": { "Accept": "text/event-stream", "Authorization": "Bearer " } }, + "events": { + "message": "data is the same task JSON as GET; re-emitted every ~10s as keep-alive even without progress change", + "error": "data is {\"message\": string, \"status_code\": integer}; may arrive after HTTP 200; status_code is mapped like an HTTP status (404 -> not_found exit 5, 400 -> validation exit 4, 500 -> server exit 1)", + "comment lines (':')": "ignored; treated as heartbeat for idle-timeout purposes", + "unknown event names": "reported as a warning, never treated as task success" + }, + "termination": "server closes after the first terminal message (SUCCEEDED/FAILED/CANCELED); the CLI also aborts the reader itself on terminal status", + "reconnect": "not implemented in S1; recovery is `get`/`wait` with the same task id" + }, + "task_view_fields": ["task_id", "resource", "endpoint", "type", "name", "status", "progress", "preceding_tasks", "created_at", "started_at", "finished_at", "expires_at", "face_count", "consumed_credits", "model_urls", "image_urls", "texture_urls", "thumbnail_url", "thumbnail_urls", "alpha_thumbnail_url", "result", "printability", "task_error"], + "task_view_notes": [ + "face_count: the public task DTO does not include face_count (meshyd httpapi/dto.go, checked 2026-09-07); it is null unless the server sends it. The legacy check-faces treated the missing field as 0 and could pass a model it never measured.", + "consumed_credits: optional integer; null when absent; 0 is preserved as 0.", + "thumbnail_urls: object keyed by view (front/right/back/left) when multi_view_thumbnails was requested." + ], + "exit_codes": { "0": "ok", "1": "task_failed / unclassified", "2": "usage", "3": "auth", "4": "validation", "5": "not_found", "6": "rate_limit", "7": "network", "8": "timed_out", "9": "credit", "10": "submission_unknown", "11": "local_io", "12": "check_failed", "13": "check_unknown", "130": "interrupted" }, + "limits": { "media_file_bytes": 52428800, "task_json_bytes": 16777216, "sse_event_bytes": 1048576, "download_bytes": 2147483648, "obj_bytes": 2147483648, "redirects_max": 5, "media_preflight_ms": 10000, "download_ms": 300000, "lock_wait_ms": 10000, "note": "engineering defaults, not Meshy product limits; tests may inject smaller values" } +} diff --git a/docs/skill-parity/live-verification.json b/docs/skill-parity/live-verification.json new file mode 100644 index 0000000..126cb66 --- /dev/null +++ b/docs/skill-parity/live-verification.json @@ -0,0 +1,1263 @@ +{ + "schema_version": 1, + "recorded_at": "2026-09-08T06:09:04.615660Z", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "environment": { + "host": "macOS 26.6.2 (Darwin 25.6.0) arm64", + "node": "v24.20.0", + "install": "npm install -g ~/Downloads/meshy-cli-0.3.0.tgz (real global install; reinstalled after each live fix)", + "config": "~/.config/meshy/credentials.json (default location; profiles: default = API key, oauth = OAuth)", + "workspace": "/meshy-live (real user directory)" + }, + "scope": "real account, real API, billable tasks authorised by the account owner without a cap; credentials entered by the owner in their own terminal and never handled by the agent; no publish", + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "steps": [ + { + "id": "T104-01", + "credential": "oauth", + "title": "auth status (OAuth active) — masked credential, verified balance", + "file": "live/t104/status-oauth.json", + "authenticated": true, + "profile": "oauth", + "profiles": [ + "default", + "oauth" + ], + "active_profile": "oauth", + "verified": true, + "balance.balance": 2806 + }, + { + "id": "T104-02", + "credential": "oauth", + "title": "auth list — two profiles (api_key default, oauth)", + "file": "live/t104/list.json", + "active_profile": "oauth", + "profiles": [ + { + "name": "default", + "kind": "api_key", + "active": false + }, + { + "name": "oauth", + "kind": "oauth", + "active": true + } + ] + }, + { + "id": "T104-03", + "credential": "oauth", + "title": "credentials.json shape: oauth profile has access/refresh tokens and login_id, no user_id (the real token endpoint did not return one)", + "file": "inspected with jq keys only", + "oauth_profile_keys": [ + "access_token", + "created_at", + "expires_at", + "kind", + "login_id", + "refresh_token" + ], + "has_user_id": false, + "has_login_id": true, + "api_key_profile_keys": [ + "api_key", + "created_at", + "kind" + ] + }, + { + "id": "T104-04", + "credential": "oauth", + "title": "silent refresh observed: expires_at advanced, login_id kept, tokens rotated; refreshed token works", + "file": "live/t104/oauth-refresh-observed.json", + "original_expires_at_iso": "2026-09-08T05:52:10.945000+00:00", + "expires_at_now_iso": "2026-09-08T06:51:13.984000+00:00", + "refreshed": true, + "login_id_present": true, + "login_id_sha": "ee55f2134c19", + "created_at_unchanged": true, + "observed_at": "2026-09-08T05:52:56.108115+00:00", + "balance_after": 2515 + }, + { + "id": "T104-05", + "credential": "api_key", + "title": "journal replay with the same key: operation_replayed, no new task, balance unchanged", + "file": "live/t110/09-replay-same-key.json", + "ok": true, + "result.task_id": "01a07f5e-81c7-7111-86f2-f6d2f80b2093", + "result.submission.operation_id": "2a9a9e91-da6f-4d14-a560-d1b16608fba2", + "warnings": [ + "operation_replayed" + ] + }, + { + "id": "T104-06", + "credential": "oauth", + "title": "same operation id under the OAuth profile → operation_conflict (credential), exit 2, nothing submitted", + "file": "live/t110/10-replay-other-credential.json", + "ok": false, + "error.code": "operation_conflict", + "result.conflict": [ + "credential" + ] + }, + { + "id": "T104-07", + "credential": "api_key", + "title": "same operation id with a different image → operation_conflict (payload); same bytes under another file name → replayed", + "file": "live/t110/19-image-replay-other-image.json", + "ok": false, + "error.code": "operation_conflict", + "result.conflict": [ + "payload" + ], + "same_bytes_replay": [ + { + "code": "operation_replayed", + "message": "operation d9342f08-41b0-4338-a862-a659bdc64269 was already accepted as task 01a07f66-9cc1-7408-ad91-eec669ea0bf5 on 2026-09-08T05:03:46.613Z; no new request was sent" + } + ] + }, + { + "id": "T104-08", + "credential": "oauth", + "title": "OAuth bearer on v2 get and v1 asset download — same bytes as the API-key download", + "file": "live/t104/download-oauth.json", + "ok": true, + "files": [ + { + "key": "model.glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" + } + ], + "get_ok": true, + "legacy_get_keys": [ + "created_at", + "finished_at", + "id", + "model_urls", + "progress", + "resource", + "status", + "texture_urls", + "thumbnail_url", + "type" + ] + }, + { + "id": "T110-01", + "credential": "api_key", + "title": "project init (real workspace ~/meshy-live)", + "file": "live/t110/01-project-init.json", + "ok": true, + "result.project_dir": "/meshy-live/meshy_output/20260908_125453_live-teapot_d344", + "result.index.updated": true + }, + { + "id": "T110-02", + "credential": "api_key", + "title": "text-to-3d create --mode preview --async --project --save-json", + "file": "live/t110/04-preview-create.json", + "ok": true, + "result.task_id": "01a07f5e-81c7-7111-86f2-f6d2f80b2093", + "result.submission.state": "accepted", + "result.project.action": "added", + "result.saved_json.bytes": 55, + "credits": 20 + }, + { + "id": "T110-03", + "credential": "api_key", + "title": "stream (ndjson) on the finished preview: task + outcome, sequence contiguous", + "file": "live/t110/05-preview-stream.ndjson", + "events": 2, + "last": { + "events": 1, + "ended": "terminal", + "elapsed_seconds": 0.78 + } + }, + { + "id": "T110-04", + "credential": "api_key", + "title": "wait -o preview into the project (model.glb + thumbnail, meta.json, snapshot merged)", + "file": "live/t110/07-preview-wait-download.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.wait": { + "timed_out": false, + "elapsed_seconds": 0.85, + "polls": 1 + }, + "result.downloads.state": "completed", + "result.downloads.metadata_path": "/meshy-live/preview/meta.json", + "result.project.action": "merged", + "files": [ + { + "key": "model_glb", + "bytes": 34393124, + "status": "written", + "sha256_12": "38a2e495b5d2" + }, + { + "key": "thumbnail", + "bytes": 59226, + "status": "written", + "sha256_12": "6d0e766f6a49" + } + ] + }, + { + "id": "T110-05", + "credential": "api_key", + "title": "download --list on the preview", + "file": "live/t110/08-download-list.json", + "ok": true, + "assets": [ + [ + "model.glb", + "glb" + ], + [ + "thumbnail.primary", + "png" + ] + ] + }, + { + "id": "T110-06", + "credential": "api_key", + "title": "text-to-3d create --mode refine (glb,obj,fbx) --async", + "file": "live/t110/11-refine-create.json", + "ok": true, + "result.task_id": "01a07f61-6956-7630-a9a5-e0dcfd344683", + "result.submission.operation_id": "5e1b7a4d-d41a-4fdf-800a-7863a91cdb0d", + "result.project.action": "added", + "credits": 10 + }, + { + "id": "T110-07", + "credential": "api_key", + "title": "stream (ndjson) during the refine: 30 progress events + 1 outcome, contiguous sequence", + "file": "live/t110/12-refine-stream.ndjson", + "events": 31, + "last": { + "events": 30, + "ended": "terminal", + "elapsed_seconds": 213.25 + } + }, + { + "id": "T110-08", + "credential": "api_key", + "title": "wait -o refine: 9 files incl. OBJ+MTL+4 textures; real MTL map_Kd texture_0.png → texture_0_base_color.png by source_name; material_links complete", + "file": "live/t110/13-refine-wait-download.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "result.downloads.state": "completed", + "result.downloads.material_links.status": "complete", + "result.downloads.material_links.texture_maps": [ + { + "line": 12, + "material": "Material.005", + "reference": "texture_0.png", + "resolved_to": "texture_0_base_color.png", + "method": "source_name" + } + ], + "files": [ + { + "key": "model_glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" + }, + { + "key": "model_fbx", + "bytes": 99468604, + "status": "written", + "sha256_12": "915454648561" + }, + { + "key": "model_obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" + }, + { + "key": "model_mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "thumbnail", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + }, + { + "key": "texture_0_base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture_0_metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture_0_normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + }, + { + "key": "texture_0_roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" + } + ] + }, + { + "id": "T110-09", + "credential": "api_key", + "title": "download --model-format obj (selective, dependencies) — material_links complete", + "file": "live/t110/17-download-obj.json", + "ok": true, + "result.selection": { + "selected": [ + "model.obj" + ], + "dependencies": [ + "model.mtl", + "texture.0.base_color", + "texture.0.metallic", + "texture.0.roughness", + "texture.0.normal" + ] + }, + "result.downloads.state": "completed", + "result.downloads.material_links.status": "complete", + "files": [ + { + "key": "model.obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" + }, + { + "key": "model.mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "texture.0.base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture.0.metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture.0.roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" + }, + { + "key": "texture.0.normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + } + ] + }, + { + "id": "T110-10", + "credential": "api_key", + "title": "download --asset thumbnail.primary --output file", + "file": "live/t110/18-download-thumb.json", + "ok": true, + "files": [ + { + "key": "thumbnail.primary", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + } + ] + }, + { + "id": "T110-11", + "credential": "api_key", + "title": "image-to-3d create from a local synthetic PNG (data URI) — server-side FAILED, 0 credits, task_failed relayed, FAILED recorded in the project", + "file": "live/t110/16-image-wait-download.json", + "ok": false, + "error.code": "task_failed", + "result.task.status": "FAILED", + "result.task.consumed_credits": 0, + "result.task.task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + }, + "result.project.action": "merged", + "credits": 0 + }, + { + "id": "T110-12", + "credential": "api_key", + "title": "download --list / get on the FAILED task (not_ready + task_not_ready warning; task_error relayed)", + "file": "live/t110/21-failed-download-list.json", + "ok": true, + "result.downloads.state": "not_ready", + "warnings": [ + "task_not_ready" + ], + "get_task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + } + }, + { + "id": "T110-13", + "credential": "api_key", + "title": "image-to-3d retry with the real refine thumbnail: SUCCEEDED, OBJ/MTL relinked", + "file": "live/t110/23-image-create-retry.json", + "result.task_id": "01a07f6d-d56b-711b-8ebc-3fe0c1dd8541", + "wait": {}, + "status": "SUCCEEDED", + "consumed": 30, + "material_links": "complete", + "files": [ + { + "key": "model_glb", + "bytes": 80427848, + "status": "written", + "sha256_12": "31e43eba14c2" + }, + { + "key": "model_obj", + "bytes": 195493200, + "status": "written", + "sha256_12": "aa13ba15d5e3" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 47266, + "status": "written", + "sha256_12": "2c4a3a83bd4c" + }, + { + "key": "texture_0_base_color", + "bytes": 17044752, + "status": "written", + "sha256_12": "7af338cc630c" + }, + { + "key": "texture_0_metallic", + "bytes": 19055, + "status": "written", + "sha256_12": "6caf8476a1aa" + }, + { + "key": "texture_0_normal", + "bytes": 8612949, + "status": "written", + "sha256_12": "3ef26cf887cf" + }, + { + "key": "texture_0_roughness", + "bytes": 788016, + "status": "written", + "sha256_12": "67fc11c6adf9" + } + ], + "credits": 30 + }, + { + "id": "T110-14", + "credential": "api_key", + "title": "text-to-3d list --page-size 3", + "file": "live/t110/14-list.json", + "ok": true, + "result.count": 2 + }, + { + "id": "T110-15", + "credential": "oauth", + "title": "image-to-3d delete (the FAILED task) then get → not_found 404 exit 5", + "file": "live/t110/24-delete-failed.json", + "ok": true, + "result.deleted": true, + "get_after": { + "code": "not_found", + "http_status": 404 + } + }, + { + "id": "T111-01", + "credential": "api_key", + "title": "showcases list → 403 enterprise-only (account-gated): error.code server, http 403, exit 1", + "file": "live/t111/01-showcases-list.json", + "ok": false, + "error.code": "server", + "error.http_status": 403 + }, + { + "id": "T111-02", + "credential": "api_key", + "title": "animation-catalog list (public, free)", + "file": "live/t111/02-animation-catalog.json", + "ok": true, + "result.count": 8, + "result.total": 157 + }, + { + "id": "T111-03", + "credential": "api_key", + "title": "uv-unwrap on the 1.9M-face refine → API 400 (44k limit) mapped to validation exit 4; on the remeshed model SUCCEEDED", + "file": "live/t111/wait-uv-unwrap.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "model_glb", + "bytes": 212528, + "status": "written", + "sha256_12": "37651e05e3fa" + }, + { + "key": "thumbnail", + "bytes": 60098, + "status": "written", + "sha256_12": "73eafd0c48f2" + } + ], + "credits": 5 + }, + { + "id": "T111-04", + "credential": "api_key", + "title": "remesh (8000 faces, glb,obj) SUCCEEDED; OBJ set relinked", + "file": "live/t111/wait-remesh.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "result.downloads.material_links.status": "complete", + "files": [ + { + "key": "model_glb", + "bytes": 24213896, + "status": "written", + "sha256_12": "bb2bdc29e55f" + }, + { + "key": "model_obj", + "bytes": 757517, + "status": "written", + "sha256_12": "767bf14f8c46" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 61545, + "status": "written", + "sha256_12": "bd911dba6dfe" + }, + { + "key": "texture_0_base_color", + "bytes": 18108173, + "status": "written", + "sha256_12": "7ed2e4f3b659" + }, + { + "key": "texture_0_metallic", + "bytes": 900041, + "status": "written", + "sha256_12": "b7b51aa43b77" + }, + { + "key": "texture_0_normal", + "bytes": 8656095, + "status": "written", + "sha256_12": "681e3f286a1f" + }, + { + "key": "texture_0_roughness", + "bytes": 2289526, + "status": "written", + "sha256_12": "ab7395fccc69" + }, + { + "key": "texture_0_metallic_roughness", + "bytes": 5830658, + "status": "written", + "sha256_12": "262cc61894df" + } + ], + "credits": 5 + }, + { + "id": "T111-05", + "credential": "api_key", + "title": "retexture (text style prompt, PBR) SUCCEEDED", + "file": "live/t111/wait-retexture.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "files": [ + { + "key": "model_glb", + "bytes": 83810660, + "status": "written", + "sha256_12": "d7d18c8cb3de" + }, + { + "key": "thumbnail", + "bytes": 93635, + "status": "written", + "sha256_12": "d8804aba5986" + }, + { + "key": "texture_0_base_color", + "bytes": 19358023, + "status": "written", + "sha256_12": "0a467f028d73" + }, + { + "key": "texture_0_metallic", + "bytes": 237086, + "status": "written", + "sha256_12": "ef3aaeab62f0" + }, + { + "key": "texture_0_normal", + "bytes": 11468546, + "status": "written", + "sha256_12": "376b83e5b532" + }, + { + "key": "texture_0_roughness", + "bytes": 1027166, + "status": "written", + "sha256_12": "b474c3dfa34d" + } + ], + "credits": 10 + }, + { + "id": "T111-06", + "credential": "api_key", + "title": "analyze-printability SUCCEEDED (0 credits): report-only, meta.json written, status warning (degenerate faces)", + "file": "live/t111/wait-printability.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 0, + "result.task.printability.status": "warning", + "result.task.printability.metrics": { + "is_watertight": true, + "volume": 0.7804952440067714, + "non_manifold_edges": 0, + "degenerate_faces": 64868, + "holes": 0 + }, + "result.downloads.metadata_path": "/meshy-live/printability/meta.json", + "credits": 0 + }, + { + "id": "T111-07", + "credential": "api_key", + "title": "rigging on the teapot → API 400 (face limit) then 422 (pose estimation failed) → validation exit 4", + "file": "live/t111/sub-rigging.json", + "error.code": null, + "error.message": null, + "note": "the teapot is not a humanoid; see T111-11 for the successful rig" + }, + { + "id": "T111-08", + "credential": "api_key", + "title": "text-to-motion: CLI requires --duration (2–10 s); with --duration 4 SUCCEEDED, motion.fbx", + "file": "live/t111/wait-motion.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "files": [ + { + "key": "motion_url", + "bytes": 16825904, + "status": "written", + "sha256_12": "f34b68ced2fe" + } + ], + "credits": 10 + }, + { + "id": "T111-09", + "credential": "api_key", + "title": "humanoid text-to-3d preview for the rig chain", + "file": "live/t111/sub-humanoid.json", + "result.task_id": "01a07f72-0239-759f-850b-54081f7e12a0", + "result.submission.operation_id": "587d078c-7823-4a71-a795-e03a7a00259b", + "credits": 20 + }, + { + "id": "T111-10", + "credential": "oauth", + "title": "remesh the humanoid to 30k faces (rigging refused 1.95M faces with 400)", + "file": "live/t111/wait-humanoid-remesh.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "model_glb", + "bytes": 1300572, + "status": "written", + "sha256_12": "21400643996f" + }, + { + "key": "thumbnail", + "bytes": 108534, + "status": "written", + "sha256_12": "95fb5c2d7771" + }, + { + "key": "texture_0_normal", + "bytes": 4570614, + "status": "written", + "sha256_12": "3217a4b1b18f" + } + ], + "credits": 5 + }, + { + "id": "T111-11", + "credential": "oauth", + "title": "rigging SUCCEEDED: rigged glb/fbx + walking/running clips (8 files)", + "file": "live/t111/wait-rigging.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "rigged_character_fbx_url", + "bytes": 2033532, + "status": "written", + "sha256_12": "1ef48318a45d" + }, + { + "key": "rigged_character_glb_url", + "bytes": 2020516, + "status": "written", + "sha256_12": "59f350b4c8ec" + }, + { + "key": "basic_animations_walking_glb_url", + "bytes": 2033292, + "status": "written", + "sha256_12": "235da1bf00dd" + }, + { + "key": "basic_animations_walking_fbx_url", + "bytes": 2058316, + "status": "written", + "sha256_12": "f20d67b431b6" + }, + { + "key": "basic_animations_walking_armature_glb_url", + "bytes": 65392, + "status": "written", + "sha256_12": "20e066e440b3" + }, + { + "key": "basic_animations_running_glb_url", + "bytes": 2028688, + "status": "written", + "sha256_12": "9cd1dcc464a3" + }, + { + "key": "basic_animations_running_fbx_url", + "bytes": 2049660, + "status": "written", + "sha256_12": "04f1027412a8" + }, + { + "key": "basic_animations_running_armature_glb_url", + "bytes": 60788, + "status": "written", + "sha256_12": "3dc15c9cbe60" + } + ], + "credits": 5 + }, + { + "id": "T111-12", + "credential": "oauth", + "title": "animate create --action-id 28 (Big Wave Hello) on the rig: stream 10 events + outcome; wait -o glb+fbx", + "file": "live/t111/wait-animation.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 3, + "files": [ + { + "key": "animation_glb_url", + "bytes": 2085424, + "status": "written", + "sha256_12": "71cacbd652db" + }, + { + "key": "animation_fbx_url", + "bytes": 2133068, + "status": "written", + "sha256_12": "6516e604028a" + } + ], + "stream_events": 11, + "credits": 3 + }, + { + "id": "T111-13", + "credential": "oauth", + "title": "make 'a low-poly cactus…' -o: preview → refine, two journal records, downloads", + "file": "live/make/make.json", + "ok": true, + "result.route": "text", + "result.executed": [ + { + "step": 1, + "resource": "text-to-3d", + "action": "preview", + "task_id": "01a07f75-c6f0-7219-8a2e-65577ed29838", + "status": "SUCCEEDED", + "operation_id": "4ce4ad82-a125-49e1-a198-868b0b41bad4" + }, + { + "step": 2, + "resource": "text-to-3d", + "action": "refine", + "task_id": "01a07f76-c8a8-7731-bdbd-464304055493", + "status": "SUCCEEDED", + "operation_id": "a8227912-f16b-48b5-a155-1b3a429445e4" + } + ], + "result.submission": { + "state": "accepted", + "operation_id": "a8227912-f16b-48b5-a155-1b3a429445e4", + "task_id": "01a07f76-c8a8-7731-bdbd-464304055493" + }, + "result.task.status": "SUCCEEDED", + "result.downloads.state": "completed", + "files": [ + { + "key": "model_glb", + "bytes": 91624388, + "status": "written", + "sha256_12": "8de9537dfff7" + }, + { + "key": "thumbnail", + "bytes": 116190, + "status": "written", + "sha256_12": "7b1e09871729" + }, + { + "key": "texture_0_base_color", + "bytes": 22237173, + "status": "written", + "sha256_12": "4fffa8996db1" + }, + { + "key": "texture_0_metallic", + "bytes": 52871, + "status": "written", + "sha256_12": "dbe5bb6c6ecc" + }, + { + "key": "texture_0_normal", + "bytes": 15698341, + "status": "written", + "sha256_12": "68716ce5b0b5" + }, + { + "key": "texture_0_roughness", + "bytes": 1287400, + "status": "written", + "sha256_12": "fbe3aaeab1fa" + } + ], + "credits": 30 + }, + { + "id": "T111-14", + "credential": "oauth", + "title": "creative-lab lamp prototype create; get/wait on the IN_PROGRESS task FAILED with 'unexpected task shape' (finished_at: null) → live finding L01", + "file": "live/creative/lamp-proto-wait.json", + "ok": false, + "error.code": "server", + "error.http_status": 200, + "message_head": "unexpected task shape from GET /lamp/v1/prototype/01a07f79-e9f7-7347-89c7-c46fb7a11d06: [\n {\n \"expected\": \"number\",\n", + "credits": 30 + }, + { + "id": "T111-15", + "credential": "oauth", + "title": "after the L01 fix (reinstalled CLI): lamp prototype get/wait SUCCEEDED (lampshade glb + concept image)", + "file": "live/creative/lamp-proto-wait3.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.wait.polls": 1, + "files": [ + { + "key": "model_glb", + "bytes": 4483108, + "status": "written", + "sha256_12": "62b8179c70b4" + }, + { + "key": "thumbnail", + "bytes": 69479, + "status": "written", + "sha256_12": "2fe40b2143fa" + }, + { + "key": "image_0", + "bytes": 678713, + "status": "written", + "sha256_12": "121c950c3694" + } + ] + }, + { + "id": "T111-16", + "credential": "oauth", + "title": "keychain prototype create + immediate wait polled through 3 IN_PROGRESS states to SUCCEEDED (L01 verified live)", + "file": "live/creative/keychain-proto-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "result.wait.polls": 4, + "files": [ + { + "key": "image_0", + "bytes": 477978, + "status": "written", + "sha256_12": "e4eab2f32244" + } + ], + "credits": 6 + }, + { + "id": "T111-17", + "credential": "oauth", + "title": "lamp build SUCCEEDED — legacy -o named the parts model.lamp_stl/model.base_stl → live finding L02", + "file": "live/creative/lamp-build-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "files": [ + { + "key": "model_base_stl", + "bytes": 360284, + "status": "written", + "sha256_12": "5cbf2af2866c" + }, + { + "key": "model_lamp_stl", + "bytes": 15162184, + "status": "written", + "sha256_12": "94f5809a85df" + } + ], + "credits": 6 + }, + { + "id": "T111-18", + "credential": "oauth", + "title": "meshy download --list/--all on the lamp build names lamp.stl / base.stl (selective path was right)", + "file": "live/creative/lamp-build-selective.json", + "ok": true, + "files": [ + { + "key": "model.base_stl", + "relative_path": "lamp-build-selective/base.stl", + "format": "stl" + }, + { + "key": "model.lamp_stl", + "relative_path": "lamp-build-selective/lamp.stl", + "format": "stl" + } + ] + }, + { + "id": "T111-19", + "credential": "oauth", + "title": "keychain builds: default (glb) and --model-format obj (ZIP bundle); legacy -o saved the bundle as model.obj → L02; selective path: model.obj.zip", + "file": "live/creative/keychain-build-obj-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "legacy_files": [ + { + "key": "model_obj", + "bytes": 46871892, + "status": "written", + "sha256_12": "bf665456914a" + } + ], + "selective": [ + { + "key": "model.obj", + "relative_path": "keychain-build-selective/model.obj.zip", + "format": "zip", + "container_format": "zip" + } + ], + "credits": 60 + }, + { + "id": "T111-20", + "credential": "oauth", + "title": "after the L02 fix (reinstalled CLI): -o on the same builds → lamp.stl/base.stl (byte-identical) and model.obj.zip", + "file": "live/creative/lamp-build-fixed.json", + "ok": true, + "lamp_files": [ + [ + "model_base_stl", + "base.stl" + ], + [ + "model_lamp_stl", + "lamp.stl" + ] + ], + "keychain_files": [ + [ + "model_obj", + "model.obj.zip" + ] + ] + }, + { + "id": "T111-21", + "credential": "oauth", + "title": "figure prototype SUCCEEDED; first figure build FAILED server-side (0 credits, task_failed relayed); retry SUCCEEDED with OBJ/MTL relinked", + "file": "live/creative/figure-build-wait2.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "result.downloads.material_links.status": "complete", + "first_attempt": { + "status": "FAILED", + "consumed_credits": 0, + "task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + } + }, + "files": [ + { + "key": "model_glb", + "bytes": 78663044, + "status": "written", + "sha256_12": "24c8443380ed" + }, + { + "key": "model_obj", + "bytes": 199049675, + "status": "written", + "sha256_12": "653af29a67bd" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 55346, + "status": "written", + "sha256_12": "24e69b643114" + }, + { + "key": "texture_0_base_color", + "bytes": 18874922, + "status": "written", + "sha256_12": "09bd7ed10caa" + } + ], + "credits": 36 + }, + { + "id": "T111-22", + "credential": "oauth", + "title": "fridge-magnet prototype + build (exposed product) SUCCEEDED with the fixed CLI: bundle named by the shared mapping", + "file": "live/creative/magnet-build-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "prototype": { + "status": "SUCCEEDED", + "consumed_credits": 6 + }, + "files": [ + [ + "model_glb", + "model.glb", + 17185876 + ] + ], + "credits": 36 + }, + { + "id": "T112-01", + "credential": "none", + "title": "slicer detect finds Bambu Studio 02.08.02.61; slicer open on a prepared print OBJ launches it (macOS open -a, pid observed)", + "file": "live/t112/open.json", + "ok": true, + "result.launch_requested": true, + "result.slicer.name": "Bambu Studio", + "result.slicer.path": "/Applications/BambuStudio.app", + "result.launcher": { + "command": "open", + "exit_code": 0 + } + }, + { + "id": "T112-02", + "credential": "none", + "title": "mesh prepare-print on the real remeshed OBJ (60 mm) → print OBJ + copied MTL/texture; slicer open again on that file", + "file": "live/t112/prepare-real.json", + "ok": true, + "result.output": "/meshy-live/print/teapot.print.obj", + "open": { + "launch_requested": true, + "pid": 17811 + } + }, + { + "id": "LOCAL-01", + "credential": "none", + "title": "project show/list on the real project: 11+ task entries with snapshots and operation ids; history index clean", + "file": "live/local/project-show.json", + "ok": true, + "tasks": 11, + "index_dirty": false + }, + { + "id": "LOCAL-02", + "credential": "none", + "title": "inspect faces on real task snapshots → check_unknown (13): the API task JSON carries no face_count", + "file": "live/local/inspect-remesh.json", + "ok": false, + "error.code": "check_unknown", + "result.verdict": "unknown", + "result.reason": "face_count missing" + } + ], + "t109_linux": [ + { + "platform": "linux/arm64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, native" + }, + { + "platform": "linux/x64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, linux/amd64 emulated" + } + ], + "not_run": [ + "T-109 Windows x64 (no host)", + "MESHY_API_KEY env / --api-key-file variants against the real API (offline tests only; the stored API-key profile was used live)", + "showcases content (account is not enterprise: 403 recorded)", + "auth logout/revoke on the owner's real profiles (left to the owner)" + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ] +} diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md new file mode 100644 index 0000000..074bd0c --- /dev/null +++ b/docs/skill-parity/migration-notes.md @@ -0,0 +1,193 @@ +# Skill-parity S1 — migration notes + +Mapping from the legacy Python helpers bundled with the Meshy Skills +(`meshy_task.py`, `fix_obj.py`, `slicers.py` at `b9db44b`) to `meshy-cli`, plus every +intentional difference. S2 (Skill rewrite) must use `--output-schema v1 --format json` +explicitly in its examples. + +## 1. Command mapping + +| Legacy call | CLI equivalent | Notes | +| --- | --- | --- | +| `meshy_task.py check-env` | `meshy doctor [--api-key-file .env] [--check-api]` | Local by default; `--check-api` does one free balance call. No `.env` auto-scan (see 2.6). | +| `meshy_task.py balance` | `meshy balance --output-schema v1` | unchanged endpoint | +| `meshy_task.py create --endpoint E --payload J` | `meshy create --data '' --async --output-schema v1` | one POST, no poll; `result.task.task_id` | +| `meshy_task.py poll --endpoint E --task-id ID [--project-dir D]` | `meshy wait ID --timeout 600 [--project D] --output-schema v1` | saves `task_.json` into the project when `--project` is given | +| `meshy_task.py get --endpoint E --task-id ID [--save F]` | `meshy get ID [--save-json F] [--include-raw] --output-schema v1` | non-terminal status exits 0 | +| SSE curl/Python examples | `meshy stream ID --format ndjson --output-schema v1` | new | +| `meshy_task.py download --task-json F --format glb --output P` | `meshy download --task-json F --model-format glb --output P --output-schema v1` | selection is explicit | +| `meshy_task.py download --url U --output P` | `meshy download --url U --output P --output-schema v1` | no Authorization is sent to asset hosts | +| nested `result.basic_animations.walking_glb_url` via python | `meshy download --task-json F --asset result.basic_animations.walking_glb_url --output walking.glb` | stable asset keys | +| `meshy_task.py thumbnail --project-dir D --task-json F` | `meshy download --task-json F --kind thumbnail --output-dir D` | fails loudly instead of swallowing errors | +| `meshy_task.py project-dir --task-id ID --prompt P` | `meshy project init --root ./meshy_output --task-id ID --name P --output-schema v1` | folder slug + timestamp + random suffix | +| `meshy_task.py record --project-dir D --task-id ID --task-type T --stage S --files a,b` | `meshy project record --project D --task-id ID --resource T --stage S --file a --file b` | `--file` repeats; `(task_id, stage)` de-duplicates | +| — | `meshy project show / list / rebuild-index` | new; history is a rebuildable index | +| `meshy_task.py check-faces --endpoint E --task-id ID --max-faces N` | `meshy inspect faces --resource R --task-id ID --max-faces N` | `--max-faces` is required; missing face count = `unknown` (exit 13), never 0 | +| `fix_obj.py model.obj --height-mm 75` | `meshy mesh prepare-print model.obj --height-mm 75 [--output F | --in-place]` | default writes `model.print.obj`, no in-place overwrite unless asked | +| `slicers.py detect` | `meshy slicer detect --output-schema v1` | same seven slicers + `multicolor` | +| `slicers.py open --file F --slicer S` | `meshy slicer open --slicer S --file F --output-schema v1` | no default-app fallback; detected path only; `launch_requested` is not proof of import | +| curl `/web/public/animations/resources?category=C` + python filter | `meshy animation-catalog list --category C --search wave` | no key, no Authorization; search is local | +| curl `/openapi/v1/showcases?...` | `meshy showcases list --search car --page-size 3 --model-format glb` | billable GET, single request, no retry | +| curl `/openapi/v1/uv-unwrap` | `meshy uv-unwrap create --input-task-id ID` or `--model-url m.glb` | exactly one source | +| curl `/openapi/creative-lab/{product}/v1/prototype` | `meshy creative-lab prototype create --image-url photo.png [--name N] [--remove-background] [--image-subject …]` | per-product schema | +| curl `/openapi/creative-lab/{product}/v1/build` | `meshy creative-lab build create --input-task-id ID [--options ''] [--model-format …]` | per-product options | + +## 2. Intentional differences + +Each difference names the legacy behaviour, the CLI behaviour, and the evidence. + +### 2.1 Missing `face_count` is `unknown`, not 0 +- Legacy: `task.get("face_count", 0)` then `0 > max` → prints a passing line. +- CLI: `verdict: unknown`, exit 13, `reason: face_count missing`. Evidence: public task + DTO has no `face_count` (meshyd `httpapi/dto.go`, read-only 2026-09-07); Rigging docs + only describe the server-side 300k gate. + +### 2.2 OBJ transform never overwrites by default +- Legacy `fix_obj.py`: default `output_path = input_path`; degenerate height silently + used scale 1.0; NaN passed through. +- CLI: default sibling `.print.obj`; `--in-place` opt-in; NaN/Infinity, empty or + degenerate (height ≤ 1e-6) input is a validation error and no file is written. + Numbers follow the same rotation/scale/translation formulas (fixture oracle + `box-height-80.expected.json`). + +### 2.3 Slicer launch uses the detected executable +- Legacy Windows path looked up the exe on PATH and fell back to `os.startfile` + (default application); Linux fell back to `xdg-open`. +- CLI: launches only registered slicers at their detected path; missing slicer or + file is an explicit error (exit 11 / 5), no default-application fallback. + +### 2.4 Downloads are selective +- Legacy `download --format` defaulted to `glb`; thumbnails were swallowed on error. +- CLI `download` requires a selector (`--asset`, `--model-format`, `--kind`, `--all`) + when more than one asset exists and reports every failure. The legacy `-o` on task + commands keeps downloading everything for compatibility. + +### 2.5 Keychain / fridge-magnet OBJ output is a ZIP +- Legacy docs generalised "textured GLB / OBJ+MTL" for every build. +- CLI records `model_format: obj, container_format: zip, extracted: false` and saves the + file as `.zip`; lamp parts `lamp_stl`/`base_stl`/`bundle_zip` are saved as `.stl` / + `.zip`. Evidence: official lamp/keychain/fridge-magnet pages and meshyd artifact keys. + +### 2.6 No implicit `.env` discovery +- Legacy: read `.env` / `.env.local` from cwd automatically. +- CLI: `--api-key-file ` must be explicit; `doctor` may report that a candidate + file exists in cwd but never reads it. Priority: `--api-key` > `MESHY_API_KEY` > + `--api-key-file` > stored profile. A named file that is missing, malformed or + key-less is an error, never a fall-through to another account. Empty/placeholder + `--api-key` and `MESHY_API_KEY` still mean "unset" (0.2.0 behaviour). +- The flag is not called `--env-file` because Node.js itself intercepts that name + anywhere in argv (loads the whole file into the environment, exits 9 when missing); + see decisions D-025. Passing `--env-file` to the CLI yields a usage error. + +### 2.7 `showcase_type` spelling +- Docs/Skill: `animated`. Server enum: `animate`. CLI accepts both, sends `animate`, and + warns (`showcase_type_alias`). Needs live confirmation (not_run). + +### 2.8 Lamp `text` prototype input is rejected +- The server struct still carries a deprecated `text` field; the CLI rejects `text` in + `--data` for lamp prototypes before submission and points to `--image-url`. + +### 2.9 Rigging `list` +- CLI 0.2.0 said "Meshy does not expose a list endpoint for rigging". Official docs and + the server route table do; `meshy rigging list` now works. (Difference from 0.2.0, + not from the Skills.) + +### 2.10 Data URIs +- CLI 0.2.0 rejected `data:` URIs on typed media flags while accepting them inside + `--data`. S1 accepts well-formed `data:` URIs on both paths with the same size/format + checks (Skill examples build data URIs in Python). + +### 2.11 No automatic retries +- Legacy troubleshooting suggested auto-retrying 429/5xx creates. The CLI never + re-sends a billable POST or the billable showcases GET; retries are the caller's + decision with the recorded task/operation id. + +### 2.12 Webhooks stay documentation +- No webhook management endpoint exists in the frozen baseline; the CLI adds no daemon. + +## 3. Compatibility notes for existing CLI users (legacy schema) + +| Behaviour | 0.2.0 | S1 | Why | +| --- | --- | --- | --- | +| `get` of PENDING/IN_PROGRESS task | exit 1 | exit 0 | a successful query is not a failure | +| `make --async` | POST + poll step 1, then stop | POST only, return `pending_steps` | contract; old behaviour is `--stop-after-first` | +| Commander parse errors | exit 1 | exit 2 | README already documented `2 usage` | +| `rigging list` | usage error | works | see 2.9 | +| everything else (flags, payload defaults, `-o` download layout, `meta.json`, auth) | unchanged | unchanged | legacy output preserved; v1 is opt-in | + +### 3.1 Corrections from Codex review round 1 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `create`/`make -o ` or `--save-json ` | refused after the task ran (exit 2 / 11) | refused **before** the POST, exit 11, "nothing was submitted" | F01 | +| failure after the server accepted a task (save, poll 5xx, download, record) | `result: null` / bare API error | same exit code, `result.task_id` + `submission` + `next` kept | F01, F02 | +| `wait`: reply arriving after `--timeout` | reported as in-time success; one extra GET possible | exit 8 (`timed_out`), no GET after the deadline | F07 | +| `--workspace` on `-o` (task verbs, `make`), `project`, `--project` | not enforced | exit 11 before any write | F03 | +| `mesh prepare-print` material copy through a symlinked `materials/` | written outside | exit 11, nothing written | F04 | +| `--operation-id` with another API key / account or another image of equal size | replayed the old task | `operation_conflict` (exit 2), no request | F05, F06 | +| Creative Lab `--data.options` + `--options` | `--options` replaced the object | merged field by field | F08 | +| downloaded OBJ/MTL references | pointed at server-side names | rewritten to the saved names, reported | F09 | +| `stream --format ndjson -o` | assets not downloaded | downloaded; `outcome` carries the manifest | F10 | + +### 3.2 Corrections from Codex review round 2 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| report-only task `-o` outside `--workspace` | file written | exit 11, no directory created | R2-F01 | +| `--workspace` = project dir, implicit history root | `history.json` written in the parent | metadata recorded, `index.updated=false` with the reason, parent untouched | R2-F01 | +| refused `download` target | directory created before the refusal | nothing created | R2-F01 | +| OBJ with several material groups | every same-channel map → first texture | mapped by the server-side file name; ambiguous maps kept as written + `material_reference_ambiguous`, `material_links.status=incomplete` | R2-F02 | +| `stream` + `--save-json`/`--project` failure | bare error envelope after the task events | one `outcome` line (sequence continues) / one envelope | R2-F03 | +| task `-o` fails on the 2nd asset | `downloads.files: []`, `local_io` | `downloads.state=partial` with a per-file manifest; HTTP class and status kept (legacy error payload gains `code`/`status`/`result.downloads`) | R2-F04 | +| Ctrl-C during a task `-o` transfer | download completes, exit 0 | exit 130 `interrupted`, manifest of what landed, temp file removed | R2-F05 | +| OAuth profile without `user_id` and the same `--operation-id` | replayed the other login's task | `login_id` minted at login binds the journal; profiles with neither are refused a replay (exit 2, `credential_unverified`) until `meshy auth login` | R2-F06 | + +### 3.3 Corrections from Codex review round 3 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| a file/symlink/directory appears at `meta.json` (or `_meta.json`) during the transfer | followed or truncated, even outside `--workspace` | refused at publication (`local_io`), model kept in the manifest, nothing outside touched | R3-F01 | +| legacy-schema `-o` failure after a `create`/`wait`/`get`/`stream`/`make` | payload without the task | additive `task_id`/`operation_id` fields, `result.submission`/`next`/manifest, hint names the task on stderr | R3-F02 | +| MTL reference equal to a generated texture name that belongs to another source | rewritten to the wrong image, `complete` | mapped by the server-side name; a generated-name reference with a different source is `ambiguous` (kept, warned, `incomplete`) | R3-F03 | +| relink/digest/sidecar failure after every asset landed | `downloads.files: []` | `downloads.state=partial`, full manifest with on-disk digests, `failed_step` | R3-F04 | +| Ctrl-C during the OBJ/MTL rewrite | download completes, exit 0 | exit 130 `interrupted`, committed files kept, no sidecar, no temp file | R3-F05 | +| `download --project` through an alias path | files not recorded, false `files_outside_project` | files recorded relative to the real project directory | R3-F06 | + +### 3.4 Corrections from Codex review round 4 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| MTL whose different references reach the sole texture only through channel fallbacks (name channel for one, key channel for the other) | both rewritten to the one texture, `material_links.status=complete` | both kept as written, `method: ambiguous` with one shared `note`, `status=incomplete`, one `material_reference_ambiguous` warning; identity matches (source name) keep their texture | R4-F01 | +| `download --project` when the project record fails after the transfer (metadata.json replaced by a symlink, damaged, unwritable directory, lock) | exit 11 with `result: null` | exit 11 (same class), full `result` incl. `downloads` manifest and `saved_json`, `project.action="failed"` with `error`, `error.recovery.action="record_project"` and `hint` = the `meshy project record …` command; assets kept, nothing re-downloaded or re-submitted | R4-F02 | +| `download --project` with a metadata.json that is a symlink or not valid JSON, or a blank `--stage` | detected after the transfer | refused before any request ("nothing was downloaded") | R4-F02 | +| task verbs `--project` record failure | `local_io` without a recovery | same class, plus `recovery.action="record_project"` and the record command as hint | R4-F02 | + +### 3.5 Corrections from Codex review round 5 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `record_project` recovery command after `--project P --workspace P` | no `--workspace`; replayed, it refreshed the parent's history.json and locked outside the boundary | carries `--workspace ` (shell-quoted); replayed verbatim it records metadata, skips the parent index with `index_dirty`, writes nothing outside; no workspace → nothing appended | R5-F01 | +| MTL reference used under two keys where one key hits a texture and the other is ambiguous (`map_Kd shared.png` + `map_Bump shared.png`, one base color, two normals) | the hit line rewritten, the other left | neither line rewritten; both `ambiguous` with their own candidates and one cross-key note ("one reference names one file") | R5-F02 | +| task verbs `--project` when metadata.json disappears (or the project leaves the workspace) after the preflight | `local_io`, `recovery: null`, hint = `wait` | missing/damaged/locked → `record_project` command with `--operation-id` and `--workspace` (hint too); project outside the workspace → explicit boundary message, task and journal named, no command | R5-F03 | +| `download --project` when metadata.json disappears during the transfer | `not_found` (exit 5) from the record step | `local_io` (exit 11) with the full result and the record_project recovery | R5-F03 | + +### 3.6 Corrections from Codex review round 6 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `--workspace W` directory (or the alias it was given through) replaced by a symlink to an outside tree while a request is in flight; task verbs with `--project` | root re-resolved at check time → snapshot/metadata/history written outside, exit 0 | boundary frozen with the flags (real path + directory identity); exit 11 `local_io`, task/journal kept, single POST, nothing written outside, `recovery: null`, no record command | R6-F02 | +| `download --project P --workspace W --output-dir W/assets` with P or its parent replaced by a symlink to an outside project during the transfer | outside metadata.json rewritten, exit 0 with `files_outside_project` | exit 11 `local_io`, completed manifest kept, `project.action="failed"` with the reason, `recovery: null`, outside tree untouched | R6-F01 | +| `get`/`wait`/`stream` with `--project` that is not an initialised project | refused after the request | refused before any request | R6-F02 (D-057) | + +### 3.7 Corrections from the live (real-account) verification (visible in both schemas) + +| Behaviour | before the fix | after | Decision | +| --- | --- | --- | --- | +| `creative-lab … get`/`wait` while the task is IN_PROGRESS (the endpoint returns `finished_at: null`) | `server` error "unexpected task shape", HTTP 200, exit 1 on every poll until completion | parses; null timestamps/counts read as 0 (v1 view shows `null` as before); `wait` polls through | D-059 | +| task verbs `-o` on a Creative Lab lamp build / keychain OBJ build | `model.lamp_stl`, `model.base_stl`; the OBJ ZIP bundle saved as `model.obj` | `lamp.stl`, `base.stl`, `model.obj.zip` — the same names `meshy download` uses; slot keys unchanged | D-060 | + +New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, +`--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, +`--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), +`--idle-timeout` (stream). New commands: `uv-unwrap`, `creative-lab`, `animation-catalog`, +`showcases`, `download`, `project`, `inspect`, `mesh`, `slicer`, `doctor`. diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json new file mode 100644 index 0000000..9e0691f --- /dev/null +++ b/docs/skill-parity/verification.json @@ -0,0 +1,2456 @@ +{ + "schema_version": 1, + "status": "G1-code accepted by Codex rounds 7 (e567646) and 8 (1d9f109: live fixes L01/L02 closed); live verification against the owner's real account completed 2026-09-08; Windows x64 not_run (skipped by the owner's decision); release authorised 2026-09-08 and in progress via release.yml — G1-release is recorded only after the publish is verified", + "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", + "environment": { + "os": "macOS 26.6.2 (Darwin 25.6.0) arm64", + "node": "v24.20.0", + "pnpm": "11.24.0" + }, + "baseline_runs": [ + { + "command": "pnpm install --frozen-lockfile", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 10, + "result": "passed" + }, + { + "command": "pnpm typecheck", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm test", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 41, + "result": "passed", + "tests": 363, + "pass": 363, + "fail": 0 + }, + { + "command": "pnpm build", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + } + ], + "final_runs": [ + { + "command": "node --version", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm --version", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm install --frozen-lockfile", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + }, + { + "command": "pnpm typecheck", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm build", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm test", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 45, + "result": "passed", + "tests": 558, + "pass": 558, + "fail": 0, + "skipped": 0 + }, + { + "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 7, + "result": "passed" + }, + { + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/meshy-cli-0.3.0.tgz", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 7, + "result": "passed" + }, + { + "command": "git diff --check fd94490", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "git diff --check 4da216d", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "git diff --check", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 15, + "result": "passed", + "detail": "12/12 runs; deterministic + real-timer smoke" + }, + { + "command": "for i in 1..8: node --import tsx --test tests/codex-review-round1.test.ts", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 49, + "result": "passed", + "detail": "8/8 runs; R03 asserts the D-044 invariants" + }, + { + "command": "for i in 1..3: node --import tsx --test tests/codex-review-round6.test.ts tests/codex-review-round5.test.ts tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "duration_s": 24, + "result": "passed", + "detail": "3/3 runs; timing-sensitive review regressions" + } + ], + "behavior_tests": { + "T-001": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-002": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/result.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-003": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-004": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-005": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-006": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-007": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-008": { + "result": "passed", + "files": [ + "tests/task-view.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-009": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", + "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", + "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)", + "review_round_4": "R4-T01: make's task_id, submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's (distinct step ids; legacy/v1 × asset 503/SIGINT)", + "review_round_5": "R5-F03 E03: legacy/v1 × get/wait/stream/create --async/sync × metadata missing/damaged → exit 11 with task_id, submission.operation_id = the single accepted journal record, record_project command with --operation-id and --workspace (legacy hint too)", + "review_round_6": "R6-F02: the accepted task, its single POST and journal record survive the boundary refusal; legacy payload names task_id/operation_id; recovery null, no record command" + }, + "T-010": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", + "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", + "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" + }, + "T-011": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/result.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-012": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-020": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-021": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-022": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts", + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L01: Creative Lab in-progress body with finished_at: null parses (fixture captured live); get/wait poll through" + }, + "T-023": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts", + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L02: legacy -o names lamp parts lamp.stl/base.stl and the keychain OBJ bundle model.obj.zip" + }, + "T-024": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L01/L02 via creative-lab lamp/keychain build get -o" + }, + "T-025": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts", + "tests/live-verification.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F08: R04 three-layer options merge on the real POST body", + "live_fix": "L01: creative-lab lamp prototype wait polls through IN_PROGRESS (null timestamps) to SUCCEEDED" + }, + "T-026": { + "result": "passed", + "files": [ + "tests/catalog-showcases.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-027": { + "result": "passed", + "files": [ + "tests/catalog-showcases.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-028": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F08: R04" + }, + "T-029": { + "result": "passed", + "files": [ + "tests/file-input.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" + }, + "T-030": { + "result": "passed", + "files": [ + "tests/file-input.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-031": { + "result": "passed", + "files": [ + "tests/transport.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-032": { + "result": "passed", + "files": [ + "tests/transport.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-040": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-041": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" + }, + "T-042": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F02: R11" + }, + "T-043": { + "result": "passed", + "files": [ + "tests/operation-store.test.ts", + "tests/task-lifecycle.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-044": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-045": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", + "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST", + "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1", + "review_round_5": "R5-F03 E03: exactly one POST / one journal record per create; the recovery never re-submits; a project that escaped the workspace → boundary message, task + operation named, recovery null", + "review_round_6": "R6-F02: exactly one POST per create in the 20-entry mutable-workspace matrix; exact method/path sequences (R6-T01 also applied to the round-5 E03 matrix)" + }, + "T-046": { + "result": "passed", + "files": [ + "tests/operation-store.test.ts", + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" + }, + "T-047": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/poll.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", + "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline", + "review_round_4": "R03 'expiry during the sleep' asserts the D-044 invariants (test-only commit 68690f9); 8/8 standalone runs" + }, + "T-048": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F01: task context on every post-acceptance failure", + "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", + "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt", + "review_round_4": "R4-T01: SIGINT during make's final download under both schemas keeps the last step's identity; no final file" + }, + "T-049": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-050": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-051": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-052": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-053": { + "result": "passed", + "files": [ + "tests/sse.test.ts", + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F10: R10", + "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" + }, + "T-060": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-061": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-062": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", + "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", + "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous", + "review_round_4": "R4-F01 D01: two references reaching the sole texture only through channel fallbacks stay as written in both orders (MTL byte-identical, one shared note, digests match disk); arbitration unit cases (identity vs heuristic, ambiguous rival, one reference/two keys, lone and distinct-channel fallbacks)", + "review_round_5": "R5-F02 E02: one reference under two keys (hit + ambiguous) stays as written in both orders; 8x2 arbitration matrix (identity, ambiguous rival, same-ref two hits / hit+ambiguity / same identity / same fallback, distinct channels) as a repository test", + "review_round_6": "R6-T01: E02 binds each line's candidate set to its key (line 2 / line 3 in both orders)" + }, + "T-063": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-064": { + "result": "passed", + "files": [ + "tests/download-command.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", + "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets", + "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request", + "review_round_5": "R5-F01 E01: the record_project command carries the original --workspace (quoted path with a space and a quote); replayed verbatim it records with 0 requests, parent history.json bytes unchanged, index_dirty; no workspace → none appended", + "review_round_6": "R6-F01: download --project with the project leaf or parent swapped for a symlink to an outside project during the asset GET (task-json and API sources) → exit 11, completed manifest kept, project.failed with recovery null, outside tree byte-identical, exact requests; healthy control still records" + }, + "T-066": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-067": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-068": { + "result": "passed", + "files": [ + "tests/download-command.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", + "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", + "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants", + "review_round_4": "R4-F02 D02: outside metadata bytes and the original metadata stay unchanged; planted symlink not replaced; the recovery command records exactly the downloaded file with no request", + "review_round_5": "R5-F01 E01: nothing appears in the parent directory during the replay (no lock, no temp); outside metadata untouched", + "review_round_6": "R6-F01/R6-F02: whole outside trees (metadata.json, history.json, snapshots, locks, temps) compared by digest before/after — nothing written across the boundary; the original workspace/project trees untouched too" + }, + "T-069": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-070": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-071": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-072": { + "result": "passed", + "files": [ + "tests/project-store.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", + "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", + "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded", + "review_round_4": "R4-F02 D02: project.action=failed with error + recovery; damaged metadata.json is left for repair; unwritable project directory (lock cannot be created) is local_io with the result; healthy project still records", + "review_round_5": "R5-F01/R5-F03: index_dirty only ever means metadata committed + history untouched; verbatim replay across download and the 20 task-verb entries; a project that left the workspace gets no command", + "review_round_6": "R6-F02: the boundary is frozen with the flags (real path + directory identity); a workspace directory swapped for a symlink or a workspace alias re-pointed while the request is in flight is refused across legacy/v1 × get/wait/stream/create --async/sync create; stable aliases keep recording (snapshot in the real project, index refreshed)" + }, + "T-073": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-074": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-075": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-076": { + "result": "passed", + "files": [ + "tests/project-store.test.ts", + "tests/codex-review-round2.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" + }, + "T-077": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-080": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-081": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-082": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-083": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-084": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-085": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-086": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts", + "tests/codex-review-round1.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" + }, + "T-087": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-088": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-089": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-090": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-091": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-100": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/env-file.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F05: R01 API key digest binding", + "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" + }, + "T-101": { + "result": "passed", + "files": [ + "tests/env-file.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-102": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/doctor.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-103": { + "result": "passed", + "files": [ + "tests/doctor.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "T-105": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", + "review_round_2": "R2-F06 N07" + }, + "T-104": { + "result": "passed", + "files": [ + "tests/auth-headless.test.ts" + ], + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" + } + }, + "live_verification": [ + { + "id": "T-104", + "scope": "API Key and OAuth profile regression against a real account", + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "real account, credentials entered by the owner (meshy auth login --with-key → profile 'default'; meshy auth login → profile 'oauth', browser PKCE); both used live for creates/gets/downloads; the real token endpoint returned no user_id — identity falls back to the minted login_id (present); silent refresh observed at expiry (expires_at advanced, login_id kept, tokens rotated, refreshed token verified by balance); journal replay with the same key → operation_replayed, same operation id under the other credential → operation_conflict (credential), different image bytes → operation_conflict (payload), same bytes under another name → replayed", + "steps": [ + "T104-01", + "T104-02", + "T104-03", + "T104-04", + "T104-05", + "T104-06", + "T104-07", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json", + "not_run": [ + "MESHY_API_KEY env and --api-key-file against the real API (offline tests only)", + "auth logout/revoke of the owner's real profiles" + ] + }, + { + "id": "T-109", + "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", + "result": "partial", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "macOS 26.6.2 arm64 Node v24.20.0: tarball install + 29 smoke checks passed; Linux (OrbStack docker node:24-bookworm): linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, native); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, linux/amd64 emulated); the owner also installed the same tarball globally (npm install -g ~/Downloads/meshy-cli-0.3.0.tgz) and ran every live command through that install. Windows x64: not_run (no host; the owner decided on 2026-09-08 to skip it for this release)", + "linux": [ + { + "platform": "linux/arm64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, native" + }, + { + "platform": "linux/x64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, linux/amd64 emulated" + } + ] + }, + { + "id": "T-110", + "scope": "real free queries, existing task get, asset download", + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "new tasks created and fetched under both credentials: text-to-3d preview (20) → refine (10, glb/obj/fbx + 4 textures; real MTL relinked by source name, material_links complete), stream over real SSE (30 progress events + outcome, contiguous), wait -o into a project, selective download (--model-format obj with dependencies, --asset thumbnail --output), --list, list, delete → not_found; image-to-3d from a local PNG (data URI): first task FAILED server-side (0 credits, task_failed relayed, FAILED recorded in the project), retry with a real thumbnail SUCCEEDED (30); public animation catalog GET", + "steps": [ + "T110-01", + "T110-02", + "T110-03", + "T110-04", + "T110-05", + "T110-06", + "T110-07", + "T110-08", + "T110-09", + "T110-10", + "T110-11", + "T110-12", + "T110-13", + "T110-14", + "T110-15" + ] + }, + { + "id": "T-111", + "scope": "UV Unwrap / Creative Lab / showcases smoke", + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "uv-unwrap: 400 on a 1.9M-face model mapped to validation/4, SUCCEEDED on the remeshed model (5); remesh (5), retexture (10), analyze-printability (0, report), text-to-motion (10), rigging (400/422 on the teapot → validation/4; SUCCEEDED on a remeshed humanoid, 5), animate action 28 (3), make two-step chain (30); Creative Lab: lamp prototype (30) + build (6), keychain prototype (6) + glb build (30) + obj-bundle build (30), figure prototype (6) + build (first attempt FAILED server-side with 0 credits, retry 30), fridge-magnet prototype + build (36). Two defects found and fixed live: L01 (null finished_at rejected → get/wait failed while IN_PROGRESS) and L02 (legacy -o mis-named lamp parts and the keychain bundle). showcases: 403 'only available for enterprise users' for this account — recorded, not enterprise", + "steps": [ + "T111-01", + "T111-02", + "T111-03", + "T111-04", + "T111-05", + "T111-06", + "T111-07", + "T111-08", + "T111-09", + "T111-10", + "T111-11", + "T111-12", + "T111-13", + "T111-14", + "T111-15", + "T111-16", + "T111-17", + "T111-18", + "T111-19", + "T111-20", + "T111-21", + "T111-22" + ], + "fixes": [ + "L01 (D-059)", + "L02 (D-060)" + ] + }, + { + "id": "T-112", + "scope": "real slicer open", + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "Bambu Studio 02.08.02.61 on macOS 26.6.2 detected at /Applications/BambuStudio.app; slicer open launched it (open -a, pid observed) on a fixture print OBJ and again on mesh prepare-print output of the real remeshed teapot (60 mm) with its copied MTL/texture; no print job sent", + "steps": [ + "T112-01", + "T112-02" + ] + } + ], + "code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "recorded_at": "2026-09-08T08:34:23.922875Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1…6 notes name the Codex finding and probe the file now covers; live_fix notes name the defect found in the real-account verification.", + "package_smoke": { + "tarball": "meshy-cli-0.3.0.tgz", + "sha256": "0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e", + "file_count": 398, + "required_present": true, + "forbidden_absent": true, + "install_prefix": "temporary npm prefix (npm install -g --prefix )", + "bins": [ + "meshy", + "meshy-cli" + ], + "python_required": false, + "checks": [ + { + "id": "version", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --version", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "version_cli", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --version", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "help_cli", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_nokey_json", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_nokey_v1", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "resources_v1", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy resources --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor_check_slicers", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor_check_api_nokey", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "slicer_detect", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_pass", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_fail", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 12, + "expected_exit": 12, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_unknown", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-unknown-faces.json --max-faces 300000 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 13, + "expected_exit": 13, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "mesh_prepare", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "mesh_refuse_overwrite", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 11, + "expected_exit": 11, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_init", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_record", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_140008_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_show", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_140008_smoke-demo_fixture- --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_list", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "download_list", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "download_no_selector", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 2, + "expected_exit": 2, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "make_dry_run", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "usage_unknown_flag", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 2, + "expected_exit": 2, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "uv_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy uv-unwrap create --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "creative_lab_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "stream_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy text-to-3d stream --help", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "catalog_live", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential", + "note": "public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157" + } + ] + }, + "review_rounds": [ + { + "round": 1, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-6273d9a", + "reviewed_head": "6273d9aa6ef396cf1cc26838e2c0d09ab176f595", + "reviewed_code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 10, + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "regression_tests": [ + "tests/codex-review-round1.test.ts", + "tests/operation-store.test.ts", + "tests/poll.test.ts" + ], + "probe_rerun": { + "script": "reviews/cli-s1-6273d9a/reproduce.mjs (copied to a scratch directory so the reviewer's evidence file is untouched)", + "node": "v24.20.0", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "reproduced_after_fix": 0, + "total": 12, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/12 reproduce", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + }, + "reverified_on": { + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "probe": "round1-probes.mjs re-run", + "reproduced": 0, + "total": 12 + } + }, + { + "round": 2, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-730132b", + "reviewed_head": "730132bd99a471ed41d6bbf6b200219f13f569ba", + "reviewed_code_head": "0388fe804b456a931f871d2f227e2acf22359d77", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 7, + "priorities": { + "P1": 1, + "P2": 6 + }, + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "regression_tests": [ + "tests/codex-review-round2.test.ts", + "tests/poll.test.ts", + "tests/auth-headless.test.ts (login_id)", + "tests/codex-review-round1.test.ts (R10 expectation follows the preserved HTTP class)" + ], + "reviewer_test_result_before_fix": "pnpm test 521/522 (flaky real-timer poll test), 8/8 round-2 probes reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-730132b/round2-probes.mjs (copied to a scratch directory; the reviewer's evidence files are untouched)", + "node": "v24.20.0", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "reproduced_after_fix": 0, + "total": 8, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/8 reproduce. Positive acceptance is asserted by tests/codex-review-round2.test.ts, not by this flag", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "poll_stability": { + "runs": 8, + "passed": 8, + "failed": 0, + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + }, + "reverified_on": { + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "probe": "round2-probes.mjs re-run", + "reproduced": 0, + "total": 8 + } + }, + { + "round": 3, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-cf8905d", + "reviewed_head": "566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a", + "reviewed_code_head": "cf8905dd285bf16896df053aac253dbf8c672979", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 6, + "priorities": { + "P1": 2, + "P2": 4 + }, + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "test_stabilised_in": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "regression_tests": [ + "tests/codex-review-round3.test.ts", + "tests/poll.test.ts (real-timer smoke judged with the loop's clock reading)" + ], + "reviewer_test_result_before_fix": "pnpm test 535/535; 20/20 round 1+2 positive checks; 6/6 round-3 probes reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-cf8905d/round3-probes.mjs (copied to a scratch directory; the reviewer's evidence files are untouched)", + "node": "v24.20.0", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "reproduced_after_fix": 0, + "total": 6, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/6 reproduce. Positive acceptance is asserted by tests/codex-review-round3.test.ts", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130 + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "reviewer_positive_checker": { + "script": "verify-original-regressions.py on the re-run round-1/round-2 results", + "passed": 20, + "total": 20, + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs", + "passed": true, + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + } + }, + { + "round": 4, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-235d6de", + "reviewed_head": "9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4", + "reviewed_code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not allowed)", + "findings": 2, + "test_gaps": 1, + "priorities": { + "P2": 2, + "P3": 1 + }, + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "test_stabilised_in": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "regression_tests": [ + "tests/codex-review-round4.test.ts (D01 both orders + arbitration unit cases; D02 symlink/damaged/unwritable + recovery command replay; preflight; R4-T01 make identity legacy/v1 × 503/SIGINT)", + "tests/codex-review-round3.test.ts C06 (make scenarios moved to round 4 with distinct ids; create/wait/get kept)", + "tests/codex-review-round1.test.ts R03 (D-044 invariants instead of a poll-count cap; test-only)" + ], + "reviewer_test_result_before_fix": "pnpm test 542/542; 20/20 round 1+2 positive checks; 6/6 round-3 positive checks; 5/5 context checks; D01/D02 reproduced, D03 positive", + "probe_rerun": { + "script": "reviews/cli-s1-235d6de/round4-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "reproduced_after_fix": 0, + "total": 3, + "exit_code": 0, + "note": "D01 and D02 no longer show the old behaviour; D03 is the reviewer's positive interrupt probe (signal_sent=true, exit 130). reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round4.test.ts", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "older_probe_reruns": { + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run (it had not in the reviewer's run; fs.watch timing decides), the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checker": { + "script": "verify-original-regressions.py on the re-run round-1/round-2 results", + "passed": 20, + "total": 20, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + } + }, + { + "round": 5, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-68690f9", + "reviewed_head": "e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59", + "reviewed_code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "verdict": "changes_requested (G1-code not_accepted; G1-release and S2 out of scope)", + "findings": 3, + "priorities": { + "P2": 3 + }, + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "regression_tests": [ + "tests/codex-review-round5.test.ts (E01 verbatim replay with --workspace incl. a quoted path; E02 both orders + 8x2 arbitration matrix; E03 20-entry matrix with verbatim replay; escaped-project boundary case)", + "tests/codex-review-round4.test.ts (D02 replay now verbatim; R4-T01 SIGINT manifest asserted as failed + [model_glb failed])" + ], + "reviewer_test_result_before_fix": "pnpm test 547/547; 20/20 round 1+2 positive; 8/8 round 3+4 positive; 5/5 context checks; material matrix 14/16; project entries damaged 10/10 / missing 0/10 recovery; E01/E02x2/E03 reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-68690f9/round5-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "reproduced_after_fix": 0, + "total": 4, + "note": "E01: history unchanged + index_dirty on verbatim replay; E02 both orders: MTL unchanged; E03: record_project present. reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round5.test.ts", + "cases": [ + { + "id": "E01-recovery-drops-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-kd-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-bump-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E03-task-project-resolution-outside-recovery-wrapper", + "reproduced": false, + "exit_code": 11 + } + ] + }, + "material_matrix": { + "script": "material-matrix-check.mjs copy", + "passed": 16, + "total": 16, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "project_entry_matrix": { + "script": "project-entry-matrix-check.mjs copy", + "record_recovery": 20, + "total": 20, + "exit_codes": [ + 11 + ], + "task_ids_preserved": 20, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "older_probe_reruns": { + "round4": { + "script": "round4-probes.mjs copy", + "reproduced": 0, + "total": 3, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run; the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checkers": { + "verify_original_regressions": { + "passed": 20, + "total": 20, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "verify_round3_4_regressions": { + "passed": 8, + "total": 8, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + } + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + } + }, + { + "round": 6, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-7b7c24c", + "reviewed_head": "166492ba9691f093b302c1c114e782bceee14a1d", + "reviewed_code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "verdict": "changes_requested (G1-code not_accepted; G1-release and S2 out of scope)", + "findings": 2, + "test_gaps": 1, + "priorities": { + "P1": 2, + "P3": 1 + }, + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "regression_tests": [ + "tests/codex-review-round6.test.ts (R6-F01 download × task-json/API × leaf/parent symlink + healthy control; R6-F02 legacy/v1 × 5 verbs × directory-swap/alias-repoint = 20 entries; stable-alias control)", + "tests/codex-review-round5.test.ts (R6-T01: exact method/path for all 20 E03 entries; E02 candidates bound to line/key)" + ], + "reviewer_test_result_before_fix": "pnpm test 552/552; round1+2 positive 20/20; round3+4 positive 8/8; round5 positive 24/24; material matrix 16/16 + round6 material 10/10; project entries 20/20; context 5/5; round6-probes 4/4 reproduced (F01 task-json/api, F02 get/create-async)", + "probe_rerun": { + "script": "reviews/cli-s1-7b7c24c/round6-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "reproduced_after_fix": 0, + "total": 4, + "note": "all four now exit 11 with external metadata/history unchanged and recovery null; reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round6.test.ts", + "cases": [ + { + "id": "F01-download-project-boundary-task-json", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false + }, + { + "id": "F01-download-project-boundary-api", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false + }, + { + "id": "F02-mutable-workspace-boundary-get", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false, + "external_history_changed": false + }, + { + "id": "F02-mutable-workspace-boundary-create-async", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false, + "external_history_changed": false + } + ] + }, + "material_matrix": { + "script": "material-matrix-check.mjs copy", + "passed": 16, + "total": 16, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "round6_material_check": { + "script": "round6-material-check.mjs copy", + "passed": 10, + "total": 10, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "project_entry_matrix": { + "script": "project-entry-matrix-check.mjs copy", + "record_recovery": 20, + "total": 20, + "exit_codes": [ + 11 + ], + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "older_probe_reruns": { + "round5": { + "script": "round5-probes.mjs copy", + "reproduced": 0, + "total": 4, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "E01-recovery-drops-workspace", + "reproduced": false, + "exit_code": 11, + "history_changed": false + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-kd-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-bump-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E03-task-project-resolution-outside-recovery-wrapper", + "reproduced": false, + "exit_code": 11 + } + ] + }, + "round4": { + "script": "round4-probes.mjs copy", + "reproduced": 0, + "total": 3, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run; the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checkers": { + "verify_original_regressions": { + "passed": 20, + "total": 20, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "verify_round3_4_regressions": { + "passed": 8, + "total": 8, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "verify_round5_positive": { + "passed": 24, + "total": 24, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + } + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + } + }, + { + "round": 7, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-e567646", + "reviewed_head": "4da216d3568fbd997bf85f8047ce3932672a25de", + "reviewed_code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "verdict": "accepted", + "findings": [], + "note": "R6-F01/R6-F02/R6-T01 closed; 32 findings from rounds 1-6 not regressed; G1-code accepted" + }, + { + "round": 8, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-1d9f109", + "reviewed_head": "c70b06cc3509b6e36b18a51783b368d816c964b4", + "reviewed_code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "verdict": "accepted", + "findings": [], + "note": "live fixes L01/L02 closed; 32 prior findings not regressed on 1d9f109; 255 prior evidence files unchanged; OBS-1..7 judged non-blocking P3 observations; G1-code continues accepted" + } + ], + "live_verification_run": { + "date": "2026-09-08", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "record": "docs/skill-parity/live-verification.json", + "fixes": [ + { + "id": "L01", + "decision": "D-059", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "Creative Lab in-progress bodies carry finished_at: null; schema now reads null timestamps/counts as 0; verified live afterwards" + }, + { + "id": "L02", + "decision": "D-060", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "legacy -o names Creative Lab parts/bundles via the shared modelAsset mapping (lamp.stl, base.stl, model.obj.zip); verified live afterwards" + } + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ], + "reviewer_script_reruns_on_this_head": { + "round1": 0, + "round2": 0, + "round3": 0, + "round4": 0, + "round5": 0, + "round6": 0, + "verify_original": "20/20", + "verify_round3_4": "8/8", + "verify_round5": "24/24", + "verify_round6": "5/5", + "material_matrix": "16/16", + "round6_material": "10/10", + "project_entries": "20/20", + "context": "5/5", + "stream": true + } + }, + "release": { + "authorised_by_owner_on": "2026-09-08", + "integration": "feat/skill-parity-s1 → main via pull request (owner's decision); merge commit, as for the previous releases", + "mechanism": ".github/workflows/release.yml (workflow_dispatch, run on main after the merge) publishes meshy-cli and the scoped alias @meshy-ai/cli at package.json version 0.3.0 with the repository's NPM_TOKEN; no local npm publish", + "windows_x64": "not_run — the owner decided on 2026-09-08 to skip it for this release (no host)", + "status": "pending — recorded with the published version, tag and registry digest in a docs commit after the publish is verified" + } +} diff --git a/package.json b/package.json index d45a80e..85e4e92 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meshy-cli", - "version": "0.2.0", - "description": "Official command-line interface for the Meshy AI API — text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animate, retexture, 2D images, multi-color print, balance.", + "version": "0.3.0", + "description": "Official command-line interface for the Meshy AI API \u2014 text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animate, retexture, 2D images, multi-color print, Creative Lab, balance \u2014 plus selective downloads, project folders, face checks, OBJ print preparation and slicer launch.", "license": "MIT", "type": "module", "packageManager": "pnpm@11.24.0", @@ -47,6 +47,7 @@ "clean": "rm -rf dist", "dev": "tsx src/index.ts", "start": "node dist/index.js", + "pretest": "tsc", "test": "tsx --test tests/**/*.test.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "prepare": "test -f dist/index.js || npm run build", diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 41b601e..b0a1e92 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -1,10 +1,10 @@ --- name: meshy-cli -description: "Generate 3D models, motion clips, and 2D images with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animation, retexture, printability. Use for any Meshy asset request." +description: "Generate 3D models, motion clips, 2D images and Creative Lab print products with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animation, retexture, printability, selective downloads, project folders, face checks, OBJ print preparation and slicer launch. Use for any Meshy asset or 3D-printing request." license: MIT -compatibility: Requires meshy-cli on PATH and a stored credential or MESHY_API_KEY; network access to api.meshy.ai +compatibility: Requires meshy-cli on PATH (Node 24+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline. metadata: - version: "1.0.0" + version: "1.1.0" cli-help: "meshy --help" --- @@ -14,38 +14,111 @@ metadata: `npm i -g meshy-cli`, then `meshy auth login` (browser) or `meshy auth login --with-key msy_...`. `MESHY_API_KEY` also works and wins over -a stored profile, which is what CI wants. Never echo the key back or write it -into a shell profile. +a stored profile, which is what CI wants; `--api-key-file ./keys.env` reads only +`MESHY_API_KEY` from an explicit dotenv-style file (nothing is auto-discovered or +executed; do not use Node's `--env-file`). Never echo the key back or write it +into a shell profile. `meshy doctor` checks the environment without touching the +network; `meshy doctor --check-api` makes one free balance call. + +## Always ask for the stable envelope + +Add `--output-schema v1 --format json` to every command you parse. stdout is then +exactly one JSON object with six keys — `schema_version, command, ok, result, +error, warnings` — and nothing else; progress goes to stderr. `ok` is whether the +CLI operation completed; `result.task.status` is the server's task state (a `get` +of a FAILED task is `ok:true`). Fields the server did not send are `null` — +never treat a `null` `face_count` or `consumed_credits` as 0. `error.code` and +the exit code (below) are stable; `error.recovery.command`, when present, is a +command you can run verbatim. ## One model, one command ```bash -meshy make "a red sports car" -o car.glb # prompt → text-to-3d preview → refine -meshy make ./cat.png -o out/cat/ # image → image-to-3d, textured -meshy make "a red sports car" --dry-run # planned steps + estimate, no spend -meshy make "..." --max-credits 25 # refuse to start over budget +meshy make "a red sports car" -o car.glb --output-schema v1 # prompt → text-to-3d preview → refine +meshy make ./cat.png -o out/cat/ --output-schema v1 # image → image-to-3d, textured +meshy make "a red sports car" --dry-run --output-schema v1 # planned steps + estimate, no spend, no network +meshy make "..." --max-credits 25 # refuse to start over budget +meshy make "..." --async --output-schema v1 # one POST, returns step 1's task_id + pending_steps ``` -The input decides the chain and nothing else does. If step 2 fails, the error's -`hint` is the command that resumes from step 1's task — run it verbatim rather -than starting over, or the finished step is paid for twice. +The input decides the chain and nothing else does. If a later step fails, the +result carries the finished step's task id and the `resume` command — run it +verbatim rather than starting over, or the finished step is paid for twice. ## Everything else -`meshy resources` indexes the 17 endpoint commands; each carries the same verbs: +`meshy resources --output-schema v1` indexes every command (kind `task`, `query` +or `local`); each task resource carries the same verbs: ```bash -meshy create [flags] [--data ''] [--async] [--timeout ] -meshy get|wait|delete -meshy list [--page-size ] +meshy create [flags] [--data ''] [--async] [--timeout ] [--operation-id ] --output-schema v1 +meshy get|wait|stream|delete --output-schema v1 +meshy list [--page-size ] --output-schema v1 ``` -`create` blocks until the task is terminal; `--async` returns the id instead. -`-o ` downloads artifacts (a directory for multi-file results) and writes -a `meta.json` sidecar; without `-o`, stdout is the task JSON — parse ids from -there, never from text shown in chat. `--data ''` reaches any field the -CLI has no flag for, and `meshy api ` reaches any endpoint it has -no command for. +- `create` blocks until the task is terminal; `--async` submits **exactly one + POST** and returns `result.submission.task_id` — parse ids from stdout, never + from text shown in chat. `wait ` polls (`--timeout 0` = one query, exit 8 on + timeout with the last status kept; a reply that lands after the deadline is a + timeout, not a success); `stream --format ndjson` follows Server-Sent + Events (one line per event, last line `event:"outcome"`, which carries the `-o` + download manifest). +- **Never re-run a create after exit 10** (`submission_unknown`): the request was + sent and the server may have created the task. Run the `error.recovery.command` + (`… list`) and reconcile first. Pass `--operation-id ` to a create so a + repeat of the same request replays the recorded outcome instead of billing again; + a different key/account, payload or image under the same id is refused (exit 2). + An OAuth profile without an account id or login id (saved before `login_id` existed) + is refused a replay (`credential_unverified`) — run `meshy auth login` once first. +- **An error after `create` was accepted still names the task**: read + `result.task_id` / `result.submission.task_id` and `result.next` from any + non-zero exit (11 local I/O, 1 polling failure, 130 interrupt) before deciding + anything; never create again because a later step failed. +- `--save-json ` stores the raw API task (use it as the input of `download`, + `inspect faces` and `project record`); `--include-raw` puts the raw response under + `result.task.raw`; `--project ` records the task in a project folder. +- `--data ''` reaches any field the CLI has no flag for (flags win, explicit + `false`/`0` survive); `meshy api ` reaches any endpoint it has no + command for. Media flags and the same fields inside `--data` accept URLs, local + files and `data:` URIs. + +## Creative Lab, UV, catalog, showcases + +```bash +meshy uv-unwrap create --input-task-id --async --output-schema v1 # GLB only, ≤40k faces (server enforces) +meshy creative-lab prototype create --image-url ./photo.png --name demo --async --output-schema v1 +meshy creative-lab build create --input-task-id [--model-format …] [--options ''] --async --output-schema v1 +meshy animation-catalog list --category DailyActions --search wave --output-schema v1 # public, no key; search is local +meshy showcases list --search car --page-size 3 --output-schema v1 # Enterprise only; EVERY request is billed +``` + +Prototype meanings differ: figure/keychain/fridge-magnet yield a concept image, +lamp also yields a lampshade GLB. Build consumes a SUCCEEDED prototype made +through this API with the same key (web-app prototypes → 404). Lamp builds output +`lamp_stl`/`base_stl` or `bundle_zip`; keychain/fridge-magnet `obj` output is a +ZIP bundle saved as `.zip`. Never call `showcases` in a health check. + +## Downloads, projects, printing (local, no key) + +```bash +meshy download --task-json ./task.json --list --output-schema v1 +meshy download --task-json ./task.json --model-format glb --output ./model.glb --output-schema v1 +meshy download --task-json ./rig.json --asset result.basic_animations.walking_glb_url --output ./walking.glb --output-schema v1 +meshy download --resource image-to-3d --task-id --all --output-dir ./out/ --output-schema v1 +meshy project init --root ./meshy_output --name "" --task-id --output-schema v1 +meshy project record --project --task-id --resource text-to-3d --stage preview --file preview.glb --output-schema v1 +meshy inspect faces --task-json ./task.json --max-faces 300000 --output-schema v1 # exit 0 pass | 12 fail | 13 unknown +meshy mesh prepare-print ./model.obj --height-mm 75 --output-schema v1 # writes ./model.print.obj (Y-up → Z-up, grounded) +meshy slicer detect --output-schema v1 +meshy slicer open --slicer OrcaSlicer --file ./model.print.obj --output-schema v1 # launch only; not proof of import +``` + +`download` needs an explicit selector when a task has several assets (it lists +them and exits 2 otherwise); it never overwrites without `--overwrite`. `inspect +faces` answers only the face-count question — `unknown` (exit 13) means the task +carries no usable `face_count`; do not treat it as a pass and do not start a +remesh unless the user agrees. `prepare-print` never edits the input unless +`--in-place`. ## Constraints that will bite @@ -56,64 +129,72 @@ These are API rules, not preferences — ignoring them produces failed tasks: textured with `retexture` instead. - **`rigging` needs a textured biped GLB** under 300k faces, with clear limbs — not props, quadrupeds or untextured drafts. Too dense? `remesh` first. A - successful rigging task already bundles walking and running clips, so check - its result before calling `animate`. + successful rigging task already bundles walking and running clips + (`result.basic_animations.*`), so check its result before calling `animate`. - **`animate` takes a rigging task id**, not a model task id, plus an integer - `--action-id`. + `--action-id` from the catalog. - **`text-to-motion` produces a standalone skeletal clip**, not an animated - character. Pass a 2–10 second duration in 0.5-second increments. Prime is the - default (FBX, 10 credits); Swift returns BVH (3 credits). + character. Pass a 2–10 second duration in 0.5-second increments. - **`repair-printability` drops textures and invalidates UVs.** Run it before texturing, or re-`retexture` afterwards. +- **`uv-unwrap` takes one source** (`--input-task-id` or `--model-url`, GLB only). - **`multi-image-to-3d` is beta**; use `image-to-3d` unless multi-view input was explicitly asked for. - `image-to-3d` defaults to an untextured draft mesh; `--should-texture true` (what `make` uses) produces a textured model in one task. - **The model set is not the same on every endpoint.** The image-driven endpoints run Meshy 7 by default; `text-to-3d` has no Meshy 7 at all and its - default is still Meshy 6. So `--ultra-mode` (an extra Meshy 7 geometry pass, - billed on top) exists on `image-to-3d` alone, and `retexture`'s - `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of **the same - object**, not 1-4 style references, and cannot be combined with - `--text-style-prompt` or `--image-style-url`. + default is still Meshy 6. `--ultra-mode` exists on `image-to-3d` alone, and + `retexture`'s `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of + **the same object**, not style references. ## Finding an animation id -`--action-id` is an integer from Meshy's animation library. This skill bundles -the full catalog as `animation-library.json` next to this file — 678 actions, -each with `action_id`, `name`, `category`, `sub_category`, `is_free`, -`preview_url`, and a hand-written `description` the API does not return. Pick an -action by what it depicts, offline and with no key: - -```bash -# from this skill's directory -jq -r '.[] | select(.category=="Fighting") | "\(.action_id)\t\(.name)\t\(.description)"' \ - animation-library.json -``` +`--action-id` is an integer from Meshy's animation library. Ask the live public +catalog (no key): `meshy animation-catalog list --category Fighting --search kick +--output-schema v1` → `result.items[].action_id`. Ids are not `1..N` (the catalog +contains `-2`, `-1` and `0`) — never guess one. This skill also bundles a +snapshot as `animation-library.json` (with hand-written descriptions the API does +not return; it can lag behind the live catalog). Categories: WalkAndRun, BodyMovements, DailyActions, Fighting, Dancing. -The bundled catalog can lag as Meshy adds actions. For the authoritative live -list (ids + names, no descriptions) hit the public endpoint — no key required: - -```bash -curl -s "https://api.meshy.ai/web/public/animations/resources" \ - | jq -r '.result.list[] | select(.category=="Fighting") | "\(.id)\t\(.name)"' -``` - ## When something fails -Exit codes: `0` ok · `2` usage · `3` auth · `4` validation · `5` not found · -`6` rate limit · `7` network · `8` timed out · `9` out of credits. +Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · +`2` usage · `3` auth · `4` validation · `5` not found · `6` rate limit · +`7` network · `8` timed out (task keeps running) · `9` out of credits · +`10` submission unknown (do not re-create) · `11` local I/O · `12` check failed · +`13` check unknown · `130` interrupted. - Exit 9 → run `meshy balance`, relay the number, do not retry. - Exit 6 → back off; the CLI does not retry for you. -- A `FAILED` task → relay `task_error.message` verbatim; do not guess a cause. -- Any error payload may carry `hint` — a command to run. Prefer it over - improvising. - -JSON object output may also carry `_notice.update` when a newer meshy-cli -exists; pass its `command` on to the user. +- Exit 8 → `wait`/`stream` the same task id again; it was not cancelled. +- Exit 130 after `-o` → the transfer (or the material rewrite) was cancelled; + `result.downloads.files` lists what already landed (`status: written`), the rest can + be fetched with `meshy download`. `downloads.failed_step` (`relink` | `digest` | + `sidecar`) means every asset is on disk and only that step is missing. +- Legacy-schema errors (no `--output-schema v1`) after a create carry additive + `task_id` / `operation_id` fields and a `hint` with the resume command — never re-create. +- `result.downloads.material_links.status: "incomplete"` → name the references that + stayed unresolved or ambiguous (`texture_maps[].method`, `note`) instead of claiming the + model loads; two material groups that would share one texture without evidence are + deliberately left as written. +- `result.project.action: "failed"` (download `--project`, exit 11) → the assets are on + disk (`result.downloads.files`); repair the project's `metadata.json`, then run + `error.recovery.command` (`meshy project record …`, it already carries the original + `--workspace` and `--operation-id`) verbatim — do not download or create again. The same + command appears after a task verb's `--project` record failure (legacy: `hint`); when the + error says the project is *no longer a target inside the authorised boundary* (the project, + its parent or the workspace itself changed while the request was in flight) there is + deliberately no command and `recovery` is null — restore the directory first, then record + with `meshy project record` from inside the original workspace. +- Exit 10 → reconcile with ` list`; never submit the same create again blindly. +- A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. +- Any error may carry `error.recovery.command` — prefer it over improvising. + +Legacy JSON output (without `--output-schema v1`) may carry `_notice.update` when +a newer meshy-cli exists; pass its `command` on to the user. ## Docs diff --git a/src/client/endpoints/analyze-printability.ts b/src/client/endpoints/analyze-printability.ts deleted file mode 100644 index 7a8e4f8..0000000 --- a/src/client/endpoints/analyze-printability.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class AnalyzePrintabilityEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/analyze"); - } -} diff --git a/src/client/endpoints/animate.ts b/src/client/endpoints/animate.ts deleted file mode 100644 index bff07da..0000000 --- a/src/client/endpoints/animate.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST /openapi/v1/animations — plural path. */ -export class AnimateEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/animations"); - } -} diff --git a/src/client/endpoints/animation-catalog.ts b/src/client/endpoints/animation-catalog.ts new file mode 100644 index 0000000..80ee235 --- /dev/null +++ b/src/client/endpoints/animation-catalog.ts @@ -0,0 +1,62 @@ +/** + * GET /web/public/animations/resources — the public Animation Library. + * + * No credential is ever attached: the endpoint is public and the transport + * used here has none. `category` is the only server-side filter the frozen + * Skill baseline relies on; the response is `{result:{total,list}}`. + */ + +import { z } from "zod"; +import { MeshyApiError } from "../errors.js"; +import type { Transport } from "../transport.js"; + +export const AnimationCatalogEntrySchema = z + .object({ + id: z.number(), + key: z.string().optional(), + name: z.string().optional(), + category: z.string().optional(), + subCategory: z.string().optional(), + previewUrl: z.string().optional(), + rigType: z.string().optional(), + isDefault: z.boolean().optional(), + isFree: z.boolean().optional(), + }) + .passthrough(); +export type AnimationCatalogEntry = z.infer; + +const ResponseSchema = z + .object({ + result: z.object({ total: z.number().optional(), list: z.array(AnimationCatalogEntrySchema) }).passthrough(), + }) + .passthrough(); + +export const ANIMATION_CATEGORIES = ["WalkAndRun", "BodyMovements", "DailyActions", "Fighting", "Dancing"] as const; + +export class AnimationCatalogEndpoint { + static readonly PATH = "/animations/resources"; + private readonly transport: Transport; + + constructor(transport: Transport) { + if (transport.authenticated) throw new Error("the animation catalog must use an unauthenticated transport"); + this.transport = transport; + } + + async list(params: { category?: string } = {}, extras: { signal?: AbortSignal } = {}): Promise<{ entries: AnimationCatalogEntry[]; total: number | null; raw: unknown }> { + const resp = await this.transport.requestJson("GET", AnimationCatalogEndpoint.PATH, { + query: { category: params.category }, + signal: extras.signal, + }); + const parsed = ResponseSchema.safeParse(resp.json); + if (!parsed.success) { + throw new MeshyApiError({ + message: `unexpected catalog shape from ${AnimationCatalogEndpoint.PATH}: ${parsed.error.message}`, + status: resp.status, + code: "server", + path: AnimationCatalogEndpoint.PATH, + body: resp.json, + }); + } + return { entries: parsed.data.result.list, total: parsed.data.result.total ?? null, raw: resp.json }; + } +} diff --git a/src/client/endpoints/balance.ts b/src/client/endpoints/balance.ts index 365e89a..39db98d 100644 --- a/src/client/endpoints/balance.ts +++ b/src/client/endpoints/balance.ts @@ -12,6 +12,11 @@ export class BalanceEndpoint { } async get(): Promise { + return (await this.getWithRaw()).balance; + } + + /** The parsed balance plus the untouched response body (for --save-json). */ + async getWithRaw(): Promise<{ balance: Balance; raw: unknown }> { const resp = await this.http("/balance", { method: "GET" }); if (!resp.ok) throw await mapHttpError(resp, "/balance"); const raw: unknown = await resp.json(); @@ -25,6 +30,6 @@ export class BalanceEndpoint { body: raw, }); } - return parsed.data; + return { balance: parsed.data, raw }; } } diff --git a/src/client/endpoints/base.ts b/src/client/endpoints/base.ts index f6dbfc9..69ebde0 100644 --- a/src/client/endpoints/base.ts +++ b/src/client/endpoints/base.ts @@ -1,18 +1,24 @@ /** - * Base class for async-task Meshy endpoints: + * Async-task endpoint over one Transport: * POST / → { result: } * GET //:id → Task * GET / → Task[] * DELETE //:id → 200 + * GET //:id/stream → text/event-stream + * + * The `*Detailed` variants also return the raw JSON exactly as received, which + * is what --save-json, --include-raw and the v1 TaskView are built from. */ -import { mapHttpError, MeshyApiError } from "../errors.js"; +import { MeshyApiError } from "../errors.js"; +import type { Transport, StreamHandle } from "../transport.js"; import { TaskCreateResponseSchema, TaskSchema, type Task, } from "../types.js"; +/** Legacy fetch signature kept for the `api` passthrough and older call sites. */ export type HttpFetch = (path: string, init?: RequestInit) => Promise; export interface ListParams { @@ -21,78 +27,125 @@ export interface ListParams { sort_by?: string; } +export interface CreateResult { + taskId: string; + raw: unknown; + requestId: string | null; +} + +export interface RetrieveResult { + task: Task; + raw: unknown; +} + +export interface ListResult { + tasks: Task[]; + raw: unknown; +} + +export interface RequestExtras { + signal?: AbortSignal; + timeoutMs?: number; +} + export class TaskEndpoint { readonly resourcePath: string; - protected readonly http: HttpFetch; + protected readonly transport: Transport; - constructor(http: HttpFetch, resourcePath: string) { + constructor(transport: Transport, resourcePath: string) { if (!resourcePath.startsWith("/")) { throw new Error(`resourcePath must start with "/" (got ${resourcePath})`); } - this.http = http; + this.transport = transport; this.resourcePath = resourcePath; } - async create(payload: Record): Promise { - const resp = await this.http(this.resourcePath, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload ?? {}), + /** Base URL of the API family this endpoint talks to (for journaling the origin). */ + get transportBaseUrl(): string { + return this.transport.baseUrl; + } + + async create(payload: Record, extras: RequestExtras = {}): Promise { + return (await this.createDetailed(payload, extras)).taskId; + } + + async createDetailed(payload: Record, extras: RequestExtras = {}): Promise { + const resp = await this.transport.requestJson("POST", this.resourcePath, { + body: payload ?? {}, + signal: extras.signal, + timeoutMs: extras.timeoutMs, }); - if (!resp.ok) throw await mapHttpError(resp, this.resourcePath); - const raw: unknown = await resp.json(); - const parsed = TaskCreateResponseSchema.safeParse(raw); + const parsed = TaskCreateResponseSchema.safeParse(resp.json); if (!parsed.success) { throw new MeshyApiError({ message: `unexpected response from POST ${this.resourcePath}: ${parsed.error.message}`, status: resp.status, code: "server", path: this.resourcePath, - body: raw, + body: resp.json, }); } - return parsed.data.result; + return { taskId: parsed.data.result, raw: resp.json, requestId: resp.requestId }; + } + + async retrieve(taskId: string, extras: RequestExtras = {}): Promise { + return (await this.retrieveDetailed(taskId, extras)).task; } - async retrieve(taskId: string): Promise { + async retrieveDetailed(taskId: string, extras: RequestExtras = {}): Promise { if (!taskId) throw new Error("task_id is required"); const path = `${this.resourcePath}/${encodeURIComponent(taskId)}`; - const resp = await this.http(path, { method: "GET" }); - if (!resp.ok) throw await mapHttpError(resp, path); - const raw: unknown = await resp.json(); - const parsed = TaskSchema.safeParse(raw); + const resp = await this.transport.requestJson("GET", path, { signal: extras.signal, timeoutMs: extras.timeoutMs }); + const parsed = TaskSchema.safeParse(resp.json); if (!parsed.success) { throw new MeshyApiError({ message: `unexpected task shape from GET ${path}: ${parsed.error.message}`, status: resp.status, code: "server", path, - body: raw, + body: resp.json, }); } - return parsed.data; + return { task: parsed.data, raw: resp.json }; } - async list(params: ListParams = {}): Promise { - const search = new URLSearchParams(); - search.set("page_num", String(params.page_num ?? 1)); - search.set("page_size", String(params.page_size ?? 10)); - search.set("sort_by", params.sort_by ?? "-created_at"); - const path = `${this.resourcePath}?${search.toString()}`; - const resp = await this.http(path, { method: "GET" }); - if (!resp.ok) throw await mapHttpError(resp, this.resourcePath); - const raw: unknown = await resp.json(); - if (!Array.isArray(raw)) return []; - return raw.map((t) => { + async list(params: ListParams = {}, extras: RequestExtras = {}): Promise { + return (await this.listDetailed(params, extras)).tasks; + } + + async listDetailed(params: ListParams = {}, extras: RequestExtras = {}): Promise { + const resp = await this.transport.requestJson("GET", this.resourcePath, { + query: { + page_num: params.page_num ?? 1, + page_size: params.page_size ?? 10, + sort_by: params.sort_by ?? "-created_at", + }, + signal: extras.signal, + timeoutMs: extras.timeoutMs, + }); + const raw = resp.json; + if (!Array.isArray(raw)) return { tasks: [], raw }; + const tasks = raw.map((t) => { const parsed = TaskSchema.safeParse(t); return parsed.success ? parsed.data : (t as Task); }); + return { tasks, raw }; } - async delete(taskId: string): Promise { + async delete(taskId: string, extras: RequestExtras = {}): Promise { if (!taskId) throw new Error("task_id is required"); const path = `${this.resourcePath}/${encodeURIComponent(taskId)}`; - const resp = await this.http(path, { method: "DELETE" }); - if (!resp.ok) throw await mapHttpError(resp, path); + const resp = await this.transport.requestJson("DELETE", path, { signal: extras.signal, timeoutMs: extras.timeoutMs }); + return resp.json; + } + + streamPath(taskId: string): string { + if (!taskId) throw new Error("task_id is required"); + return `${this.resourcePath}/${encodeURIComponent(taskId)}/stream`; + } + + /** Open the SSE connection; the caller parses events and owns the deadlines. */ + async openStream(taskId: string, opts: { signal?: AbortSignal; connectTimeoutMs?: number } = {}): Promise { + return this.transport.openStream(this.streamPath(taskId), opts); } } diff --git a/src/client/endpoints/convert.ts b/src/client/endpoints/convert.ts deleted file mode 100644 index 749fdc9..0000000 --- a/src/client/endpoints/convert.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ConvertEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/convert"); - } -} diff --git a/src/client/endpoints/image-to-3d.ts b/src/client/endpoints/image-to-3d.ts deleted file mode 100644 index 0a7eddc..0000000 --- a/src/client/endpoints/image-to-3d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ImageTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/image-to-3d"); - } -} diff --git a/src/client/endpoints/image-to-image.ts b/src/client/endpoints/image-to-image.ts deleted file mode 100644 index de0cf1e..0000000 --- a/src/client/endpoints/image-to-image.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ImageToImageEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/image-to-image"); - } -} diff --git a/src/client/endpoints/multi-color-print.ts b/src/client/endpoints/multi-color-print.ts deleted file mode 100644 index a5cd81c..0000000 --- a/src/client/endpoints/multi-color-print.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class MultiColorPrintEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/multi-color"); - } -} diff --git a/src/client/endpoints/multi-image-to-3d.ts b/src/client/endpoints/multi-image-to-3d.ts deleted file mode 100644 index d269f16..0000000 --- a/src/client/endpoints/multi-image-to-3d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class MultiImageTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/multi-image-to-3d"); - } -} diff --git a/src/client/endpoints/remesh.ts b/src/client/endpoints/remesh.ts deleted file mode 100644 index 27deeed..0000000 --- a/src/client/endpoints/remesh.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RemeshEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/remesh"); - } -} diff --git a/src/client/endpoints/repair-printability.ts b/src/client/endpoints/repair-printability.ts deleted file mode 100644 index 56f0e2b..0000000 --- a/src/client/endpoints/repair-printability.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RepairPrintabilityEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/repair"); - } -} diff --git a/src/client/endpoints/resize.ts b/src/client/endpoints/resize.ts deleted file mode 100644 index 8a78b3e..0000000 --- a/src/client/endpoints/resize.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ResizeEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/resize"); - } -} diff --git a/src/client/endpoints/retexture.ts b/src/client/endpoints/retexture.ts deleted file mode 100644 index c32e948..0000000 --- a/src/client/endpoints/retexture.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RetextureEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/retexture"); - } -} diff --git a/src/client/endpoints/rigging.ts b/src/client/endpoints/rigging.ts deleted file mode 100644 index 2b1a05f..0000000 --- a/src/client/endpoints/rigging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** Note: Meshy does not expose a list endpoint for rigging. */ -export class RiggingEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/rigging"); - } -} diff --git a/src/client/endpoints/showcases.ts b/src/client/endpoints/showcases.ts new file mode 100644 index 0000000..b07a674 --- /dev/null +++ b/src/client/endpoints/showcases.ts @@ -0,0 +1,62 @@ +/** + * GET /openapi/v1/showcases — Enterprise showcase search. + * + * Every call may be billed (1 credit per request per the official docs), so + * the endpoint performs exactly one GET, never retries and never paginates on + * its own. Items are passed through untouched. + */ + +import { MeshyApiError } from "../errors.js"; +import type { Transport } from "../transport.js"; + +export const SHOWCASE_SORT_BY = ["+created_at", "-created_at", "+updated_at", "-updated_at", "+downloads", "-downloads"] as const; +export const SHOWCASE_FORMATS = ["glb", "fbx", "obj", "usdz"] as const; +/** Server enum (checked read-only against the server binding); the docs spell the second value `animated`. */ +export const SHOWCASE_TYPES = ["all", "animate", "static"] as const; + +export interface ShowcaseListParams { + page_size?: number; + sort_by?: string; + search?: string; + format?: string; + showcase_type?: string; +} + +export class ShowcasesEndpoint { + static readonly PATH = "/showcases"; + private readonly transport: Transport; + + constructor(transport: Transport) { + this.transport = transport; + } + + async list(params: ShowcaseListParams, extras: { signal?: AbortSignal } = {}): Promise<{ items: Record[]; raw: unknown }> { + const resp = await this.transport.requestJson("GET", ShowcasesEndpoint.PATH, { + query: { + page_size: params.page_size, + sort_by: params.sort_by, + search: params.search, + format: params.format, + showcase_type: params.showcase_type, + }, + signal: extras.signal, + }); + const raw = resp.json; + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === "object" && Array.isArray((raw as Record)["result"]) + ? ((raw as Record)["result"] as unknown[]) + : null; + if (!list) { + throw new MeshyApiError({ + message: `unexpected showcases shape from ${ShowcasesEndpoint.PATH}`, + status: resp.status, + code: "server", + path: ShowcasesEndpoint.PATH, + body: raw, + }); + } + const items = list.filter((x): x is Record => Boolean(x) && typeof x === "object" && !Array.isArray(x)); + return { items, raw }; + } +} diff --git a/src/client/endpoints/text-to-3d.ts b/src/client/endpoints/text-to-3d.ts deleted file mode 100644 index f6b68d9..0000000 --- a/src/client/endpoints/text-to-3d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST /openapi/v2/text-to-3d — the only v2-hosted endpoint. */ -export class TextTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-3d"); - } -} diff --git a/src/client/endpoints/text-to-image.ts b/src/client/endpoints/text-to-image.ts deleted file mode 100644 index 4161949..0000000 --- a/src/client/endpoints/text-to-image.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class TextToImageEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-image"); - } -} diff --git a/src/client/endpoints/text-to-motion.ts b/src/client/endpoints/text-to-motion.ts deleted file mode 100644 index 69c3ecd..0000000 --- a/src/client/endpoints/text-to-motion.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST/GET/LIST/DELETE /openapi/v1/text-to-motion. */ -export class TextToMotionEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-motion"); - } -} diff --git a/src/client/errors.ts b/src/client/errors.ts index 59fea3b..72a32bf 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -48,7 +48,7 @@ export class MeshyApiError extends Error { } } -function codeForStatus(status: number): MeshyErrorCode { +export function codeForStatus(status: number): MeshyErrorCode { if (status === 400 || status === 422) return "validation"; if (status === 401) return "auth"; if (status === 402) return "credit"; diff --git a/src/client/index.ts b/src/client/index.ts index a5bf2d4..c4f5eb9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,157 +1,127 @@ /** - * MeshyClient — two fetchers (v1 + v2), typed endpoint namespaces. + * MeshyClient — one transport per API family, endpoints resolved from the + * resource registry. + * + * v1 / v2 bearer credential, resolved by config + * creative-lab bearer credential; base derived from the v1 origin or given + * explicitly; a stored profile is never sent to another origin + * public-web no credential (animation catalog) */ -import { AnalyzePrintabilityEndpoint } from "./endpoints/analyze-printability.js"; -import { AnimateEndpoint } from "./endpoints/animate.js"; import { BalanceEndpoint } from "./endpoints/balance.js"; -import { ConvertEndpoint } from "./endpoints/convert.js"; -import { ResizeEndpoint } from "./endpoints/resize.js"; -import { ImageTo3DEndpoint } from "./endpoints/image-to-3d.js"; -import { ImageToImageEndpoint } from "./endpoints/image-to-image.js"; -import { MultiColorPrintEndpoint } from "./endpoints/multi-color-print.js"; -import { MultiImageTo3DEndpoint } from "./endpoints/multi-image-to-3d.js"; -import { RemeshEndpoint } from "./endpoints/remesh.js"; -import { RepairPrintabilityEndpoint } from "./endpoints/repair-printability.js"; -import { RetextureEndpoint } from "./endpoints/retexture.js"; -import { RiggingEndpoint } from "./endpoints/rigging.js"; -import { TaskEndpoint, type HttpFetch } from "./endpoints/base.js"; -import { TextTo3DEndpoint } from "./endpoints/text-to-3d.js"; -import { TextToImageEndpoint } from "./endpoints/text-to-image.js"; -import { TextToMotionEndpoint } from "./endpoints/text-to-motion.js"; -import { mapHttpError, MeshyApiError } from "./errors.js"; -import type { MeshyConfig } from "../internal/config.js"; -import { logger } from "../internal/logger.js"; -import { USER_AGENT } from "../internal/user-agent.js"; +import { TaskEndpoint } from "./endpoints/base.js"; +import { AnimationCatalogEndpoint } from "./endpoints/animation-catalog.js"; +import { ShowcasesEndpoint } from "./endpoints/showcases.js"; +import { + creativeLabResource, + requireTaskResource, + TASK_RESOURCES, + type ApiBase, + type CreativeLabProduct, + type CreativeLabStage, + type TaskResourceDescriptor, +} from "./resource-registry.js"; +import { createTransport, type Transport } from "./transport.js"; +import { assertCredentialAllowedForOrigin, type MeshyConfig } from "../internal/config.js"; +import { UsageError } from "../internal/errors.js"; -function makeFetcher( - baseUrl: string, - apiKey: string, - readTimeoutMs: number, - credentialKind: "oauth" | "api_key", -): HttpFetch { - return async function (path: string, init?: RequestInit): Promise { - const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`; - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${apiKey}`); - headers.set("User-Agent", USER_AGENT); - if (!headers.has("Accept")) headers.set("Accept", "application/json"); - - const controller = new AbortController(); - const timer = setTimeout( - () => controller.abort(new Error(`read timeout after ${readTimeoutMs}ms`)), - readTimeoutMs, - ); - try { - logger.debug(`HTTP ${init?.method ?? "GET"} ${url}`); - const resp = await fetch(url, { ...init, headers, signal: controller.signal }); - logger.debug(`HTTP ${resp.status} ${url}`); - if (!resp.ok) { - throw await mapHttpError(resp, path, credentialKind); - } - return resp; - } catch (err) { - if (err instanceof MeshyApiError) throw err; - if (err instanceof Error && err.name === "AbortError") { - throw new MeshyApiError({ - message: `request to ${url} timed out after ${readTimeoutMs}ms`, - status: 0, - code: "network", - path, - credentialKind, - }); - } - const msg = err instanceof Error ? err.message : String(err); - throw new MeshyApiError({ - message: `network error calling ${url}: ${msg}`, - status: 0, - code: "network", - path, - credentialKind, - }); - } finally { - clearTimeout(timer); - } - }; -} +export type { HttpFetch } from "./endpoints/base.js"; export class MeshyClient { readonly config: MeshyConfig; readonly balance: BalanceEndpoint; - readonly textTo3d: TextTo3DEndpoint; - readonly imageTo3d: ImageTo3DEndpoint; - readonly multiImageTo3d: MultiImageTo3DEndpoint; - readonly remesh: RemeshEndpoint; - readonly convert: ConvertEndpoint; - readonly resize: ResizeEndpoint; - readonly rigging: RiggingEndpoint; - readonly animate: AnimateEndpoint; - readonly retexture: RetextureEndpoint; - readonly textToImage: TextToImageEndpoint; - readonly textToMotion: TextToMotionEndpoint; - readonly imageToImage: ImageToImageEndpoint; - readonly multiColorPrint: MultiColorPrintEndpoint; - readonly analyzePrintability: AnalyzePrintabilityEndpoint; - readonly repairPrintability: RepairPrintabilityEndpoint; + readonly showcases: ShowcasesEndpoint; + /** Public catalog — built on a transport without any credential. */ + readonly catalog: AnimationCatalogEndpoint; - private readonly v1Fetch: HttpFetch; - private readonly v2Fetch: HttpFetch; + private readonly v1: Transport; + private readonly v2: Transport; + private creativeLabTransport: Transport | null = null; + private readonly endpoints = new Map(); - constructor(config: MeshyConfig) { + constructor(config: MeshyConfig, opts: { fetchImpl?: typeof fetch } = {}) { this.config = config; - this.v1Fetch = makeFetcher(config.baseUrlV1, config.apiKey, config.readTimeoutMs, config.credentialKind); - this.v2Fetch = makeFetcher(config.baseUrlV2, config.apiKey, config.readTimeoutMs, config.credentialKind); - - this.balance = new BalanceEndpoint(this.v1Fetch); - this.textTo3d = new TextTo3DEndpoint(this.v2Fetch); - this.imageTo3d = new ImageTo3DEndpoint(this.v1Fetch); - this.multiImageTo3d = new MultiImageTo3DEndpoint(this.v1Fetch); - this.remesh = new RemeshEndpoint(this.v1Fetch); - this.convert = new ConvertEndpoint(this.v1Fetch); - this.resize = new ResizeEndpoint(this.v1Fetch); - this.rigging = new RiggingEndpoint(this.v1Fetch); - this.animate = new AnimateEndpoint(this.v1Fetch); - this.retexture = new RetextureEndpoint(this.v1Fetch); - this.textToImage = new TextToImageEndpoint(this.v1Fetch); - this.textToMotion = new TextToMotionEndpoint(this.v1Fetch); - this.imageToImage = new ImageToImageEndpoint(this.v1Fetch); - this.multiColorPrint = new MultiColorPrintEndpoint(this.v1Fetch); - this.analyzePrintability = new AnalyzePrintabilityEndpoint(this.v1Fetch); - this.repairPrintability = new RepairPrintabilityEndpoint(this.v1Fetch); + const common = { readTimeoutMs: config.readTimeoutMs, fetchImpl: opts.fetchImpl, credentialKind: config.credentialKind }; + this.v1 = createTransport({ ...common, baseUrl: config.baseUrlV1, apiKey: config.apiKey }); + this.v2 = createTransport({ ...common, baseUrl: config.baseUrlV2, apiKey: config.apiKey }); + const publicTransport = createTransport({ baseUrl: config.publicWebBase, readTimeoutMs: config.readTimeoutMs, fetchImpl: opts.fetchImpl }); + this.balance = new BalanceEndpoint((path, init) => this.v1.fetchRaw(path, init)); + this.showcases = new ShowcasesEndpoint(this.v1); + this.catalog = new AnimationCatalogEndpoint(publicTransport); } - endpointFor(resource: ResourceName): TaskEndpoint { - switch (resource) { - case "text-to-3d": return this.textTo3d; - case "image-to-3d": return this.imageTo3d; - case "multi-image-to-3d": return this.multiImageTo3d; - case "remesh": return this.remesh; - case "convert": return this.convert; - case "resize": return this.resize; - case "rigging": return this.rigging; - case "animate": return this.animate; - case "retexture": return this.retexture; - case "text-to-image": return this.textToImage; - case "text-to-motion": return this.textToMotion; - case "image-to-image": return this.imageToImage; - case "multi-color-print": return this.multiColorPrint; - case "analyze-printability": return this.analyzePrintability; - case "repair-printability": return this.repairPrintability; + private transportFor(base: ApiBase): Transport { + switch (base) { + case "v1": + return this.v1; + case "v2": + return this.v2; + case "creative-lab": { + if (this.creativeLabTransport) return this.creativeLabTransport; + const baseUrl = this.config.baseUrlCreativeLab; + if (!baseUrl) { + throw new UsageError( + "the Creative Lab base URL cannot be derived from --base-url-v1; pass --base-url-creative-lab (or MESHY_BASE_URL_CREATIVE_LAB)", + ); + } + assertCredentialAllowedForOrigin(this.config, baseUrl, "Creative Lab"); + this.creativeLabTransport = createTransport({ + baseUrl, + apiKey: this.config.apiKey, + credentialKind: this.config.credentialKind, + readTimeoutMs: this.config.readTimeoutMs, + }); + return this.creativeLabTransport; + } } } - /** Raw HTTP passthrough for `meshy api …` — selects v1 or v2 by prefix. */ + /** Endpoint for any registered task resource (`text-to-3d`, `creative-lab.lamp.build`, …). */ + endpointFor(resource: string | TaskResourceDescriptor): TaskEndpoint { + const d = typeof resource === "string" ? requireTaskResource(resource) : resource; + const existing = this.endpoints.get(d.id); + if (existing) return existing; + const ep = new TaskEndpoint(this.transportFor(d.base), d.relativePath); + this.endpoints.set(d.id, ep); + return ep; + } + + creativeLab(product: CreativeLabProduct | string, stage: CreativeLabStage | string): TaskEndpoint { + const d = creativeLabResource(product, stage); + if (!d) throw new UsageError(`unknown Creative Lab product/stage '${product}/${stage}'`); + return this.endpointFor(d); + } + + // Named accessors kept for existing call sites and tests. + get textTo3d(): TaskEndpoint { return this.endpointFor("text-to-3d"); } + get imageTo3d(): TaskEndpoint { return this.endpointFor("image-to-3d"); } + get multiImageTo3d(): TaskEndpoint { return this.endpointFor("multi-image-to-3d"); } + get remesh(): TaskEndpoint { return this.endpointFor("remesh"); } + get convert(): TaskEndpoint { return this.endpointFor("convert"); } + get resize(): TaskEndpoint { return this.endpointFor("resize"); } + get rigging(): TaskEndpoint { return this.endpointFor("rigging"); } + get animate(): TaskEndpoint { return this.endpointFor("animate"); } + get retexture(): TaskEndpoint { return this.endpointFor("retexture"); } + get textToImage(): TaskEndpoint { return this.endpointFor("text-to-image"); } + get textToMotion(): TaskEndpoint { return this.endpointFor("text-to-motion"); } + get imageToImage(): TaskEndpoint { return this.endpointFor("image-to-image"); } + get multiColorPrint(): TaskEndpoint { return this.endpointFor("multi-color-print"); } + get analyzePrintability(): TaskEndpoint { return this.endpointFor("analyze-printability"); } + get repairPrintability(): TaskEndpoint { return this.endpointFor("repair-printability"); } + get uvUnwrap(): TaskEndpoint { return this.endpointFor("uv-unwrap"); } + + /** Raw HTTP passthrough for `meshy api …` — selects the API family by flag. */ async raw( - apiVersion: "v1" | "v2", + apiVersion: "v1" | "v2" | "creative-lab", method: string, path: string, init?: RequestInit, ): Promise { - const fetcher = apiVersion === "v2" ? this.v2Fetch : this.v1Fetch; - return fetcher(path, { ...init, method }); + return this.transportFor(apiVersion).fetchRaw(path, { ...init, method }); } } +/** The 0.2.0 resource names, still valid ids in the registry. */ export const RESOURCE_NAMES = [ "text-to-3d", "image-to-3d", @@ -172,4 +142,7 @@ export const RESOURCE_NAMES = [ export type ResourceName = (typeof RESOURCE_NAMES)[number]; +/** Every registered task resource id (15 legacy + uv-unwrap + Creative Lab stages). */ +export const TASK_RESOURCE_IDS: readonly string[] = TASK_RESOURCES.map((d) => d.id); + export { MeshyApiError } from "./errors.js"; diff --git a/src/client/resource-registry.ts b/src/client/resource-registry.ts new file mode 100644 index 0000000..af889c6 --- /dev/null +++ b/src/client/resource-registry.ts @@ -0,0 +1,249 @@ +/** + * Resource registry — the single source for what the CLI can call. + * + * Every task resource (the 16 first-class endpoints plus the 4 × 2 Creative + * Lab stages) is described once: command path, API family, relative path, + * supported verbs, billing class and the payload fields that carry media. + * Commands, the `resources` index, payload normalisation and the transport + * all read from here; nothing else hard-codes a path. + * + * docs/skill-parity/endpoint-contracts.json documents the same data and + * tests/resource-registry.test.ts fails when the two drift apart. + */ + +export type ApiBase = "v1" | "v2" | "creative-lab"; +export type MediaKind = "image" | "model"; +export type Billing = "none" | "may-charge"; +export type CreativeLabProduct = "figure" | "lamp" | "keychain" | "fridge-magnet"; +export type CreativeLabStage = "prototype" | "build"; +export type Verb = "create" | "get" | "list" | "delete" | "stream"; + +export interface MediaField { + /** snake_case payload field that carries the media reference. */ + path: string; + kind: MediaKind; + many: boolean; + /** When set, only these file extensions/formats are accepted for local files. */ + formats?: readonly string[]; +} + +export interface VerbSupport { + create: boolean; + get: boolean; + list: boolean; + delete: boolean; + stream: boolean; +} + +export interface TaskResourceDescriptor { + /** Stable id, dotted for Creative Lab: `creative-lab.figure.prototype`. */ + id: string; + /** Command tokens after `meshy`. */ + commandPath: readonly string[]; + base: ApiBase; + /** Appended to the base URL of the API family. */ + relativePath: string; + /** Full path as the Skills documented it, for `resources` and endpoint-contracts. */ + legacyEndpoint: string; + supports: VerbSupport; + mediaFields: readonly MediaField[]; + /** `type` values the server reports for this resource. */ + taskTypes: readonly string[]; + billing: { create: Billing }; + /** Never true in S1: nothing billable is retried for the caller. */ + automaticRetry: false; + creativeLab?: { product: CreativeLabProduct; stage: CreativeLabStage }; + summary: string; +} + +const ALL_VERBS: VerbSupport = { create: true, get: true, list: true, delete: true, stream: true }; + +const image = (path: string, many = false, formats?: readonly string[]): MediaField => + formats ? { path, kind: "image", many, formats } : { path, kind: "image", many }; +const model = (path: string, formats?: readonly string[]): MediaField => + formats ? { path, kind: "model", many: false, formats } : { path, kind: "model", many: false }; + +function v1(id: string, relativePath: string, opts: { + mediaFields?: readonly MediaField[]; + taskTypes: readonly string[]; + billing?: Billing; + summary: string; +}): TaskResourceDescriptor { + return { + id, + commandPath: [id], + base: "v1", + relativePath, + legacyEndpoint: `/openapi/v1${relativePath}`, + supports: ALL_VERBS, + mediaFields: opts.mediaFields ?? [], + taskTypes: opts.taskTypes, + billing: { create: opts.billing ?? "may-charge" }, + automaticRetry: false, + summary: opts.summary, + }; +} + +export const CREATIVE_LAB_PRODUCTS: readonly CreativeLabProduct[] = ["figure", "lamp", "keychain", "fridge-magnet"]; +export const CREATIVE_LAB_STAGES: readonly CreativeLabStage[] = ["prototype", "build"]; +const CREATIVE_LAB_IMAGE_FORMATS = ["jpg", "jpeg", "png", "webp"] as const; + +function creativeLab(product: CreativeLabProduct, stage: CreativeLabStage, summary: string): TaskResourceDescriptor { + const relativePath = `/${product}/v1/${stage}`; + return { + id: `creative-lab.${product}.${stage}`, + commandPath: ["creative-lab", product, stage], + base: "creative-lab", + relativePath, + legacyEndpoint: `/openapi/creative-lab${relativePath}`, + supports: ALL_VERBS, + mediaFields: stage === "prototype" ? [image("image_url", false, CREATIVE_LAB_IMAGE_FORMATS)] : [], + taskTypes: [`creative-lab-${product}-${stage}`], + billing: { create: "may-charge" }, + automaticRetry: false, + creativeLab: { product, stage }, + summary, + }; +} + +export const TASK_RESOURCES: readonly TaskResourceDescriptor[] = [ + { + id: "text-to-3d", + commandPath: ["text-to-3d"], + base: "v2", + relativePath: "/text-to-3d", + legacyEndpoint: "/openapi/v2/text-to-3d", + supports: ALL_VERBS, + mediaFields: [image("texture_image_url")], + taskTypes: ["text-to-3d-preview", "text-to-3d-refine"], + billing: { create: "may-charge" }, + automaticRetry: false, + summary: "two-stage 3D generation from text (preview → refine)", + }, + v1("image-to-3d", "/image-to-3d", { mediaFields: [image("image_url")], taskTypes: ["image-to-3d"], summary: "3D from a single image (standard or smart-topology low-poly)" }), + v1("multi-image-to-3d", "/multi-image-to-3d", { mediaFields: [image("image_urls", true)], taskTypes: ["multi-image-to-3d"], summary: "3D from multiple views (beta; prefer image-to-3d)" }), + v1("remesh", "/remesh", { mediaFields: [model("model_url")], taskTypes: ["remesh"], summary: "retopologize / change polycount" }), + v1("convert", "/convert", { mediaFields: [model("model_url")], taskTypes: ["convert"], summary: "change file format only" }), + v1("resize", "/resize", { mediaFields: [model("model_url")], taskTypes: ["resize"], summary: "resize to real-world dimensions" }), + v1("rigging", "/rigging", { mediaFields: [model("model_url", ["glb"]), image("texture_image_url")], taskTypes: ["rig"], summary: "rig a humanoid mesh (+ bundled walk/run animations)" }), + v1("animate", "/animations", { taskTypes: ["animation"], summary: "apply an animation clip to a rigged mesh" }), + v1("retexture", "/retexture", { mediaFields: [model("model_url"), image("image_style_url"), image("multiview_image_urls", true)], taskTypes: ["retexture"], summary: "regenerate textures" }), + v1("text-to-image", "/text-to-image", { taskTypes: ["text-to-image"], summary: "2D image generation" }), + v1("text-to-motion", "/text-to-motion", { taskTypes: ["text-to-motion"], summary: "generate a standalone skeletal motion clip from text" }), + v1("image-to-image", "/image-to-image", { mediaFields: [image("reference_image_urls", true)], taskTypes: ["image-to-image"], summary: "2D image editing" }), + v1("multi-color-print", "/print/multi-color", { mediaFields: [model("model_url")], taskTypes: ["print-multi-color"], summary: "color-separated 3D print output" }), + v1("analyze-printability", "/print/analyze", { mediaFields: [model("model_url")], taskTypes: ["print-analyze"], billing: "none", summary: "inspect a model for 3D-printing issues (free)" }), + v1("repair-printability", "/print/repair", { mediaFields: [model("model_url")], taskTypes: ["print-repair"], summary: "fix non-watertight / non-manifold geometry" }), + v1("uv-unwrap", "/uv-unwrap", { mediaFields: [model("model_url", ["glb"])], taskTypes: ["uv-unwrap"], summary: "generate fresh UVs for a GLB (≤40k faces) — a UV white model for external texturing" }), + creativeLab("figure", "prototype", "Creative Lab figure: photo → styled concept image"), + creativeLab("figure", "build", "Creative Lab figure: prototype → textured GLB/OBJ/MTL"), + creativeLab("lamp", "prototype", "Creative Lab lamp: photo → concept image + lampshade GLB"), + creativeLab("lamp", "build", "Creative Lab lamp: prototype → lamp_stl/base_stl or bundle_zip"), + creativeLab("keychain", "prototype", "Creative Lab keychain: photo → styled concept image"), + creativeLab("keychain", "build", "Creative Lab keychain: prototype → relief GLB / OBJ bundle (zip) / zip"), + creativeLab("fridge-magnet", "prototype", "Creative Lab fridge magnet: photo → styled concept image"), + creativeLab("fridge-magnet", "build", "Creative Lab fridge magnet: prototype → relief GLB / OBJ bundle (zip) / zip"), +]; + +const BY_ID = new Map(TASK_RESOURCES.map((d) => [d.id, d] as const)); + +export function findTaskResource(id: string): TaskResourceDescriptor | undefined { + return BY_ID.get(id); +} + +export function requireTaskResource(id: string): TaskResourceDescriptor { + const d = BY_ID.get(id); + if (!d) throw new Error(`unknown task resource '${id}'`); + return d; +} + +export function taskResourceByCommandPath(path: readonly string[]): TaskResourceDescriptor | undefined { + return TASK_RESOURCES.find((d) => d.commandPath.length === path.length && d.commandPath.every((p, i) => p === path[i])); +} + +/** Resolve a Creative Lab descriptor from user-supplied tokens without ever building a path from them. */ +export function creativeLabResource(product: string, stage: string): TaskResourceDescriptor | undefined { + if (!(CREATIVE_LAB_PRODUCTS as readonly string[]).includes(product)) return undefined; + if (!(CREATIVE_LAB_STAGES as readonly string[]).includes(stage)) return undefined; + return BY_ID.get(`creative-lab.${product}.${stage}`); +} + +/** Resource ids that map 1:1 onto a top-level command (used by the legacy `--resource` flag and the index). */ +export const TOP_LEVEL_TASK_RESOURCE_IDS: readonly string[] = TASK_RESOURCES.filter((d) => d.commandPath.length === 1).map((d) => d.id); + +export interface QueryResourceDescriptor { + id: string; + commandPath: readonly string[]; + base: "v1" | "public-web"; + method: "GET"; + relativePath: string; + auth: "bearer" | "none"; + billing: Billing; + summary: string; +} + +export const QUERY_RESOURCES: readonly QueryResourceDescriptor[] = [ + { id: "balance", commandPath: ["balance"], base: "v1", method: "GET", relativePath: "/balance", auth: "bearer", billing: "none", summary: "remaining credit balance" }, + { id: "animation-catalog", commandPath: ["animation-catalog", "list"], base: "public-web", method: "GET", relativePath: "/animations/resources", auth: "none", billing: "none", summary: "public animation library (action ids); no key needed" }, + { id: "showcases", commandPath: ["showcases", "list"], base: "v1", method: "GET", relativePath: "/showcases", auth: "bearer", billing: "may-charge", summary: "Enterprise community showcases (every request is billed)" }, +]; + +export interface LocalToolDescriptor { + id: string; + commandPath: readonly string[]; + summary: string; +} + +export const LOCAL_TOOLS: readonly LocalToolDescriptor[] = [ + { id: "download", commandPath: ["download"], summary: "download selected assets of a task (from a saved task JSON, a URL or the API)" }, + { id: "project", commandPath: ["project"], summary: "meshy_output project folders: init | record | show | list | rebuild-index" }, + { id: "inspect.faces", commandPath: ["inspect", "faces"], summary: "face-count gate: pass | fail | unknown" }, + { id: "mesh.prepare-print", commandPath: ["mesh", "prepare-print"], summary: "OBJ Y-up → Z-up, scale to a target height, centre, bottom at Z=0" }, + { id: "slicer", commandPath: ["slicer"], summary: "detect installed slicers | open a file in one" }, + { id: "doctor", commandPath: ["doctor"], summary: "local environment diagnosis (no network by default)" }, + { id: "delete", commandPath: ["delete"], summary: "delete any task, whatever its resource" }, +]; + +export type ResourceIndexKind = "task" | "query" | "local"; + +export interface ResourceIndexEntry { + name: string; + kind: ResourceIndexKind; + command: string; + summary: string; + endpoint: string | null; + verbs: Verb[] | null; +} + +/** The v1 `resources` index: every task resource, query and local tool. */ +export function resourceIndex(): ResourceIndexEntry[] { + const verbsOf = (s: VerbSupport): Verb[] => + (["create", "get", "list", "wait", "stream", "delete"] as const) + .filter((v) => (v === "wait" ? s.get : s[v])) + .map((v) => v as Verb); + const tasks: ResourceIndexEntry[] = TASK_RESOURCES.map((d) => ({ + name: d.id, + kind: "task", + command: `meshy ${d.commandPath.join(" ")}`, + summary: d.summary, + endpoint: d.legacyEndpoint, + verbs: verbsOf(d.supports), + })); + const queries: ResourceIndexEntry[] = QUERY_RESOURCES.map((q) => ({ + name: q.id, + kind: "query", + command: `meshy ${q.commandPath.join(" ")}`, + summary: q.summary, + endpoint: q.base === "public-web" ? `/web/public${q.relativePath}` : `/openapi/v1${q.relativePath}`, + verbs: null, + })); + const locals: ResourceIndexEntry[] = LOCAL_TOOLS.map((l) => ({ + name: l.id, + kind: "local", + command: `meshy ${l.commandPath.join(" ")}`, + summary: l.summary, + endpoint: null, + verbs: null, + })); + return [...tasks, ...queries, ...locals]; +} diff --git a/src/client/transport.ts b/src/client/transport.ts new file mode 100644 index 0000000..7dfabfa --- /dev/null +++ b/src/client/transport.ts @@ -0,0 +1,373 @@ +/** + * HTTP transport with explicit boundaries. + * + * createAuthenticatedTransport — one API family (v1 / v2 / creative-lab): + * the bearer credential is attached only to paths that resolve inside that + * family's base URL. Absolute URLs are accepted only when they are the + * same origin *and* under the base path; scheme-relative, userinfo, + * foreign schemes and `..` segments are refused before any request. + * Redirects are never followed with a credential. + * createPublicTransport — no credential ever (catalog, media preflight, + * asset downloads). + * + * `requestJson` keeps the deadline armed until the body is fully read — a slow + * body counts against the timeout, unlike a fetcher that clears its timer as + * soon as headers arrive. `openStream` hands back the response plus an + * explicit `close()`; the caller owns idle/total deadlines from there. + * + * Failures carry a `phase` so callers can tell "the request never left" + * (connect) from "it may have been processed" (request/response/body), which + * is the whole difference between not_submitted and submission_unknown. + */ + +import { mapHttpError, MeshyApiError } from "./errors.js"; +import { logger } from "../internal/logger.js"; +import { USER_AGENT } from "../internal/user-agent.js"; + +export type TransportPhase = "validate" | "connect" | "request" | "response" | "body" | "timeout" | "aborted"; + +export class TransportError extends MeshyApiError { + readonly phase: TransportPhase; + readonly errno?: string; + + constructor(params: { message: string; phase: TransportPhase; path?: string; errno?: string; credentialKind?: "oauth" | "api_key"; cause?: unknown }) { + super({ message: params.message, status: 0, code: "network", path: params.path, credentialKind: params.credentialKind }); + this.name = "TransportError"; + this.phase = params.phase; + this.errno = params.errno; + if (params.cause !== undefined) (this as { cause?: unknown }).cause = params.cause; + } + + /** True when there is transport-level evidence the request never reached a server. */ + get neverSent(): boolean { + return this.phase === "validate" || this.phase === "connect"; + } +} + +export interface JsonResponse { + status: number; + headers: Headers; + /** Parsed body, or null for an empty body. */ + json: unknown; + /** Raw text as received. */ + text: string; + requestId: string | null; +} + +export interface RequestOptions { + body?: unknown; + query?: Record; + headers?: Record; + signal?: AbortSignal; + /** Overrides the transport default (covers headers + body). */ + timeoutMs?: number; + /** Cap on the response body; exceeding it is a protocol failure, never a truncation. */ + maxBodyBytes?: number; +} + +export interface StreamHandle { + response: Response; + /** Abort the underlying request and release the body reader. Idempotent. */ + close(): void; + signal: AbortSignal; +} + +export interface Transport { + readonly baseUrl: string; + readonly authenticated: boolean; + requestJson(method: string, path: string, opts?: RequestOptions): Promise; + /** Legacy-style fetch: validates the path, attaches auth, throws MeshyApiError on non-2xx. Body reading is the caller's. */ + fetchRaw(path: string, init?: RequestInit & { timeoutMs?: number }): Promise; + openStream(path: string, opts?: { signal?: AbortSignal; headers?: Record; connectTimeoutMs?: number }): Promise; +} + +export interface TransportConfig { + baseUrl: string; + apiKey?: string; + credentialKind?: "oauth" | "api_key"; + readTimeoutMs: number; + fetchImpl?: typeof fetch; + userAgent?: string; + defaultMaxBodyBytes?: number; +} + +export const DEFAULT_MAX_JSON_BODY_BYTES = 16 * 1024 * 1024; + +function stripTrail(s: string): string { + return s.replace(/\/+$/, ""); +} + +/** + * Resolve a caller path against the base. Only two shapes are accepted: + * - a relative path starting with a single "/" (no `..` segments), or + * - an absolute http(s) URL with the same origin whose path starts with the + * base path (an explicitly same-family URL, e.g. one the API returned). + */ +export function resolveApiUrl(baseUrl: string, path: string): URL { + const base = new URL(`${stripTrail(baseUrl)}/`); + if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) { + let target: URL; + try { + target = new URL(path); + } catch { + throw new TransportError({ message: `invalid URL: ${path}`, phase: "validate", path }); + } + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new TransportError({ message: `refusing ${target.protocol} URL for an API request`, phase: "validate", path }); + } + if (target.username || target.password) { + throw new TransportError({ message: "refusing URL with embedded credentials", phase: "validate", path }); + } + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname.replace(/\/$/, ""))) { + throw new TransportError({ + message: `refusing to send this API family's credential to ${target.origin}${target.pathname}; it is outside ${stripTrail(baseUrl)}`, + phase: "validate", + path, + }); + } + return target; + } + if (!path.startsWith("/")) path = `/${path}`; + const segments = path.split("?")[0]!.split("/"); + if (segments.some((s) => s === "..")) { + throw new TransportError({ message: `refusing path with '..' segment: ${path}`, phase: "validate", path }); + } + // URL() would treat a leading "//" as scheme-relative — already rejected above. + const target = new URL(`.${path}`, base); + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname.replace(/\/$/, ""))) { + throw new TransportError({ message: `path ${path} escapes the API base`, phase: "validate", path }); + } + return target; +} + +function errnoOf(err: unknown): string | undefined { + const e = err as { code?: unknown; cause?: { code?: unknown } } | undefined; + const code = e?.cause?.code ?? e?.code; + return typeof code === "string" ? code : undefined; +} + +const CONNECT_ERRNOS = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "ERR_TLS_CERT_ALTNAME_INVALID", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "SELF_SIGNED_CERT_IN_CHAIN"]); + +function classifyFetchFailure(err: unknown, phase: TransportPhase): TransportPhase { + const errno = errnoOf(err); + if (errno && CONNECT_ERRNOS.has(errno)) return "connect"; + return phase; +} + +function combineSignals(...signals: Array): AbortSignal | undefined { + const present = signals.filter((s): s is AbortSignal => Boolean(s)); + if (present.length === 0) return undefined; + if (present.length === 1) return present[0]; + return AbortSignal.any(present); +} + +export function createTransport(cfg: TransportConfig): Transport { + const baseUrl = stripTrail(cfg.baseUrl); + const authenticated = Boolean(cfg.apiKey); + const userAgent = cfg.userAgent ?? USER_AGENT; + const maxBody = cfg.defaultMaxBodyBytes ?? DEFAULT_MAX_JSON_BODY_BYTES; + + function headersFor(extra?: ConstructorParameters[0]): Headers { + const headers = new Headers(extra); + if (cfg.apiKey) headers.set("Authorization", `Bearer ${cfg.apiKey}`); + else headers.delete("Authorization"); + headers.delete("Cookie"); + headers.set("User-Agent", userAgent); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + return headers; + } + + async function doFetch(url: URL, init: RequestInit, timeoutMs: number, externalSignal: AbortSignal | undefined, path: string): Promise<{ resp: Response; release: () => void; timeoutSignal: AbortSignal }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs); + const signal = combineSignals(controller.signal, externalSignal)!; + const release = () => clearTimeout(timer); + const fetchImpl = cfg.fetchImpl ?? globalThis.fetch; + try { + logger.debug(`HTTP ${init.method ?? "GET"} ${url.href}`); + const resp = await fetchImpl(url, { ...init, signal, redirect: "manual" }); + logger.debug(`HTTP ${resp.status} ${url.href}`); + return { resp, release, timeoutSignal: controller.signal }; + } catch (err) { + release(); + throw wrapFetchError(err, "request", path, externalSignal, controller.signal, timeoutMs); + } + } + + function wrapFetchError(err: unknown, phase: TransportPhase, path: string, externalSignal: AbortSignal | undefined, timeoutSignal: AbortSignal, timeoutMs: number): TransportError { + if (externalSignal?.aborted) { + return new TransportError({ message: `request to ${path} aborted`, phase: "aborted", path, cause: err, credentialKind: cfg.credentialKind }); + } + if (timeoutSignal.aborted) { + return new TransportError({ message: `request to ${path} timed out after ${timeoutMs}ms`, phase: "timeout", path, cause: err, credentialKind: cfg.credentialKind }); + } + const errno = errnoOf(err); + const msg = err instanceof Error ? err.message : String(err); + return new TransportError({ + message: `network error calling ${path}: ${msg}${errno ? ` (${errno})` : ""}`, + phase: classifyFetchFailure(err, phase), + path, + errno, + cause: err, + credentialKind: cfg.credentialKind, + }); + } + + function refuseRedirect(resp: Response, path: string): void { + if (resp.status >= 300 && resp.status < 400) { + throw new TransportError({ + message: `refusing to follow HTTP ${resp.status} redirect from ${path} (redirects are never followed with an API credential)`, + phase: "response", + path, + credentialKind: cfg.credentialKind, + }); + } + } + + async function readBodyWithin(resp: Response, cap: number, path: string, externalSignal: AbortSignal | undefined, timeoutSignal: AbortSignal, timeoutMs: number): Promise { + if (!resp.body) return ""; + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > cap) { + await reader.cancel().catch(() => undefined); + throw new MeshyApiError({ message: `response body from ${path} exceeds ${cap} bytes`, status: resp.status, code: "server", path }); + } + chunks.push(value); + } + } + } catch (err) { + if (err instanceof MeshyApiError) throw err; + throw wrapFetchError(err, "body", path, externalSignal, timeoutSignal, timeoutMs); + } + const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total); + return buf.toString("utf8"); + } + + const transport: Transport = { + baseUrl, + authenticated, + + async requestJson(method, path, opts = {}) { + const url = resolveApiUrl(baseUrl, path); + if (opts.query) { + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue; + url.searchParams.set(k, String(v)); + } + } + const headers = headersFor(opts.headers); + const init: RequestInit = { method }; + if (opts.body !== undefined) { + headers.set("Content-Type", "application/json"); + init.body = JSON.stringify(opts.body); + } + init.headers = headers; + const timeoutMs = opts.timeoutMs ?? cfg.readTimeoutMs; + const { resp, release, timeoutSignal } = await doFetch(url, init, timeoutMs, opts.signal, path); + try { + refuseRedirect(resp, path); + if (!resp.ok) { + // mapHttpError reads the body itself; it is small (an error message). + throw await mapHttpError(resp, path, cfg.credentialKind); + } + const text = await readBodyWithin(resp, opts.maxBodyBytes ?? maxBody, path, opts.signal, timeoutSignal, timeoutMs); + let json: unknown = null; + if (text.trim().length > 0) { + try { + json = JSON.parse(text); + } catch (err) { + throw new MeshyApiError({ + message: `invalid JSON in response from ${path}: ${err instanceof Error ? err.message : String(err)}`, + status: resp.status, + code: "server", + path, + body: text.slice(0, 200), + }); + } + } + return { status: resp.status, headers: resp.headers, json, text, requestId: resp.headers.get("x-request-id") }; + } finally { + release(); + } + }, + + async fetchRaw(path, init = {}) { + const url = resolveApiUrl(baseUrl, path); + const headers = headersFor(init.headers); + const timeoutMs = init.timeoutMs ?? cfg.readTimeoutMs; + const { resp, release } = await doFetch(url, { ...init, headers }, timeoutMs, init.signal ?? undefined, path); + try { + refuseRedirect(resp, path); + if (!resp.ok) throw await mapHttpError(resp, path, cfg.credentialKind); + // The caller reads the body; give it the whole remaining budget by + // leaving the timer armed until the body is consumed or GC'd. + const body = resp.body; + if (!body) { + release(); + return resp; + } + const watched = new Response( + body.pipeThrough( + new TransformStream({ + flush() { + release(); + }, + }), + ), + { status: resp.status, statusText: resp.statusText, headers: resp.headers }, + ); + return watched; + } catch (err) { + release(); + throw err; + } + }, + + async openStream(path, opts = {}) { + const url = resolveApiUrl(baseUrl, path); + const headers = headersFor({ Accept: "text/event-stream", ...(opts.headers ?? {}) }); + const controller = new AbortController(); + const signal = combineSignals(controller.signal, opts.signal)!; + const connectTimeoutMs = opts.connectTimeoutMs ?? cfg.readTimeoutMs; + const connectTimer = setTimeout(() => controller.abort(new Error(`stream connect timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs); + const fetchImpl = cfg.fetchImpl ?? globalThis.fetch; + let resp: Response; + try { + logger.debug(`HTTP GET (stream) ${url.href}`); + resp = await fetchImpl(url, { method: "GET", headers, signal, redirect: "manual" }); + } catch (err) { + clearTimeout(connectTimer); + throw wrapFetchError(err, "request", path, opts.signal, controller.signal, connectTimeoutMs); + } + clearTimeout(connectTimer); + const close = () => { + if (!controller.signal.aborted) controller.abort(new Error("stream closed by client")); + resp.body?.cancel().catch(() => undefined); + }; + try { + refuseRedirect(resp, path); + if (!resp.ok) throw await mapHttpError(resp, path, cfg.credentialKind); + } catch (err) { + close(); + throw err; + } + return { response: resp, close, signal }; + }, + }; + return transport; +} + +export function createAuthenticatedTransport(cfg: TransportConfig & { apiKey: string }): Transport { + return createTransport(cfg); +} + +export function createPublicTransport(cfg: Omit): Transport { + return createTransport({ ...cfg, apiKey: undefined, credentialKind: undefined }); +} diff --git a/src/client/types.ts b/src/client/types.ts index 2f6d671..c487ea0 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,5 +1,9 @@ /** * Zod schemas for Meshy request/response shapes (permissive passthrough). + * + * `TaskSchema` fills a few defaults so 0.2.0 summaries keep their shape. The + * v1 TaskView is built from the *raw* JSON (see task-view.ts), never from the + * defaulted object, so a field the server did not send stays null there. */ import { z } from "zod"; @@ -38,6 +42,10 @@ export type TaskStatus = z.infer; export const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]); +export function isTerminalStatus(status: string | null | undefined): boolean { + return typeof status === "string" && TERMINAL_STATUSES.has(status); +} + export const PrintabilitySchema = z .object({ _version: z.string().optional(), @@ -51,24 +59,37 @@ export const PrintabilitySchema = z .passthrough(); export type Printability = z.infer; +/** A count or epoch-millisecond field that a server may omit or send as null: absent and null both read as 0. */ +const nullableNumberOr0 = z + .number() + .nullable() + .optional() + .transform((v) => v ?? 0); + export const TaskSchema = z .object({ id: z.string(), type: z.string().default(""), + name: z.string().nullable().optional(), status: z.string().default(""), - progress: z.number().default(0), - preceding_tasks: z.number().default(0), + // The v2 endpoints report 0 for a timestamp that has not happened yet; the + // Creative Lab endpoints report null (observed live: finished_at: null while + // IN_PROGRESS). Both mean "not yet" and normalise to 0. + progress: nullableNumberOr0, + preceding_tasks: nullableNumberOr0, - created_at: z.number().default(0), - started_at: z.number().default(0), - finished_at: z.number().default(0), - expires_at: z.number().default(0), + created_at: nullableNumberOr0, + started_at: nullableNumberOr0, + finished_at: nullableNumberOr0, + expires_at: nullableNumberOr0, task_error: TaskErrorSchema.nullable().optional(), model_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), texture_urls: z.array(TextureSetSchema).nullable().optional(), thumbnail_url: z.string().nullable().optional(), + thumbnail_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), + alpha_thumbnail_url: z.string().nullable().optional(), image_urls: z.array(z.string()).nullable().optional(), @@ -76,6 +97,9 @@ export const TaskSchema = z printability: PrintabilitySchema.nullable().optional(), + face_count: z.number().nullable().optional(), + consumed_credits: z.number().nullable().optional(), + ai_model: z.string().nullable().optional(), prompt: z.string().nullable().optional(), texture_prompt: z.string().nullable().optional(), @@ -108,6 +132,7 @@ export interface TaskSummary { elapsed_seconds?: number; } +/** Legacy (0.2.0) summary — shape preserved for existing consumers. */ export function summarizeTask(task: Task, elapsedSeconds?: number): TaskSummary { const summary: TaskSummary = { id: task.id, diff --git a/src/cmd/animation-catalog.ts b/src/cmd/animation-catalog.ts new file mode 100644 index 0000000..2b017da --- /dev/null +++ b/src/cmd/animation-catalog.ts @@ -0,0 +1,101 @@ +/** + * animation-catalog — the public Animation Library behind `animate --action-id`. + * + * GET /web/public/animations/resources[?category=…]. No credential + * is loaded or sent; the command works without any Meshy account. `--search` + * filters the fetched batch locally (name / key / subCategory, case-insensitive) + * and says so in the result — it is not a server-side search. + */ + +import { Command, Option } from "commander"; +import { AnimationCatalogEndpoint, ANIMATION_CATEGORIES, type AnimationCatalogEntry } from "../client/endpoints/animation-catalog.js"; +import { createPublicTransport } from "../client/transport.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { DEFAULT_BASE_URL_V1, derivePublicWebBase } from "../internal/config.js"; +import { abortSignal } from "../internal/context.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; +import { warning, type Warning } from "../internal/result.js"; + +export interface CatalogItem { + action_id: number; + name: string | null; + key: string | null; + category: string | null; + sub_category: string | null; + preview_url: string | null; + rig_type: string | null; + is_default: boolean | null; + is_free: boolean | null; +} + +export function toCatalogItem(e: AnimationCatalogEntry): CatalogItem { + return { + action_id: e.id, + name: e.name ?? null, + key: e.key ?? null, + category: e.category ?? null, + sub_category: e.subCategory ?? null, + preview_url: e.previewUrl ?? null, + rig_type: e.rigType ?? null, + is_default: e.isDefault ?? null, + is_free: e.isFree ?? null, + }; +} + +/** Case-insensitive substring match over name, key and sub-category. */ +export function matchesSearch(item: CatalogItem, search: string): boolean { + const needle = search.trim().toLowerCase(); + if (!needle) return true; + return [item.name, item.key, item.sub_category].some((v) => typeof v === "string" && v.toLowerCase().includes(needle)); +} + +const listCommand = new Command("list") + .description("Fetch the public animation catalog (no API key needed) and list action ids") + .addOption(new Option("--category ", "server-side category filter").choices([...ANIMATION_CATEGORIES])) + .option("--search ", "local, case-insensitive match on name / key / sub-category") + .option("--include-raw", "include the untouched catalog response under result.raw") + .option("--save-json ", "save the untouched catalog response to this file (never overwrites)") + .action(async (opts: { category?: string; search?: string; includeRaw?: boolean; saveJson?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "animation-catalog.list", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + buildLocalRuntime(opened.flags); + const baseV1 = opened.flags.baseUrlV1 ?? process.env["MESHY_BASE_URL_V1"] ?? DEFAULT_BASE_URL_V1; + const publicBase = derivePublicWebBase(baseV1); + const transport = createPublicTransport({ baseUrl: publicBase, readTimeoutMs: readTimeout() }); + const endpoint = new AnimationCatalogEndpoint(transport); + const { entries, total, raw } = await endpoint.list({ category: opts.category }, { signal: abortSignal() }); + const all = entries.map(toCatalogItem); + const items = opts.search ? all.filter((i) => matchesSearch(i, opts.search!)) : all; + const warnings: Warning[] = []; + if (total !== null && total !== entries.length) { + warnings.push(warning("catalog_total_mismatch", `server reported total=${total} but returned ${entries.length} entries; only the returned batch was searched`)); + } + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; + await emitResult( + opened, + items, + { + items, + count: items.length, + fetched: entries.length, + total, + filters: { category: opts.category ?? null, search: opts.search ?? null }, + search_scope: "local", + source: `${publicBase}${AnimationCatalogEndpoint.PATH}`, + authenticated: false, + saved_json: saved, + ...(opts.includeRaw ? { raw } : {}), + }, + { warnings }, + ); + }); + +function readTimeout(): number { + const raw = process.env["MESHY_READ_TIMEOUT_MS"]; + const n = raw ? Number(raw) : NaN; + return Number.isFinite(n) && n > 0 ? n : 120_000; +} + +export const animationCatalogCommand = new Command("animation-catalog") + .description("Public animation library lookups (no API key required)") + .addCommand(listCommand); diff --git a/src/cmd/api.ts b/src/cmd/api.ts index 1218e6a..70c3c10 100644 --- a/src/cmd/api.ts +++ b/src/cmd/api.ts @@ -1,14 +1,18 @@ /** - * Raw API passthrough: `meshy-cli api [--v1|--v2] [--data ] [--params ]` + * Raw API passthrough: `meshy-cli api [--v1|--v2|--creative-lab] [--data ] [--params ]` * * Prints the JSON body when the response is JSON, or raw text otherwise. - * Non-2xx responses exit with code 1 and print an error summary on stderr. + * Non-2xx responses exit non-zero with an error payload. Write verbs are + * never retried: the passthrough is a controlled escape hatch, not a client. */ -import { Command } from "commander"; -import { emit } from "../internal/output.js"; +import { Command, Option } from "commander"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; import { parseJsonFlag } from "../internal/payload.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { buildRuntime } from "../internal/runtime.js"; + +const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); export const apiCommand = new Command("api") .description("Raw HTTP passthrough to the Meshy API (JSON only)") @@ -16,12 +20,20 @@ export const apiCommand = new Command("api") .argument("", "API path (e.g. /text-to-3d or /balance)") .option("--v2", "use the v2 base URL (default: v1)") .option("--v1", "force v1 (default)") + .addOption(new Option("--creative-lab", "use the Creative Lab base URL (paths like /figure/v1/prototype)")) .option("--data ", "request body JSON (or @file.json)") .option("--params ", "query params JSON object") + .option("--save-json ", "v1: also save the raw response body to this file (never overwrites)") .action(async (method: string, path: string, opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const apiVersion = opts.v2 ? "v2" : "v1"; + const opened = openCommand(thisCmd, "api", "legacy"); + rejectOutputFlagForV1(opened, opts.saveJson as string | undefined); const verb = method.toUpperCase(); + if (!METHODS.has(verb)) throw new UsageError(`unsupported HTTP method '${method}'. Expected: GET | POST | PUT | PATCH | DELETE`); + if ((opts.v2 ? 1 : 0) + (opts.v1 ? 1 : 0) + (opts.creativeLab ? 1 : 0) > 1) { + throw new UsageError("--v1, --v2 and --creative-lab are mutually exclusive"); + } + const apiVersion = opts.v2 ? "v2" : opts.creativeLab ? "creative-lab" : "v1"; + const runtime = await buildRuntime(opened.flags); let finalPath = path.startsWith("/") ? path : `/${path}`; const params = parseJsonFlag(opts.params as string | undefined, "--params"); @@ -48,5 +60,12 @@ export const apiCommand = new Command("api") } catch { /* leave as text */ } - emit(parsed, { format: runtime.flags.format, file: runtime.flags.output }); + const saveJson = opts.saveJson as string | undefined; + const saved = saveJson ? saveRawJson(saveJson, parsed, { workspace: opened.flags.workspaceRoot }) : null; + await emitResult( + opened, + parsed, + { http_status: resp.status, method: verb, path: finalPath, api: apiVersion, body: parsed, saved_json: saved }, + { legacyFile: opened.flags.output }, + ); }); diff --git a/src/cmd/auth.ts b/src/cmd/auth.ts index 955c177..a1727b1 100644 --- a/src/cmd/auth.ts +++ b/src/cmd/auth.ts @@ -16,6 +16,7 @@ */ import * as readline from "node:readline"; +import { randomUUID } from "node:crypto"; import { Command } from "commander"; import { MeshyClient, MeshyApiError } from "../client/index.js"; import { loadConfig } from "../internal/config.js"; @@ -116,12 +117,16 @@ async function finishLogin( }, ): Promise { const file = resolveFile(flags); + // Every login gets its own identifier: the operation journal binds a + // submission to the account (user_id) when the token endpoint reports one, + // and to this login otherwise — never to a token that rotates on refresh. const profileData = { kind: "oauth" as const, access_token: tok.access_token, refresh_token: tok.refresh_token, expires_at: Date.now() + tok.expires_in * 1000, ...(tok.user_id ? { user_id: tok.user_id } : {}), + login_id: randomUUID(), }; const credState = saveProfile(file, opts.profile, profileData); diff --git a/src/cmd/balance.ts b/src/cmd/balance.ts index 3aa41df..d6f6f93 100644 --- a/src/cmd/balance.ts +++ b/src/cmd/balance.ts @@ -3,13 +3,17 @@ */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { buildRuntime } from "../internal/runtime.js"; export const balanceCommand = new Command("balance") .description("Show the current API key's credit balance") - .action(async (_opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const balance = await runtime.client.balance.get(); - emit(balance, { format: runtime.flags.format, file: runtime.flags.output }); + .option("--save-json ", "v1: also save the raw API response to this file (never overwrites)") + .action(async (opts: { saveJson?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "balance", "legacy"); + rejectOutputFlagForV1(opened, opts.saveJson); + const runtime = await buildRuntime(opened.flags); + const { balance, raw } = await runtime.client.balance.getWithRaw(); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; + await emitResult(opened, balance, { balance: balance.balance, saved_json: saved }, { legacyFile: opened.flags.output }); }); diff --git a/src/cmd/creative-lab.ts b/src/cmd/creative-lab.ts new file mode 100644 index 0000000..b355e6d --- /dev/null +++ b/src/cmd/creative-lab.ts @@ -0,0 +1,279 @@ +/** + * creative-lab — https://docs.meshy.ai/en/api/creative-lab-{figure,lamp,keychain,fridge-magnet} + * + * Four products × two stages, each its own registered resource with the same + * create/get/list/wait/stream/delete verbs. Nothing here builds a path from a + * user string: product and stage are resolved through the registry. + * + * Stage meanings differ by product and are not generalised: + * figure / keychain / fridge-magnet prototype → a styled concept image + * lamp prototype → concept image + lampshade GLB + * figure build → GLB, OBJ, MTL, base-color texture + * lamp build → lamp_stl (+ base_stl) or bundle_zip + * keychain / fridge-magnet build → glb, obj (a ZIP bundle) or bundle_zip + * + * Build options are validated per product with the ranges the API documents + * so a bad value fails before a billable POST; unknown option keys are passed + * through (the server is the final validator). `options` and `output` are + * declared as nested-merge keys, so `--data '{"options":{…}}'`, `--options` + * and the typed flags compose field by field (typed flags win) instead of the + * later layer deleting the earlier one's settings. + */ + +import { Command, Option } from "commander"; +import { z } from "zod"; +import { CREATIVE_LAB_PRODUCTS, type CreativeLabProduct, type CreativeLabStage } from "../client/resource-registry.js"; +import { UsageError } from "../internal/errors.js"; +import { parseJsonFlag } from "../internal/payload.js"; +import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; + +const NAME_MAX = 100; + +const lampOptionsSchema = z + .object({ + diameter_mm: z.number().min(50).max(400).optional(), + thickness_mm: z.number().gt(0).max(10).optional(), + cut_amount_percent: z.number().min(1).max(100).optional(), + light_source_preset: z.enum(["bambu_mh001_60mm", "none"]).optional(), + fixture_offset_x_mm: z.number().min(-80).max(80).optional(), + fixture_offset_z_mm: z.number().min(-80).max(80).optional(), + rotate_x_deg: z.number().min(-360).max(360).optional(), + rotate_y_deg: z.number().min(-360).max(360).optional(), + rotate_z_deg: z.number().min(-360).max(360).optional(), + include_result_json: z.boolean().optional(), + }) + .passthrough(); + +const reliefOptionsSchema = z + .object({ + badge_shape: z.enum(["circle", "rounded-rect", "hexagon", "shield", "star"]).optional(), + size_mm: z.number().gt(0).max(400).optional(), + relief_height_mm: z.number().min(0).max(20).optional(), + relief_offset_mm: z.number().min(0).max(20).optional(), + base_thickness_mm: z.number().min(0).max(20).optional(), + has_closed_back: z.boolean().optional(), + relief_curve: z.enum(["linear", "gamma", "s-curve"]).optional(), + curve_param: z.number().gt(0).max(10).optional(), + invert_depth: z.boolean().optional(), + smoothing: z.number().min(0).max(10).optional(), + relief_scale: z.number().gt(0).max(10).optional(), + depth_threshold: z.number().min(0).max(1).optional(), + remove_background: z.boolean().optional(), + export_resolution: z.number().int().min(64).max(2048).optional(), + }) + .passthrough(); + +interface ProductDescriptor { + product: CreativeLabProduct; + label: string; + prototypeMeaning: string; + buildMeaning: string; + /** Extra prototype flags beyond image/name/remove-background. */ + prototypeExtra?: (cmd: Command) => Command; + prototypeExtraPayload?: (opts: Record) => Record; + validatePrototype?: (payload: Record) => void; + /** Build output formats (`output.format`); undefined when the product has none. */ + buildFormats?: readonly string[]; + optionsSchema?: z.ZodTypeAny; + /** Whether the product accepts `options`/`output` at all. */ + hasBuildOptions: boolean; + validateBuild?: (payload: Record) => void; +} + +const PRODUCTS: Record = { + figure: { + product: "figure", + label: "figure", + prototypeMeaning: "styled concept image of the figure", + buildMeaning: "textured figure: GLB, OBJ + MTL and a base-color texture", + hasBuildOptions: false, + }, + lamp: { + product: "lamp", + label: "lamp", + prototypeMeaning: "concept image plus a hollow matte-white lampshade GLB", + buildMeaning: "printable lamp parts: lamp_stl (+ base_stl with a light-source preset) or bundle_zip", + prototypeExtra: (cmd) => + cmd.addOption(new Option("--image-subject ", "what the photo shows (default: character)").choices(["character", "landscape"])), + prototypeExtraPayload: (opts) => ({ image_subject: opts.imageSubject }), + validatePrototype: (payload) => { + if ("text" in payload) { + throw new UsageError("lamp prototype: the `text` input is deprecated and not accepted by this CLI; provide --image-url (a photo) instead"); + } + if (payload.image_subject !== undefined && payload.image_subject !== "character" && payload.image_subject !== "landscape") { + throw new UsageError("image_subject must be 'character' or 'landscape'"); + } + }, + buildFormats: ["stl", "zip"], + optionsSchema: lampOptionsSchema, + hasBuildOptions: true, + validateBuild: (payload) => { + const options = (payload.options ?? {}) as Record; + const output = (payload.output ?? {}) as Record; + if (options.include_result_json === true && output.format !== "zip") { + throw new UsageError("lamp build: include_result_json=true requires --model-format zip (the STL output has no place for result.json)"); + } + }, + }, + keychain: { + product: "keychain", + label: "keychain", + prototypeMeaning: "styled concept image of the keychain", + buildMeaning: "relief keychain: glb, obj (a ZIP bundle with model.obj/model.mtl/texture.png) or bundle_zip", + buildFormats: ["glb", "obj", "zip"], + optionsSchema: reliefOptionsSchema, + hasBuildOptions: true, + }, + "fridge-magnet": { + product: "fridge-magnet", + label: "fridge magnet", + prototypeMeaning: "styled concept image of the magnet", + buildMeaning: "relief fridge magnet: glb, obj (a ZIP bundle with model.obj/model.mtl/texture.png) or bundle_zip", + buildFormats: ["glb", "obj", "zip"], + optionsSchema: reliefOptionsSchema, + hasBuildOptions: true, + }, +}; + +function checkName(payload: Record): void { + if (payload.name === undefined || payload.name === null) return; + if (typeof payload.name !== "string") throw new UsageError("name must be a string"); + if (payload.name.length > NAME_MAX) throw new UsageError(`name must be at most ${NAME_MAX} characters (got ${payload.name.length})`); +} + +function prototypeSpec(p: ProductDescriptor): ResourceCommandSpec { + return { + name: `creative-lab.${p.product}.prototype`, + commandName: "prototype", + defaultSchema: "v1", + description: `Creative Lab ${p.label} — prototype stage: photo → ${p.prototypeMeaning}`, + create: { + description: `Create a ${p.label} prototype from a photo`, + configure(cmd) { + const base = cmd + .option("--image-url ", "photo as http(s) URL, data: URI or local jpg/jpeg/png/webp path (required)") + .option("--name ", `optional name, at most ${NAME_MAX} characters`) + .option("--remove-background", "return the concept image as a transparent RGBA PNG (default: false)"); + return p.prototypeExtra ? p.prototypeExtra(base) : base; + }, + toPayload(opts) { + return { + image_url: opts.imageUrl, + name: opts.name, + remove_background: opts.removeBackground === true ? true : undefined, + ...(p.prototypeExtraPayload ? p.prototypeExtraPayload(opts) : {}), + }; + }, + validatePayload(payload) { + if (payload.image_url === undefined || payload.image_url === null || payload.image_url === "") { + throw new UsageError("provide --image-url (a photo URL, data: URI or local file)"); + } + checkName(payload); + if (payload.remove_background !== undefined && typeof payload.remove_background !== "boolean") { + throw new UsageError("remove_background must be a boolean"); + } + p.validatePrototype?.(payload); + }, + }, + }; +} + +function buildSpec(p: ProductDescriptor): ResourceCommandSpec { + return { + name: `creative-lab.${p.product}.build`, + commandName: "build", + defaultSchema: "v1", + description: `Creative Lab ${p.label} — build stage: SUCCEEDED prototype → ${p.buildMeaning}`, + create: { + description: `Create a ${p.label} build from a prototype task created through this API`, + nestedObjectKeys: ["options", "output"], + configure(cmd) { + cmd + .option("--input-task-id ", "SUCCEEDED prototype task of the same product created with the same API key (required)") + .option("--name ", `optional name, at most ${NAME_MAX} characters`); + if (p.hasBuildOptions) { + cmd.option("--options ", "product-specific build options as JSON (or @file.json); merged into payload.options, typed flags win"); + if (p.buildFormats) { + cmd.addOption(new Option("--model-format ", `output.format (default: ${p.buildFormats[0]})`).choices([...p.buildFormats])); + } + if (p.product === "lamp") { + cmd.option("--include-result-json", "include result.json in the bundle (requires --model-format zip)"); + } + } + return cmd; + }, + toPayload(opts) { + const payload: Record = { + input_task_id: opts.inputTaskId, + name: opts.name, + }; + if (p.hasBuildOptions) { + const optionsFlag = parseJsonFlag(opts.options as string | undefined, "--options"); + const options: Record = { ...optionsFlag }; + if (p.product === "lamp" && opts.includeResultJson === true) options.include_result_json = true; + if (Object.keys(options).length > 0) payload.options = options; + if (opts.modelFormat) payload.output = { format: opts.modelFormat }; + } + return payload; + }, + validatePayload(payload) { + if (payload.input_task_id === undefined || payload.input_task_id === null || payload.input_task_id === "") { + throw new UsageError("provide --input-task-id (the SUCCEEDED prototype task)"); + } + if (typeof payload.input_task_id !== "string") throw new UsageError("input_task_id must be a string"); + checkName(payload); + if (!p.hasBuildOptions) { + if (payload.options !== undefined || payload.output !== undefined) { + throw new UsageError(`${p.label} build has no options/output parameters; remove them from --data`); + } + return; + } + if (payload.options !== undefined) { + if (!payload.options || typeof payload.options !== "object" || Array.isArray(payload.options)) { + throw new UsageError("options must be a JSON object"); + } + const parsed = p.optionsSchema!.safeParse(payload.options); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new UsageError(`${p.label} build options invalid: ${issue ? `${issue.path.join(".")}: ${issue.message}` : parsed.error.message}`); + } + } + if (payload.output !== undefined) { + const out = payload.output as Record | null; + if (!out || typeof out !== "object" || Array.isArray(out)) throw new UsageError("output must be a JSON object"); + if (out.format !== undefined && !(p.buildFormats ?? []).includes(String(out.format))) { + throw new UsageError(`${p.label} build output.format must be one of ${(p.buildFormats ?? []).join(" | ")}`); + } + } + p.validateBuild?.(payload); + }, + }, + }; +} + +function productCommand(product: CreativeLabProduct): Command { + const p = PRODUCTS[product]; + const cmd = new Command(product).description(`Creative Lab ${p.label}: prototype (${p.prototypeMeaning}) then build (${p.buildMeaning})`); + cmd.addCommand(buildResourceCommand(prototypeSpec(p))); + cmd.addCommand(buildResourceCommand(buildSpec(p))); + return cmd; +} + +export const creativeLabCommand = new Command("creative-lab") + .description("Creative Lab physical products from a photo: figure | lamp | keychain | fridge-magnet, each with prototype and build stages") + .addHelpText( + "after", + ` +Stages differ per product — prototype is a concept image (lamp also yields a lampshade GLB); +build consumes a SUCCEEDED prototype created through this API with the same key. Web-app +prototypes are rejected by the server (404). Build never re-runs a prototype. + + meshy creative-lab figure prototype create --image-url ./photo.png --name demo --async + meshy creative-lab figure build create --input-task-id --async + meshy creative-lab lamp build create --input-task-id --model-format zip --options '{"diameter_mm":180}' +`, + ); + +for (const product of CREATIVE_LAB_PRODUCTS) creativeLabCommand.addCommand(productCommand(product)); + +export const CREATIVE_LAB_STAGE_NAMES: readonly CreativeLabStage[] = ["prototype", "build"]; diff --git a/src/cmd/delete.ts b/src/cmd/delete.ts index 3a220cd..0a505d9 100644 --- a/src/cmd/delete.ts +++ b/src/cmd/delete.ts @@ -4,17 +4,21 @@ */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { emitResult, openCommand, rejectOutputFlagForV1 } from "../internal/command-helpers.js"; +import { buildRuntime } from "../internal/runtime.js"; export const deleteCommand = new Command("delete") .description("Delete any task by id") .argument("", "Meshy task id") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); + const opened = openCommand(thisCmd, "delete", "legacy"); + rejectOutputFlagForV1(opened, undefined); + const runtime = await buildRuntime(opened.flags); await runtime.client.textTo3d.delete(taskId); - emit({ task_id: taskId, deleted: true }, { - format: runtime.flags.format, - file: runtime.flags.output, - }); + await emitResult( + opened, + { task_id: taskId, deleted: true }, + { task_id: taskId, resource: null, endpoint: "/openapi/v2/text-to-3d", deleted: true }, + { legacyFile: opened.flags.output }, + ); }); diff --git a/src/cmd/doctor.ts b/src/cmd/doctor.ts new file mode 100644 index 0000000..1e0961e --- /dev/null +++ b/src/cmd/doctor.ts @@ -0,0 +1,72 @@ +/** + * doctor — environment diagnosis with a strictly local default. + * + * Exit policy: the default run always exits 0, whatever it finds — a warning + * about a missing credential *is* the diagnosis, not a failure of the command. + * `--check-api` is different: the caller asked whether the account works, so + * a credential that cannot be resolved or is rejected exits 3 and a transport + * failure exits 7, each with the full report kept in `result` (D-024). + */ + +import { Command } from "commander"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { runDoctorDetailed, type DoctorApiFailure } from "../internal/doctor.js"; +import { classifyError, CliError, type CliErrorCode } from "../internal/errors.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +interface DoctorCommandOptions { + checkApi?: boolean; + checkSlicers?: boolean; + saveJson?: string; +} + +/** + * Map a --check-api failure onto the exit contract. Anything that stops a + * credential from being resolved (none found, unusable key file, corrupt + * profile store) is `auth`; a rejected credential stays `auth`; a transport + * failure stays `network`; other API answers keep their own classification. + */ +export function apiCheckFailure(failure: DoctorApiFailure, result: Record): CliError { + const classified = classifyError(failure.error); + const code: CliErrorCode = failure.stage === "credentials" ? "auth" : classified.code; + return new CliError({ + code, + message: `--check-api failed: ${classified.message}`, + httpStatus: classified.httpStatus, + retryable: classified.retryable, + recovery: classified.recovery, + hint: classified.hint, + result, + cause: failure.error, + }); +} + +/** Build a fresh `doctor` command (tests parse a new tree per run). */ +export function buildDoctorCommand(): Command { + return new Command("doctor") + .description( + "Diagnose the local environment: versions, credential sources (presence only, values never read), base URLs, workspace. " + + "No network by default; --check-api makes one free GET /balance, --check-slicers runs slicer detection", + ) + .option("--check-api", "resolve the credential like an API command and call GET /balance once (free); exit 3 if no credential works, 7 if unreachable") + .option("--check-slicers", "detect installed slicers (local only)") + .option("--save-json ", "save the report to this file (never overwrites)") + .action(async (opts: DoctorCommandOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "doctor", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + buildLocalRuntime(opened.flags); + const { report, apiFailure } = await runDoctorDetailed({ + flags: opened.flags, + checkApi: Boolean(opts.checkApi), + checkSlicers: Boolean(opts.checkSlicers), + }); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, report, { workspace: opened.flags.workspaceRoot }) : null; + const result: Record = { ...report, saved_json: saved }; + if (opts.checkApi && report.api_ready !== true) { + throw apiCheckFailure(apiFailure ?? { stage: "balance", error: new Error("--check-api did not complete") }, result); + } + await emitResult(opened, result, result); + }); +} + +export const doctorCommand = buildDoctorCommand(); diff --git a/src/cmd/download.ts b/src/cmd/download.ts new file mode 100644 index 0000000..950939b --- /dev/null +++ b/src/cmd/download.ts @@ -0,0 +1,442 @@ +/** + * download — selective asset download for a task. + * + * Sources (exactly one): --task-json (API task, legacy meta.json or a + * v1 envelope/result), --url , or --resource --task-id + * (one GET, then the assets). Selection (at most one): --asset …, + * --model-format , --kind , --all; with several assets and no + * selector the command lists the candidates and exits 2 instead of guessing. + * + * Output: --output for exactly one asset, --output-dir otherwise. + * Files are published exclusively; --overwrite replaces atomically. Nothing + * is extracted, nothing is re-generated: an expired URL from a task JSON is + * reported as such, an API-sourced task is refreshed once. + */ + +import { Command, Option } from "commander"; +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { findTaskResource, TASK_RESOURCES, type TaskResourceDescriptor } from "../client/resource-registry.js"; +import { enumerateAssets, selectAssets, SelectionError, type Asset, type AssetKind } from "../internal/artifacts.js"; +import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { downloadAssets, type DownloadedFile } from "../internal/download.js"; +import { CliError, UsageError, type Warning } from "../internal/errors.js"; +import { freezeRoot, realpathLenient, resolveWithinRoot, safeSegment, type AuthorisedRoot } from "../internal/paths.js"; +import { warning } from "../internal/result.js"; +import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, readProject, recordTask, stageFromTaskType, type RecordInput } from "../internal/project-store.js"; +import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; +import { extractTaskObject } from "../internal/task-view.js"; +import { relative } from "node:path"; + +const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; +const KINDS: readonly AssetKind[] = ["model", "image", "texture", "thumbnail", "rig", "animation", "motion", "report"]; + +interface DownloadOpts { + taskJson?: string; + url?: string; + resource?: string; + taskId?: string; + asset?: string[]; + modelFormat?: string; + kind?: string; + all?: boolean; + list?: boolean; + outputDir?: string; + overwrite?: boolean; + withDependencies?: boolean; + geometryOnly?: boolean; + saveJson?: string; + includeRaw?: boolean; + project?: string; + stage?: string; +} + +function collect(v: string, prev: string[] = []): string[] { + return [...prev, v]; +} + +function resourceForTask(task: Record, hint: string | null): TaskResourceDescriptor | null { + if (hint) { + const d = findTaskResource(hint); + if (d) return d; + } + const type = task["type"]; + if (typeof type !== "string") return null; + return TASK_RESOURCES.find((d) => d.taskTypes.includes(type)) ?? null; +} + +function readTaskJson(path: string): { task: Record; resourceHint: string | null; shape: string } { + const abs = resolvePath(path); + let st: ReturnType; + try { + st = statSync(abs); + } catch { + throw new UsageError(`--task-json: file not found: ${path}`); + } + if (!st.isFile()) throw new UsageError(`--task-json: not a regular file: ${path}`); + if (st.size > TASK_JSON_MAX_BYTES) throw new UsageError(`--task-json: ${path} is larger than ${TASK_JSON_MAX_BYTES} bytes`); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(abs, "utf8")); + } catch (err) { + throw new UsageError(`--task-json: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`); + } + const extracted = extractTaskObject(parsed); + if (!extracted) throw new UsageError(`--task-json: ${path} does not contain a task (expected an API task, a meta.json, or a v1 envelope)`); + const o = parsed as Record; + let hint: string | null = null; + if (extracted.source === "meta.json" && typeof o["resource"] === "string") hint = o["resource"] as string; + const view = (o["result"] as Record | undefined)?.["task"] as Record | undefined; + if ((extracted.source === "v1-envelope" || extracted.source === "v1-result") && typeof view?.["resource"] === "string") hint = view["resource"] as string; + return { task: extracted.task, resourceHint: hint, shape: extracted.source }; +} + +export const downloadCommand = new Command("download") + .description("Download selected assets of a task (from a saved task JSON, a URL, or the API) with explicit selection and safe file placement") + .option("--task-json ", "task JSON saved earlier (API task, meta.json or v1 envelope)") + .option("--url ", "download exactly one asset URL (no task context)") + .option("--resource ", "with --task-id: fetch the task from the API first (one GET)") + .option("--task-id ", "with --resource: the task to fetch") + .option("--asset ", "stable asset key to download (repeatable), e.g. model.glb, thumbnail.primary, result.basic_animations.walking_glb_url", collect) + .option("--model-format ", "select the model of this format (glb, obj, fbx, usdz, stl, 3mf, …)") + .addOption(new Option("--kind ", "select every asset of one kind").choices([...KINDS])) + .option("--all", "select every asset") + .option("--list", "list the task's assets and exit without downloading") + .option("--output-dir ", "directory to write into (for several assets); mutually exclusive with --output/-o") + .option("--overwrite", "replace existing files atomically (never directories or symlinks)") + .option("--with-dependencies", "for OBJ selections also fetch the MTL and textures (default)") + .option("--geometry-only", "for OBJ selections fetch the OBJ alone") + .option("--save-json ", "save the raw task JSON (API source) alongside") + .option("--include-raw", "v1: include the raw task under result.source.raw") + .option("--project ", "initialised meshy_output project: default output directory, and the files are recorded in metadata.json") + .option("--stage ", "stage label for the project record (default: derived from the task type)") + .action(async (opts: DownloadOpts, thisCmd: Command) => { + const opened = openCommand(thisCmd, "download", "v1"); + const sources = [opts.taskJson ? "task-json" : null, opts.url ? "url" : null, opts.resource || opts.taskId ? "api" : null].filter(Boolean); + if (sources.length !== 1) throw new UsageError("provide exactly one source: --task-json , --url , or --resource --task-id "); + if ((opts.resource && !opts.taskId) || (!opts.resource && opts.taskId)) throw new UsageError("--resource and --task-id go together"); + const selectors = [opts.asset?.length ? "asset" : null, opts.modelFormat ? "model-format" : null, opts.kind ? "kind" : null, opts.all ? "all" : null].filter(Boolean); + if (selectors.length > 1) throw new UsageError(`selectors are mutually exclusive (got ${selectors.map((s) => `--${s}`).join(", ")})`); + if (opts.withDependencies && opts.geometryOnly) throw new UsageError("--with-dependencies and --geometry-only are mutually exclusive"); + if (opened.flags.output && opts.outputDir) throw new UsageError("--output/-o and --output-dir are mutually exclusive"); + // The write boundary was frozen with the flags (--workspace); without one the + // project directory itself is frozen here, before any request. + const workspaceRoot = opened.flags.workspaceRoot; + const projectDir = opts.project + ? workspaceRoot + ? resolveWithinRoot(resolvePath(opts.project), workspaceRoot, { label: "--project" }).path + : resolvePath(opts.project) + : null; + if (projectDir) preflightProject(projectDir, opts.project!, opts.stage); + const projectRoot: AuthorisedRoot | null = projectDir ? (workspaceRoot ?? freezeRoot(projectDir, { label: "--project" })) : null; + if (projectDir && opts.url) throw new UsageError("--project needs a task context; it cannot be combined with --url"); + const warnings: Warning[] = []; + + // ---- source ---- + let task: Record | null = null; + let raw: unknown = null; + let descriptor: TaskResourceDescriptor | null = null; + let sourceInfo: Record; + let refreshUrls: (() => Promise | null>) | undefined; + + if (opts.url) { + buildLocalRuntime(opened.flags); + sourceInfo = { kind: "url", url: opts.url.split("?")[0] }; + if (opts.list || selectors.length > 0) throw new UsageError("--url downloads exactly one URL; selectors and --list do not apply"); + } else if (opts.taskJson) { + buildLocalRuntime(opened.flags); + const read = readTaskJson(opts.taskJson); + task = read.task; + raw = read.task; + descriptor = resourceForTask(task, read.resourceHint); + sourceInfo = { kind: "task-json", path: resolvePath(opts.taskJson), shape: read.shape, resource: descriptor?.id ?? null, task_id: task["id"] ?? null }; + } else { + const d = findTaskResource(opts.resource!); + if (!d) throw new UsageError(`unknown resource '${opts.resource}'; run \`meshy resources\``); + descriptor = d; + const runtime = await buildRuntime(opened.flags); + const endpoint = runtime.client.endpointFor(d); + const got = await endpoint.retrieveDetailed(opts.taskId!, { signal: abortSignal() }); + task = got.raw as Record; + raw = got.raw; + sourceInfo = { kind: "api", resource: d.id, task_id: opts.taskId, status: (task["status"] as string | undefined) ?? null }; + refreshUrls = async () => { + const again = await endpoint.retrieveDetailed(opts.taskId!, { signal: abortSignal() }); + const fresh = enumerateAssets(again.raw as Record, d); + return new Map(fresh.assets.filter((a) => a.url).map((a) => [a.key, a.url!] as const)); + }; + } + const savedJson = opts.saveJson && raw ? saveRawJson(opts.saveJson, raw, { workspace: workspaceRoot }) : null; + + // ---- enumerate + select ---- + let selected: Asset[]; + let dependencies: Asset[] = []; + let enumeration: ReturnType | null = null; + if (opts.url) { + const name = safeSegment(basename(new URL(opts.url).pathname) || "asset", "asset"); + selected = [{ key: "url", kind: "model", url: opts.url, format: null, containerFormat: null, modelFormat: null, sourcePath: "url", filename: name, dependencies: [] }]; + // Kind is unknown for a bare URL: content validation only checks for HTML pages. + selected[0]!.kind = "image"; + } else { + enumeration = enumerateAssets(task!, descriptor); + if (task!["status"] !== "SUCCEEDED" && enumeration.assets.length === 0) { + await emitResult(opened, null, { source: sourceInfo, assets: [], downloads: { state: "not_ready", files: [], metadata_path: null }, saved_json: savedJson, unknown_urls: enumeration.unknown_urls }, { + warnings: [warning("task_not_ready", `task status is ${String(task!["status"])}; no assets to download yet`)], + }); + return; + } + if (opts.list) { + await emitResult(opened, null, { + source: sourceInfo, + assets: enumeration.assets.map(describeAsset), + unknown_urls: enumeration.unknown_urls, + product: enumeration.product, + saved_json: savedJson, + ...(opts.includeRaw ? { raw } : {}), + }); + return; + } + const withDeps = !opts.geometryOnly; + try { + const sel = selectors.length === 0 + ? (enumeration.assets.length === 1 + ? { keys: [enumeration.assets[0]!.key] } + : (() => { + throw new SelectionError(`task exposes ${enumeration.assets.length} assets; choose with --asset, --model-format, --kind or --all`, enumeration.assets); + })()) + : { keys: opts.asset, modelFormat: opts.modelFormat, kind: opts.kind as AssetKind | undefined, all: opts.all }; + const result = selectAssets(enumeration, sel, { withDependencies: withDeps }); + selected = result.selected; + dependencies = result.dependencies; + for (const m of result.missingDependencies) warnings.push(warning("material_dependency_missing", `${m} is not available in this task; the OBJ will be delivered without it`)); + } catch (err) { + if (err instanceof SelectionError) { + throw new CliError({ code: "usage", message: err.message, result: { source: sourceInfo, assets: err.candidates } }); + } + throw err; + } + if (opts.geometryOnly && selected.some((a) => a.modelFormat === "obj" && a.containerFormat === null)) { + warnings.push(warning("geometry_only", "OBJ delivered without its MTL/textures as requested")); + } + } + + const toDownload = [...selected, ...dependencies]; + // ---- output placement ---- + const outputFile = opened.flags.output; + const outputDir = opts.outputDir ?? (projectDir && !outputFile ? projectDir : undefined); + if (!outputFile && !outputDir) throw new UsageError("pass --output (single asset) or --output-dir "); + if (outputFile && toDownload.length > 1) { + throw new UsageError(`${toDownload.length} files would be written (${toDownload.map((a) => a.key).join(", ")}); --output names one file — use --output-dir `); + } + const dir = outputFile ? dirname(resolvePath(outputFile)) : resolvePath(outputDir!); + const root: string | AuthorisedRoot = workspaceRoot ?? dir; + + const files: DownloadedFile[] = []; + let result; + try { + result = await downloadAssets(toDownload, { + targetFile: outputFile ? resolvePath(outputFile) : undefined, + targetDir: outputFile ? undefined : dir, + overwrite: Boolean(opts.overwrite), + root, + signal: abortSignal(), + refreshUrls, + onFile: (f) => files.push(f), + }); + } catch (err) { + if (err instanceof CliError) { + const expired = err.httpStatus === 401 || err.httpStatus === 403 || err.httpStatus === 410; + const hint = expired && !refreshUrls && descriptor && task + ? `signed URL rejected and a task JSON cannot refresh it; re-fetch with \`meshy ${descriptor.commandPath.join(" ")} get ${String(task["id"])} --save-json --output-schema v1\` (no new task is created)` + : undefined; + throw new CliError({ + code: err.code, + message: hint ? `${err.message}. ${hint}` : err.message, + httpStatus: err.httpStatus, + recovery: hint && descriptor && task ? { action: "refresh_task", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} get ${String(task["id"])} --save-json --output-schema v1` } : err.recovery, + result: { source: sourceInfo, ...(err.result ?? {}), saved_json: savedJson }, + warnings: [...warnings, ...err.warnings], + cause: err, + }); + } + throw err; + } + warnings.push(...result.warnings.map((w) => warning(w.code, w.message))); + // Everything the caller must still learn if the project bookkeeping below + // fails: what was asked for, what landed (with the digests on disk), where + // the raw task went. The project phase never owns this state. + const outcome = { + source: sourceInfo, + selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, + downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null, material_links: result.materialLinks }, + unknown_urls: enumeration?.unknown_urls ?? [], + saved_json: savedJson, + ...(opts.includeRaw ? { raw } : {}), + }; + let project: Record | null = null; + if (projectDir && task) { + const written = result.files.filter((f) => f.status === "written"); + const input: RecordInput = { + taskId: String(task["id"] ?? opts.taskId ?? ""), + stage: opts.stage ?? stageFromTaskType(task["type"], descriptor?.id ?? "download"), + resource: descriptor?.id ?? null, + taskType: typeof task["type"] === "string" ? (task["type"] as string) : null, + endpoint: descriptor?.legacyEndpoint ?? null, + status: typeof task["status"] === "string" ? (task["status"] as string) : null, + files: [], + }; + const workspace = workspaceRoot?.given; + // 1. The location, against the boundary frozen before the transfers: the + // frozen directory must still be there and the project must resolve + // inside it now. A project (or a parent of it) replaced by a symlink to + // somewhere else during the transfer is refused before any lock, + // snapshot or metadata write — and gets no command that would write there. + let located: string; + try { + located = resolveWithinRoot(projectDir, projectRoot!, { label: "--project" }).path; + } catch (err) { + throw projectBoundaryFailure(err, { projectDir, dir, input, written: written.length, outcome, warnings }); + } + try { + // Compare in one real-path frame: the project may be reached through an + // alias (a symlinked parent, macOS /var → /private/var) while the + // downloader reports real paths; the recorded name is relative to the real project. + // The file list is known before the project is examined, so a recovery + // command always names what landed inside the project. + input.files = written + .map((f) => relative(located, realpathLenient(f.path)).split(/[\\/]/).join("/")) + .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); + // 2. The project passed the preflight; it must still be one now. Writes go through the real path. + assertProjectMetadataPresent(located, opts.project!); + const indexRoot = indexRootFor(located, undefined, workspaceRoot); + const rec = recordTask(located, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); + if (input.files.length !== written.length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); + project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: input.files }; + } catch (err) { + throw projectRecordFailure(err, { projectDir, workspace, dir, input, written: written.length, outcome, warnings }); + } + } + await emitResult(opened, null, { ...outcome, project }, { warnings }); + }); + +/** + * `--project` checks that can fail before any transfer, so that they do: + * metadata.json must exist, be a regular file (never a symlink the record + * step would refuse to replace) and parse as a project; `--stage` must not be + * blank. Nothing is downloaded when one of these fails. What changes *after* + * this check is caught by `projectRecordFailure`. + */ +function preflightProject(projectDir: string, flag: string, stage: string | undefined): void { + const metaPath = join(projectDir, "metadata.json"); + let st: ReturnType | null = null; + try { + st = lstatSync(metaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT" && (err as NodeJS.ErrnoException).code !== "ENOTDIR") throw err; + } + if (!st) throw new UsageError(`--project ${flag} is not an initialised project (no metadata.json); run \`meshy project init\` first`); + if (!st.isFile()) { + throw new CliError({ code: "local_io", message: `--project ${flag}: metadata.json is not a regular file (${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a directory" : "special"}); nothing was downloaded` }); + } + try { + readProject(projectDir); + } catch (err) { + if (err instanceof CliError) { + throw new CliError({ code: err.code, message: `--project ${flag}: ${err.message} (nothing was downloaded)`, recovery: err.recovery, cause: err }); + } + throw err; + } + if (stage !== undefined && stage.trim() === "") throw new UsageError("--stage must not be blank"); +} + +/** + * The transfers are done and the files are on disk, but the project no longer + * lies inside the boundary frozen when the command started (its directory or a + * parent was replaced by a symlink to somewhere else, or the boundary itself + * was moved). Nothing was locked, snapshotted or written; the complete download + * result is kept and `project.action` is `failed` — and no `meshy project + * record` command is offered, because the only one that would succeed is one + * that writes across the boundary. + */ +function projectBoundaryFailure( + err: unknown, + ctx: { projectDir: string; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, +): CliError { + const reason = err instanceof Error ? err.message : String(err); + return new CliError({ + code: "local_io", + message: `${ctx.written} file(s) were downloaded to ${ctx.dir} but --project ${ctx.projectDir} is no longer a target inside the authorised boundary: ${reason}; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task ${ctx.input.taskId} with \`meshy project record\` from that workspace`, + warnings: ctx.warnings, + details: { project: ctx.projectDir, task_id: ctx.input.taskId, stage: ctx.input.stage, recorded: false }, + result: { + ...ctx.outcome, + project: { + project_dir: ctx.projectDir, + action: "failed", + stage: ctx.input.stage, + recorded_files: [], + error: { code: "local_io", message: reason }, + recovery: null, + }, + }, + cause: err, + }); +} + +/** + * The transfers are done and the files are on disk; only the project entry + * could not be written. The error keeps its own class (a refused symlink, + * a damaged metadata.json, a lock timeout, a full disk are all local_io) and + * carries the complete download result plus a `project` record that says what + * failed and the one command that redoes just the bookkeeping. Nothing is + * rolled back, re-downloaded or re-submitted. + */ +function projectRecordFailure( + err: unknown, + ctx: { projectDir: string; workspace: string | undefined; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, +): CliError { + const base = err instanceof CliError ? err : null; + const code = base?.code ?? "local_io"; + const reason = err instanceof Error ? err.message : String(err); + const command = projectRecordCommand(ctx.projectDir, ctx.input, { workspace: ctx.workspace }); + const recovery = { action: "record_project", automatic: false, command }; + return new CliError({ + code, + message: `${ctx.written} file(s) were downloaded to ${ctx.dir} but recording task ${ctx.input.taskId} in project ${ctx.projectDir} failed: ${reason}`, + exitCode: base?.exitCode, + httpStatus: base?.httpStatus ?? null, + retryable: base?.retryable ?? false, + recovery, + hint: command, + details: base?.details, + warnings: [...ctx.warnings, ...(base?.warnings ?? [])], + result: { + ...ctx.outcome, + project: { + project_dir: ctx.projectDir, + action: "failed", + stage: ctx.input.stage, + recorded_files: [], + error: { code, message: reason }, + recovery, + }, + }, + cause: err, + }); +} + +function describeAsset(a: Asset): Record { + return { + key: a.key, + kind: a.kind, + format: a.format, + model_format: a.modelFormat, + container_format: a.containerFormat, + filename: a.filename, + dependencies: a.dependencies, + has_url: a.url !== null, + ...(a.notes ?? {}), + }; +} diff --git a/src/cmd/inspect.ts b/src/cmd/inspect.ts new file mode 100644 index 0000000..3ae03da --- /dev/null +++ b/src/cmd/inspect.ts @@ -0,0 +1,220 @@ +/** + * inspect faces — the face-count gate. + * + * Two mutually exclusive task sources: a saved task JSON (no network, no + * credential, no config directory) or exactly one GET of a task through the + * resource registry. The command never downloads a model and never creates a + * remesh task; a failing verdict only *describes* the remesh that would help. + * + * `--max-faces` is required on purpose: the rigging ceiling (300000) is one + * consumer's rule and uv-unwrap's is 40000 — there is no limit that belongs + * to every mesh, so none is assumed. Exit codes: pass 0, fail 12, unknown 13 + * (D-009). Nothing printed here claims more than the face count. + */ + +import { Command } from "commander"; +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { findTaskResource, TASK_RESOURCES } from "../client/resource-registry.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type SavedJson } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { CliError, UsageError } from "../internal/errors.js"; +import { judgeTask, remeshSuggestion, type RemeshSuggestion } from "../internal/inspect.js"; +import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; +import { extractTaskObject } from "../internal/task-view.js"; + +/** Engineering limit for a local task JSON (D-018); enforced while reading, never by truncation. */ +export const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; + +export type TaskJsonShape = "api" | "meta.json" | "v1-envelope" | "v1-result"; + +export interface LoadedTaskJson { + path: string; + task: Record; + shape: TaskJsonShape; +} + +interface FacesOptions { + taskJson?: string; + resource?: string; + taskId?: string; + maxFaces?: number; + saveJson?: string; +} + +type TaskSource = { kind: "task-json"; path: string } | { kind: "api"; resource: string; taskId: string }; + +function errnoMessage(err: unknown): string { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return code ? code : err instanceof Error ? err.message : String(err); +} + +/** `--max-faces` accepts a plain positive integer; "3.5", "0", "-1" and "abc" are usage errors. */ +export function parseMaxFaces(raw: string): number { + const text = raw.trim(); + if (!/^\d+$/.test(text)) throw new UsageError(`--max-faces must be a positive integer (got '${raw}')`); + const n = Number(text); + if (!Number.isSafeInteger(n) || n < 1) throw new UsageError(`--max-faces must be a positive integer (got '${raw}')`); + return n; +} + +function readBounded(fd: number, maxBytes: number, label: string): string { + const chunks: Buffer[] = []; + const chunk = Buffer.allocUnsafe(64 * 1024); + let total = 0; + for (;;) { + const n = readSync(fd, chunk, 0, chunk.length, null); + if (n === 0) break; + total += n; + if (total > maxBytes) { + throw new UsageError(`--task-json: ${label} exceeds ${maxBytes} bytes (task JSON limit); refusing to read further`); + } + chunks.push(Buffer.from(chunk.subarray(0, n))); + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +/** + * Read a task from a local JSON file in any shape the CLI writes or the API + * returns (API task, download meta.json, v1 envelope / result). Every problem + * with the file is a usage error: the caller named it, so a bad file is a + * mistake to report, not a fact about the model. + */ +export function readTaskJsonFile(path: string, opts: { cwd?: string; maxBytes?: number } = {}): LoadedTaskJson { + const cwd = opts.cwd ?? process.cwd(); + const maxBytes = opts.maxBytes ?? TASK_JSON_MAX_BYTES; + const abs = resolvePath(cwd, path); + let fd: number; + try { + fd = openSync(abs, "r"); + } catch (err) { + throw new UsageError(`--task-json: cannot open ${path} (${errnoMessage(err)})`); + } + let text: string; + try { + if (!fstatSync(fd).isFile()) throw new UsageError(`--task-json: not a regular file: ${path}`); + text = readBounded(fd, maxBytes, path); + } finally { + closeSync(fd); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new UsageError(`--task-json: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`); + } + const extracted = extractTaskObject(parsed); + if (!extracted) { + throw new UsageError( + `--task-json: ${path} does not contain a task (expected an API task object with id+status, a download meta.json with "task", or a v1 envelope with result.task)`, + ); + } + return { path: abs, task: extracted.task, shape: extracted.source }; +} + +function resolveSource(opts: FacesOptions): TaskSource { + const hasFile = opts.taskJson !== undefined; + const hasApi = opts.resource !== undefined || opts.taskId !== undefined; + if (hasFile && hasApi) { + throw new UsageError("choose one task source: --task-json , or --resource with --task-id — not both"); + } + if (hasFile) return { kind: "task-json", path: opts.taskJson! }; + if (opts.resource === undefined || opts.taskId === undefined) { + throw new UsageError("a task source is required: --task-json , or --resource together with --task-id "); + } + return { kind: "api", resource: opts.resource, taskId: opts.taskId }; +} + +function taskIdOf(task: Record, fallback: string | null): string | null { + if (typeof task["id"] === "string" && task["id"]) return task["id"] as string; + if (typeof task["task_id"] === "string" && task["task_id"]) return task["task_id"] as string; + return fallback; +} + +export interface FacesResult { + face_count: number | null; + limit: number; + comparison: "lte"; + verdict: "pass" | "fail" | "unknown"; + reason: string | null; + source: + | { kind: "task-json"; path: string; shape: TaskJsonShape } + | { kind: "api"; resource: string; task_id: string; endpoint: string; requests_made: 1 }; + task_id: string | null; + status: string | null; + /** Only on `fail`: an unexecuted remesh the caller may run. */ + suggestion: RemeshSuggestion | null; + saved_json: SavedJson | null; +} + +/** Build a fresh `inspect` command tree (tests parse a new tree per run). */ +export function buildInspectCommand(): Command { + const faces = new Command("faces") + .description( + "Face-count gate: pass (exit 0) | fail (exit 12) | unknown (exit 13). Answers only whether face_count <= --max-faces; " + + "a missing or malformed count is unknown, never 0. Never downloads a model or submits a remesh", + ) + .option("--task-json ", "saved task JSON (API task, download meta.json or v1 envelope); no network, no credential") + .option("--resource ", "task resource id (see `meshy resources`), fetched once with --task-id") + .option("--task-id ", "task id to fetch once from the API (with --resource)") + .option("--max-faces ", "maximum accepted face count (required; e.g. 300000 for rigging, 40000 for uv-unwrap — no default)", parseMaxFaces) + .option("--save-json ", "API source only: save the task JSON as received (never overwrites)") + .action(async (opts: FacesOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "inspect.faces", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + if (opts.maxFaces === undefined) { + throw new UsageError( + "--max-faces is required: the limit belongs to the caller (rigging accepts up to 300000 faces, uv-unwrap up to 40000); no default is assumed", + ); + } + const limit = opts.maxFaces; + const source = resolveSource(opts); + + let task: Record; + let sourceInfo: FacesResult["source"]; + let savedJson: SavedJson | null = null; + if (source.kind === "task-json") { + if (opts.saveJson) { + throw new UsageError("--save-json only applies to the API source (--resource/--task-id); the task JSON is already a file"); + } + buildLocalRuntime(opened.flags); + const loaded = readTaskJsonFile(source.path); + task = loaded.task; + sourceInfo = { kind: "task-json", path: loaded.path, shape: loaded.shape }; + } else { + const descriptor = findTaskResource(source.resource); + if (!descriptor) { + throw new UsageError(`unknown --resource '${source.resource}'. Valid ids: ${TASK_RESOURCES.map((d) => d.id).join(", ")}`); + } + const runtime = await buildRuntime(opened.flags); + // Exactly one GET; the verdict is read from the raw JSON, not the Zod-defaulted object. + const { raw } = await runtime.client.endpointFor(descriptor).retrieveDetailed(source.taskId, { signal: abortSignal() }); + const extracted = extractTaskObject(raw); + task = extracted?.task ?? (raw as Record); + savedJson = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; + sourceInfo = { kind: "api", resource: descriptor.id, task_id: source.taskId, endpoint: descriptor.legacyEndpoint, requests_made: 1 }; + } + + const verdict = judgeTask(task, limit); + const taskId = taskIdOf(task, source.kind === "api" ? source.taskId : null); + const result: FacesResult = { + ...verdict, + source: sourceInfo, + task_id: taskId, + status: typeof task["status"] === "string" ? (task["status"] as string) : null, + suggestion: verdict.verdict === "fail" ? remeshSuggestion(taskId, limit) : null, + saved_json: savedJson, + }; + if (verdict.verdict === "fail") { + throw new CliError({ code: "check_failed", message: `face count check failed: ${verdict.reason}`, result: { ...result } }); + } + if (verdict.verdict === "unknown") { + throw new CliError({ code: "check_unknown", message: `face count unknown: ${verdict.reason}`, result: { ...result } }); + } + await emitResult(opened, result, result); + }); + + return new Command("inspect").description("Local checks on task results (face-count gate)").addCommand(faces); +} + +export const inspectCommand = buildInspectCommand(); diff --git a/src/cmd/make.ts b/src/cmd/make.ts index 747aaba..ab547af 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -14,22 +14,42 @@ * someone else's credits on its own opinion. The resource commands remain the * way to compose anything else. * - * When a later step fails, the error carries the finished step's task id and - * the command that resumes from it, so a retry never re-runs — or re-bills — - * work that already succeeded. + * Submission goes through the same journal → single POST → journal primitive + * as the resource commands (`submitCreate`), so there is one state machine for + * accepted / rejected / unknown and a local journal failure after acceptance is + * local_io with the known task id — never "unknown". + * + * Three ways to stop early, none of them a re-run: + * --async submit step 1 and return immediately (no polling); + * `pending_steps` lists what was not executed. + * --stop-after-first run step 1 to completion, then return the resume plan. + * a failed later step keeps the finished step's task id and the command that + * resumes from it, so a retry never re-bills done work. */ import { Command, Option } from "commander"; -import { HintedError, UsageError } from "../internal/errors.js"; +import { abortSignal, wasInterrupted } from "../internal/context.js"; +import { CliError, HintedError, UsageError } from "../internal/errors.js"; import { parseInt10 } from "../internal/flags.js"; -import { resolveImageFields } from "../internal/file-input.js"; +import { normalizeMediaPayload } from "../internal/file-input.js"; import { logger } from "../internal/logger.js"; import { planMake, type MakePlan, type MakeStep } from "../internal/make-plan.js"; -import { emit } from "../internal/output.js"; -import { pollUntilTerminal } from "../internal/poll.js"; +import { emit, emitEnvelope } from "../internal/output.js"; +import { emitResult, openCommand, type OpenedCommand } from "../internal/command-helpers.js"; +import { downloadArtifacts } from "../internal/download.js"; +import { parseTimeoutSeconds, pollUntilTerminal, type PollResult } from "../internal/poll.js"; import { PRICING_DOCS } from "../internal/pricing.js"; -import { buildRuntime, readGlobalFlags, type Runtime } from "../internal/runtime.js"; -import { emitTerminalOutcome } from "../internal/task-command.js"; +import { okEnvelope, warning, type Warning } from "../internal/result.js"; +import { buildRuntime, type Runtime } from "../internal/runtime.js"; +import { + buildTaskResult, + emitTerminalOutcome, + preflightOutputPath, + submitCreate, + taskNextCommands, + wrapWithResult, +} from "../internal/task-command.js"; +import { requireTaskResource } from "../client/resource-registry.js"; import type { Task } from "../client/types.js"; import type { TaskEndpoint } from "../client/endpoints/base.js"; @@ -42,6 +62,7 @@ interface MakeOptions { dryRun?: boolean; maxCredits?: number; async?: boolean; + stopAfterFirst?: boolean; timeout?: string; } @@ -61,13 +82,23 @@ export const makeCommand = new Command("make") .addOption( new Option( "--async", - "start the first step and return its task id instead of running the whole chain", + "submit the first step and return its task id immediately (no polling); later steps are reported as pending", + ).default(false), + ) + .addOption( + new Option( + "--stop-after-first", + "run the first step to completion, then return the resume plan instead of continuing", ).default(false), ) .addOption(new Option("--timeout ", "max seconds to poll each step").default("600")) .action(async (input: string, opts: MakeOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "make", "legacy"); + if (opts.async && opts.stopAfterFirst) { + throw new UsageError("--async and --stop-after-first are mutually exclusive (--async submits and returns; --stop-after-first waits for step 1)"); + } + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); const plan = planMake(input); - const flags = readGlobalFlags(thisCmd); // Budget check before anything is created: refusing costs nothing, and a // refusal after step one has already billed is not a budget at all. @@ -79,51 +110,180 @@ export const makeCommand = new Command("make") } if (opts.dryRun) { - emit(planPayload(plan), { format: flags.format }); + await emitResult(opened, planPayload(plan), { ...planPayload(plan), dry_run: true, requests_made: 0 }); return; } - await runChain(plan, opts, await buildRuntime(flags)); + await runChain(plan, opts, timeoutSeconds, await buildRuntime(opened.flags), opened); }); -async function runChain(plan: MakePlan, opts: MakeOptions, runtime: Runtime): Promise { - const timeoutSeconds = Number(opts.timeout ?? 600); +interface ExecutedStep { + step: number; + resource: string; + action: string; + task_id: string; + status: string | null; + operation_id: string; +} + +function pendingSteps(plan: MakePlan, from: number, parentTaskId: string | null, output: string | undefined): Array> { + return plan.steps.slice(from).map((s) => ({ + step: s.index, + resource: s.resource, + action: s.action, + estimated_credits: s.credits, + requires: `step ${s.index - 1} SUCCEEDED`, + command: + s.resource === "text-to-3d" && s.action === "refine" + ? `meshy text-to-3d create --mode refine --preview-task-id ${parentTaskId ?? ""}${output ? ` -o ${output}` : ""}` + : null, + })); +} + +async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: number, runtime: Runtime, opened: OpenedCommand): Promise { const payloadFor = await buildPayloads(plan); + // -o is checked before the first billable request: an unwritable or + // out-of-workspace target must refuse while the run is still free. + if (runtime.flags.output) preflightOutputPath(runtime.flags.output, runtime.flags.workspaceRoot); + const executed: ExecutedStep[] = []; + const warnings: Warning[] = []; /** Task id of the last step that reached SUCCEEDED — what a resume hangs off. */ let completedTaskId = ""; for (const step of plan.steps) { const endpoint = endpointFor(step, runtime); + const descriptor = requireTaskResource(step.resource); const payload = payloadFor[step.index - 1]?.(completedTaskId) ?? {}; logger.debug(`make step ${step.index} payload`, payload); // The id is announced before polling starts: an interrupted run leaves a // task progressing server-side, and the caller needs its id to exist // somewhere other than this process's memory. - const taskId = await endpoint.create(payload); + const submitted = await submitCreate(runtime, descriptor, endpoint, payload, null, { + label: `make: the ${step.label} request`, + extraResult: { step: step.index, route: plan.route, executed: [...executed] }, + }); + const { taskId, operationId } = submitted; + warnings.push(...submitted.warnings); announceStart(plan, step, taskId); + executed.push({ step: step.index, resource: step.resource, action: step.action, task_id: taskId, status: null, operation_id: operationId }); + const submission = { state: "accepted", operation_id: operationId, task_id: taskId }; - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - }); - const elapsed = (Date.now() - started) / 1000; + if (opts.async) { + // Exactly one POST, zero polls: the caller owns the rest of the plan. + const pending = pendingSteps(plan, step.index, null, runtime.flags.output); + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope("make", { + route: plan.route, + submitted: { step: step.index, resource: step.resource, action: step.action, task_id: taskId, operation_id: operationId }, + submission, + task: null, + executed, + pending_steps: pending, + estimated_credits: plan.estimatedCredits, + next: taskNextCommands(descriptor, taskId), + }, warnings), + opened.format, + ); + return; + } + emit( + { + command: "make", + route: plan.route, + submitted: step.action, + task_id: taskId, + status: null, + operation_id: operationId, + pending_steps: pending, + hint: `meshy ${step.resource} wait ${taskId}`, + }, + { format: runtime.flags.format }, + ); + return; + } + + const started = performance.now(); + let poll: PollResult; + try { + poll = await pollUntilTerminal(endpoint, taskId, { + timeoutSeconds, + intervalMs: runtime.config.pollIntervalMs, + requestTimeoutMs: runtime.config.readTimeoutMs, + signal: abortSignal(), + }); + } catch (err) { + const context = { + route: plan.route, + executed, + task_id: taskId, + submission, + task: null, + pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), + next: taskNextCommands(descriptor, taskId), + }; + if (wasInterrupted() || abortSignal().aborted) { + throw new CliError({ + code: "interrupted", + message: `make: interrupted while waiting for ${step.label} (task ${taskId}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: context, + }); + } + // A polling failure is not "no task": the id and the resume command travel with the error. + throw wrapWithResult(err, context); + } + const { task, raw, timedOut, aborted } = poll; + const elapsed = (performance.now() - started) / 1000; + executed[executed.length - 1]!.status = task?.status ?? null; announceOutcome(task, timedOut, elapsed); - if (timedOut || task.status !== "SUCCEEDED") { - throw stepFailure(plan, step, task, timedOut, completedTaskId, runtime); + if (aborted) { + throw new CliError({ + code: "interrupted", + message: `make: interrupted while waiting for ${step.label} (task ${taskId}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: { + route: plan.route, + executed, + task_id: taskId, + submission, + task: task ? buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }).task : null, + pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), + next: taskNextCommands(descriptor, taskId), + }, + }); + } + + if (timedOut || !task || task.status !== "SUCCEEDED") { + throw stepFailure(plan, step, task, taskId, raw, timedOut, completedTaskId, runtime, executed, opened); } if (step.index === plan.steps.length) { - await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + await finalOutcome(opened, runtime, plan, step, task, raw, elapsed, executed, warnings); return; } completedTaskId = task.id; - if (opts.async) { + if (opts.stopAfterFirst) { + const pending = pendingSteps(plan, step.index, task.id, runtime.flags.output); + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope("make", { + route: plan.route, + stopped_after: { step: step.index, resource: step.resource, action: step.action, task_id: task.id, status: task.status }, + task: buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }).task, + executed, + pending_steps: pending, + resume: resumeCommand(plan, task.id, runtime) ?? null, + }, warnings), + opened.format, + ); + return; + } emit( { command: "make", @@ -140,6 +300,72 @@ async function runChain(plan: MakePlan, opts: MakeOptions, runtime: Runtime): Pr } } +async function finalOutcome( + opened: OpenedCommand, + runtime: Runtime, + plan: MakePlan, + step: MakeStep, + task: Task, + raw: unknown, + elapsed: number, + executed: ExecutedStep[], + warnings: Warning[], +): Promise { + const descriptor = requireTaskResource(step.resource); + const submission = { state: "accepted", operation_id: executed[executed.length - 1]?.operation_id ?? null, task_id: task.id }; + if (opened.schema !== "v1") { + // Legacy shape, same rule as the resource commands: a download failure + // after the chain succeeded still names the task, its submission and the + // command that fetches the assets again. + try { + await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + } catch (err) { + const wrapped = wrapWithResult(err, { route: plan.route, executed, task_id: task.id, submission, next: taskNextCommands(descriptor, task.id) }); + throw new CliError({ + code: wrapped.code, + message: wrapped.message, + exitCode: wrapped.exitCode, + httpStatus: wrapped.httpStatus, + retryable: wrapped.retryable, + recovery: wrapped.recovery ?? { action: "download", automatic: false, command: `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir ` }, + hint: wrapped.hint ?? wrapped.recovery?.command ?? `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir `, + details: wrapped.details, + warnings: wrapped.warnings, + result: wrapped.result, + cause: err, + }); + } + return; + } + const base = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }); + let downloads = base.downloads as Record; + if (runtime.flags.output) { + try { + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspaceRoot, signal: abortSignal() }); + if (materialLinks) warnings.push(...materialLinks.warnings); + downloads = { state: "completed", files: files.map((f) => ({ key: f.key, path: f.path, status: f.status, bytes: f.bytes, sha256: f.sha256, error: f.error })), metadata_path: metadataPath, material_links: materialLinks }; + } catch (err) { + // Whatever the downloader already committed stays in the manifest; the + // failure keeps its own class (HTTP status, interrupted, local I/O). + const partial = err instanceof CliError && err.result && typeof err.result["downloads"] === "object" ? (err.result["downloads"] as Record) : { state: "failed", files: [], metadata_path: null }; + const interrupted = (err instanceof CliError && err.code === "interrupted") || wasInterrupted(); + throw new CliError({ + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `make finished (task ${task.id}) but downloading its assets ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + retryable: err instanceof CliError ? err.retryable : false, + recovery: err instanceof CliError && err.recovery ? err.recovery : { action: "download", automatic: false, command: `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir ` }, + hint: err instanceof CliError ? err.hint : undefined, + details: err instanceof CliError ? err.details : undefined, + warnings: [...warnings, ...(err instanceof CliError ? err.warnings : [])], + result: { route: plan.route, executed, task_id: task.id, task: base.task, submission: base.submission, downloads: partial, next: taskNextCommands(descriptor, task.id) }, + cause: err, + }); + } + } + await emitEnvelope(okEnvelope("make", { route: plan.route, executed, task: base.task, submission: base.submission, downloads, pending_steps: [] }, warnings), opened.format); +} + /** * One payload builder per step, taking the previous step's task id so the * chain stays a function of the plan plus what actually ran. @@ -166,12 +392,11 @@ async function buildPayloads( // Resolve the image before anything is created: a missing file or an // unreachable URL must fail while the run is still free. - const resolved: Record = { imageUrl: plan.input }; - await resolveImageFields(resolved); + const { payload } = await normalizeMediaPayload({ image_url: plan.input }, requireTaskResource("image-to-3d").mediaFields, { signal: abortSignal() }); return [ () => ({ - image_url: resolved.imageUrl, + image_url: payload.image_url, should_texture: true, enable_pbr: true, texture_resolution: "4k", @@ -203,27 +428,50 @@ export function resumeCommand( function stepFailure( plan: MakePlan, step: MakeStep, - task: Task, + task: Task | null, + taskId: string, + raw: unknown, timedOut: boolean, completedTaskId: string, runtime: Runtime, -): HintedError { + executed: ExecutedStep[], + opened: OpenedCommand, +): Error { + const descriptor = requireTaskResource(step.resource); + const taskView = task ? buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: taskId } }).task : null; + const resume = resumeCommand(plan, completedTaskId, runtime); + const status = task?.status ?? "unknown"; + if (opened.schema === "v1") { + if (timedOut) { + return new CliError({ + code: "timed_out", + message: `make: ${step.label} did not finish within the timeout — task ${taskId} is still running`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: { route: plan.route, executed, task_id: taskId, task: taskView, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), resume: resume ?? null, next: taskNextCommands(descriptor, taskId) }, + }); + } + return new CliError({ + code: "task_failed", + message: task?.task_error?.message || `make: ${step.label} ended as ${status} — task ${taskId}`, + recovery: resume ? { action: "resume", automatic: false, command: resume } : null, + result: { route: plan.route, executed, task_id: taskId, task: taskView, pending_steps: pendingSteps(plan, step.index, completedTaskId || null, runtime.flags.output), resume: resume ?? null }, + }); + } if (timedOut) { return new HintedError({ - message: `make: ${step.label} did not finish within the timeout — task ${task.id} is still running`, + message: `make: ${step.label} did not finish within the timeout — task ${taskId} is still running`, code: "step_timeout", - hint: `meshy ${step.resource} wait ${task.id}`, + hint: `meshy ${step.resource} wait ${taskId}`, exitCode: 8, }); } // A resume is only offered when an earlier step actually succeeded; without // one, a suggested command would be a guess, and a wrong command is worse // than none. - const resume = resumeCommand(plan, completedTaskId, runtime); return new HintedError({ message: - task.task_error?.message || - `make: ${step.label} ended as ${task.status} — task ${task.id}`, + task?.task_error?.message || + `make: ${step.label} ended as ${status} — task ${taskId}`, code: "step_failed", ...(resume ? { hint: `${resume} # step ${step.index} failed; step ${step.index - 1} is kept` } : {}), }); @@ -253,7 +501,9 @@ function announceStart(plan: MakePlan, step: MakeStep, taskId: string): void { process.stderr.write(`[${step.index}/${plan.steps.length}] ${step.label} ${taskId}\n`); } -function announceOutcome(task: Task, timedOut: boolean, elapsed: number): void { - const mark = timedOut ? "timed out" : task.status === "SUCCEEDED" ? "ok" : task.status; +function announceOutcome(task: Task | null, timedOut: boolean, elapsed: number): void { + const mark = timedOut ? "timed out" : task ? (task.status === "SUCCEEDED" ? "ok" : task.status) : "no status received"; process.stderr.write(` ${mark} in ${elapsed.toFixed(0)}s\n`); } + +export { warning as _makeWarning }; diff --git a/src/cmd/mesh.ts b/src/cmd/mesh.ts new file mode 100644 index 0000000..7eb4ac4 --- /dev/null +++ b/src/cmd/mesh.ts @@ -0,0 +1,79 @@ +/** + * mesh — local geometry helpers. No API, no credential, no network. + * + * `prepare-print` is the legacy `fix_obj.py` transform (Y-up → Z-up, scaled to a + * target height, XY centred, grounded at Z=0) as a streaming, never-overwriting + * command. The output file is chosen by the global `-o/--output ` flag: + * Commander parses root options wherever they appear, so a second `--output` + * declared here would never receive the value. Without `-o` the result is + * `.print.obj` next to the input; `--in-place` replaces the input itself. + */ + +import { Command } from "commander"; +import { dirname, resolve as resolvePath } from "node:path"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { parseNumber } from "../internal/flags.js"; +import { DEFAULT_HEIGHT_MM, defaultOutputPath, prepareObjForPrint } from "../internal/obj-transform.js"; +import { resolveWithinRoot } from "../internal/paths.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +const prepareCommand = new Command("prepare-print") + .description( + "Rotate a Y-up OBJ to Z-up, scale it to --height-mm, centre XY and rest it on Z=0 for slicing. " + + "Writes .print.obj beside the input unless -o, --output names the target (a new file, " + + "or an existing directory) or --in-place replaces the input. mtllib/texture dependencies are copied " + + "when the output moves to another directory; a missing dependency is refused unless --geometry-only", + ) + .argument("", "OBJ file to prepare") + .option("--height-mm ", "target height in millimetres, finite and > 0", parseNumber, DEFAULT_HEIGHT_MM) + .option("--in-place", "replace the input file itself (temp file + atomic rename); cannot be combined with -o/--output") + .option("--geometry-only", "never copy MTL/texture dependencies; a missing dependency becomes a warning") + .action( + async ( + input: string, + opts: { heightMm: number; inPlace?: boolean; geometryOnly?: boolean }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "mesh.prepare-print", "v1"); + buildLocalRuntime(opened.flags); + const outputFlag = opened.flags.output; + if (outputFlag !== undefined && opts.inPlace) { + throw new UsageError("--output/-o and --in-place are mutually exclusive: pick a new file or replace the input, not both"); + } + const cwd = process.cwd(); + const workspace = opened.flags.workspaceRoot; + + // Reads may follow a symlinked input; an in-place replacement may not + // (renaming over the link would drop the bytes somewhere else). + const inputAbs = resolvePath(cwd, input); + const resolvedInput = resolveWithinRoot(inputAbs, workspace ?? dirname(inputAbs), { + cwd, + allowSymlinkLeaf: !opts.inPlace, + label: "input", + }); + + let outputPath: string | undefined; + if (outputFlag !== undefined) { + const outAbs = resolvePath(cwd, outputFlag); + outputPath = resolveWithinRoot(outAbs, workspace ?? dirname(outAbs), { cwd, label: "--output" }).path; + } else if (!opts.inPlace && workspace) { + outputPath = resolveWithinRoot(defaultOutputPath(resolvedInput.path), workspace, { cwd, label: "output" }).path; + } + + // The workspace (when given) is the root for the output *and* every + // copied material dependency; without one the output directory is. + const report = await prepareObjForPrint(resolvedInput.path, { + heightMm: opts.heightMm, + outputPath, + inPlace: opts.inPlace, + geometryOnly: opts.geometryOnly, + root: workspace, + }); + await emitResult(opened, report, report, { warnings: report.warnings }); + }, + ); + +export const meshCommand = new Command("mesh") + .description("Local mesh helpers (no API key required)") + .addCommand(prepareCommand); diff --git a/src/cmd/project.ts b/src/cmd/project.ts new file mode 100644 index 0000000..bd158f4 --- /dev/null +++ b/src/cmd/project.ts @@ -0,0 +1,147 @@ +/** + * project — local bookkeeping for `meshy_output/` folders. + * + * init create a project folder (+ metadata.json) under a root + * record add/merge a task entry with its files + * show normalised view of one project's metadata.json + * list the history.json index reconciled with the folders on disk + * rebuild-index regenerate history.json from the folders + * + * Every subcommand is local: no API key, no network, no OAuth refresh. With + * an explicit --workspace, every directory written (the root, the project) must + * resolve inside it — checked on real paths before anything is created. + */ + +import { Command } from "commander"; +import { existsSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { collect } from "../internal/flags.js"; +import { resolveWithinRoot, type AuthorisedRoot } from "../internal/paths.js"; +import { indexRootFor, initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; +import { warning, type Warning } from "../internal/result.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +const DEFAULT_ROOT = "meshy_output"; + +function rootFrom(opts: { root?: string }, cwd = process.cwd()): string { + return resolvePath(cwd, opts.root ?? DEFAULT_ROOT); +} + +/** With --workspace, a directory this command writes must resolve inside it (no symlink leaf, real paths). */ +function confine(path: string, workspace: AuthorisedRoot | undefined, label: string): string { + if (!workspace) return path; + return resolveWithinRoot(path, workspace, { label }).path; +} + +const initCommand = new Command("init") + .description("Create a project folder with metadata.json under the output root (default ./meshy_output)") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .option("--name ", "project name (kept verbatim in metadata; the folder gets a safe slug)") + .option("--task-id ", "root task id when already known (can be added later with record)") + .option("--task-type ", "task type used for the folder slug when no name is given") + .action(async (opts: { root?: string; name?: string; taskId?: string; taskType?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.init", "v1"); + buildLocalRuntime(opened.flags); + const root = confine(rootFrom(opts), opened.flags.workspaceRoot, "--root"); + const res = initProject(root, { name: opts.name, taskId: opts.taskId ?? null, taskType: opts.taskType ?? null }); + const warnings: Warning[] = []; + if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json written but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index --root ${res.root}\``)); + await emitResult(opened, res, { root: res.root, project_dir: res.project_dir, folder: res.folder, metadata: res.metadata, index: res.index }, { warnings }); + }); + +const recordCommand = new Command("record") + .description("Record a task (and its files) in a project's metadata.json; (task_id, stage) is merged, not duplicated") + .requiredOption("--project ", "project directory created by init") + .requiredOption("--task-id ", "task id") + .requiredOption("--stage ", "stage label, e.g. preview | refine | rigged | complete") + .option("--resource ", "resource id (text-to-3d, rigging, creative-lab.lamp.build, …)") + .option("--task-type ", "server task type when known") + .option("--parent-task-id ", "task this one was derived from") + .option("--status ", "last known status") + .option("--file ", "file inside the project to attach (repeatable)", collect) + .option("--task-json ", "task snapshot file inside the project") + .option("--operation-id ", "journal operation id that created the task") + .option("--root ", "output root holding history.json (default: the project's parent)") + .action( + async ( + opts: { project: string; taskId: string; stage: string; resource?: string; taskType?: string; parentTaskId?: string; status?: string; file?: string[]; taskJson?: string; operationId?: string; root?: string }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "project.record", "v1"); + buildLocalRuntime(opened.flags); + const projectDir = confine(resolvePath(opts.project), opened.flags.workspaceRoot, "--project"); + if (!existsSync(join(projectDir, "metadata.json"))) { + throw new UsageError(`${projectDir} has no metadata.json; run \`meshy project init\` first`); + } + // An explicit --root outside the workspace is refused up front; the + // implicit root (the project's parent) is checked the same way and, when + // it lies outside, the index is skipped rather than written across the boundary. + if (opts.root) confine(resolvePath(opts.root), opened.flags.workspaceRoot, "--root"); + const indexRoot = indexRootFor(projectDir, opts.root, opened.flags.workspaceRoot); + const res = recordTask( + projectDir, + { + taskId: opts.taskId, + stage: opts.stage, + resource: opts.resource ?? null, + taskType: opts.taskType ?? null, + parentTaskId: opts.parentTaskId ?? null, + status: opts.status ?? null, + files: opts.file ?? [], + taskJson: opts.taskJson ?? null, + operationId: opts.operationId ?? null, + }, + { root: indexRoot.root, skipIndex: indexRoot.skipIndex }, + ); + const warnings: Warning[] = []; + const missing = (opts.file ?? []).filter((f) => !existsSync(join(projectDir, f))); + if (missing.length) warnings.push(warning("recorded_file_missing", `recorded file(s) not present in the project yet: ${missing.join(", ")}`)); + if (res.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); + if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index\``)); + await emitResult(opened, res, { project_dir: res.project_dir, action: res.action, entry: res.entry, task_count: res.metadata.tasks.length, index: res.index, migrated_from_legacy: res.migrated_from_legacy }, { warnings }); + }, + ); + +const showCommand = new Command("show") + .description("Show a project's metadata.json (legacy files are shown in the v2 shape without being rewritten)") + .requiredOption("--project ", "project directory") + .action(async (opts: { project: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.show", "v1"); + buildLocalRuntime(opened.flags); + const read = readProject(opts.project); + const files = read.metadata.tasks.flatMap((t) => t.files.map((f) => ({ task_id: t.task_id, stage: t.stage, file: f, present: existsSync(join(read.path, f)) }))); + await emitResult(opened, read.metadata, { project_dir: read.path, legacy_format: read.legacy, metadata: read.metadata, files }); + }); + +const listCommand = new Command("list") + .description("List projects from history.json and report folders missing from the index") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .action(async (opts: { root?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.list", "v1"); + buildLocalRuntime(opened.flags); + const res = listProjects(rootFrom(opts)); + const warnings: Warning[] = []; + if (res.index_dirty) warnings.push(warning("index_dirty", "history.json does not match the folders on disk; run `meshy project rebuild-index`")); + await emitResult(opened, res.projects, res, { warnings }); + }); + +const rebuildCommand = new Command("rebuild-index") + .description("Regenerate history.json from the project folders (the previous index is backed up)") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .action(async (opts: { root?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.rebuild-index", "v1"); + buildLocalRuntime(opened.flags); + const res = rebuildIndex(confine(rootFrom(opts), opened.flags.workspaceRoot, "--root")); + const warnings: Warning[] = res.skipped.map((s) => warning("project_skipped", `${s.folder}: ${s.reason}`)); + await emitResult(opened, res, res, { warnings }); + }); + +export const projectCommand = new Command("project") + .description("Local meshy_output project folders: init | record | show | list | rebuild-index (no API key needed)") + .addCommand(initCommand) + .addCommand(recordCommand) + .addCommand(showCommand) + .addCommand(listCommand) + .addCommand(rebuildCommand); diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index fa0274e..3c53e80 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -5,11 +5,17 @@ * the root help leads with. An agent that reads `--help` on every invocation * pays for the whole surface every time, so the root lists the one verb most * callers need and points here for the rest. + * + * legacy: the 0.2.0 array of `{name, summary}` (new commands appended). + * v1: `result.items` with `kind` (task | query | local), the command line, + * the endpoint and the supported verbs — all generated from the + * registry and filtered to what this build actually registers. */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { readGlobalFlags } from "../internal/runtime.js"; +import { resourceIndex, type ResourceIndexEntry } from "../client/resource-registry.js"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; interface ResourceEntry { name: string; @@ -35,18 +41,31 @@ export const RESOURCES: ResourceEntry[] = [ { name: "repair-printability", summary: "fix non-watertight / non-manifold geometry" }, { name: "balance", summary: "remaining credit balance" }, { name: "delete", summary: "delete any task, whatever its resource" }, + // Added in S1 (appended so 0.2.0 consumers keep their positions). + { name: "uv-unwrap", summary: "generate fresh UVs for a GLB (≤40k faces) — a UV white model for external texturing" }, + { name: "creative-lab", summary: "photo → printable product: figure | lamp | keychain | fridge-magnet (prototype then build)" }, + { name: "animation-catalog", summary: "public animation library (action ids); no key needed" }, + { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, + { name: "download", summary: "download selected assets of a task (saved task JSON, URL, or API)" }, + { name: "project", summary: "meshy_output project folders: init | record | show | list | rebuild-index" }, + { name: "inspect", summary: "local checks: faces (face-count gate pass | fail | unknown)" }, + { name: "mesh", summary: "prepare-print: OBJ Y-up → Z-up, scale to height, centre, ground at Z=0" }, + { name: "slicer", summary: "detect installed slicers | open a model in one" }, + { name: "doctor", summary: "local environment diagnosis (no network by default)" }, ]; -const VERBS = `Every resource carries the same verbs: +const VERBS = `Every task resource carries the same verbs: meshy create [flags] [--data ''] [--async] [--timeout ] meshy get meshy list [--page ] [--page-size ] [--sort-by ] meshy wait [--timeout ] + meshy stream [--timeout ] [--idle-timeout ] meshy delete \`create\` is sync by default — it blocks until the task is terminal. Pass ---async for the task id straight away, then \`wait\`/\`get\` it later. +--async for the task id straight away, then \`wait\`/\`get\`/\`stream\` it later. +Add --output-schema v1 for the stable machine envelope. Endpoints without a dedicated command are reachable through the passthrough: @@ -56,13 +75,23 @@ Endpoints without a dedicated command are reachable through the passthrough: export const resourcesCommand = new Command("resources") .description("List the per-endpoint commands and the verbs they share") .addHelpText("after", `\n${VERBS}\n`) - .action((_opts: Record, thisCmd: Command) => { - const { format } = readGlobalFlags(thisCmd); - if (format === "pretty") { - const width = Math.max(...RESOURCES.map((r) => r.name.length)); - const lines = RESOURCES.map((r) => ` ${r.name.padEnd(width)} ${r.summary}`); + .action(async (_opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, "resources", "legacy"); + buildLocalRuntime(opened.flags); + const registered = new Set((thisCmd.parent?.commands ?? []).map((c) => c.name())); + const items = resourceIndex().filter((e) => registered.has(e.command.split(" ")[1] ?? "")); + const legacy = RESOURCES.filter((r) => registered.has(r.name)); + + if (opened.schema === "legacy" && opened.format === "pretty") { + const width = Math.max(...legacy.map((r) => r.name.length)); + const lines = legacy.map((r) => ` ${r.name.padEnd(width)} ${r.summary}`); process.stdout.write(`${lines.join("\n")}\n\n${VERBS}\n`); return; } - emit(RESOURCES, { format }); + const byKind = (kind: ResourceIndexEntry["kind"]) => items.filter((e) => e.kind === kind).length; + await emitResult(opened, legacy, { + items, + counts: { task: byKind("task"), query: byKind("query"), local: byKind("local") }, + verbs_help: "meshy resources --help", + }); }); diff --git a/src/cmd/showcases.ts b/src/cmd/showcases.ts new file mode 100644 index 0000000..b253e7a --- /dev/null +++ b/src/cmd/showcases.ts @@ -0,0 +1,82 @@ +/** + * showcases — GET /openapi/v1/showcases (Enterprise tier). + * + * Every request may cost a credit, so this command performs exactly one GET + * with the parameters given, never retries, never paginates, and is never + * called by doctor or any smoke check. Items are passed through unchanged. + */ + +import { Command, Option } from "commander"; +import { SHOWCASE_FORMATS, SHOWCASE_SORT_BY, SHOWCASE_TYPES } from "../client/endpoints/showcases.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { UsageError } from "../internal/errors.js"; +import { parseInt10 } from "../internal/flags.js"; +import { warning, type Warning } from "../internal/result.js"; +import { buildRuntime } from "../internal/runtime.js"; + +const listCommand = new Command("list") + .description("Search community showcase models (Enterprise tier; every request is billed)") + .option("--search ", "text search in model names (server-side)") + .option("--page-size ", "1-10 (default: 3)", parseInt10) + .addOption(new Option("--sort-by ", "sort order (default: -created_at)").choices([...SHOWCASE_SORT_BY])) + .addOption(new Option("--model-format ", "model format to return (default: glb)").choices([...SHOWCASE_FORMATS])) + .addOption( + new Option("--showcase-type ", "all (default) | animate | static (the docs spell it 'animated'; both are accepted)").choices([ + ...SHOWCASE_TYPES, + "animated", + ]), + ) + .option("--include-raw", "include the untouched response under result.raw") + .option("--save-json ", "save the untouched response to this file (never overwrites)") + .action( + async ( + opts: { search?: string; pageSize?: number; sortBy?: string; modelFormat?: string; showcaseType?: string; includeRaw?: boolean; saveJson?: string }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "showcases.list", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + if (opts.pageSize !== undefined && (opts.pageSize < 1 || opts.pageSize > 10)) { + throw new UsageError("--page-size must be between 1 and 10"); + } + const warnings: Warning[] = []; + let showcaseType = opts.showcaseType; + if (showcaseType === "animated") { + showcaseType = "animate"; + warnings.push( + warning( + "showcase_type_alias", + "the server enum spells this value 'animate' (the docs say 'animated'); sent as showcase_type=animate", + ), + ); + } + const request = { + page_size: opts.pageSize, + sort_by: opts.sortBy, + search: opts.search, + format: opts.modelFormat, + showcase_type: showcaseType, + }; + const runtime = await buildRuntime(opened.flags); + const { items, raw } = await runtime.client.showcases.list(request, { signal: abortSignal() }); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; + await emitResult( + opened, + items, + { + items, + count: items.length, + request, + billing: "may-charge", + requests_made: 1, + saved_json: saved, + ...(opts.includeRaw ? { raw } : {}), + }, + { warnings }, + ); + }, + ); + +export const showcasesCommand = new Command("showcases") + .description("Enterprise showcase search (every request is billed)") + .addCommand(listCommand); diff --git a/src/cmd/slicer.ts b/src/cmd/slicer.ts new file mode 100644 index 0000000..748da78 --- /dev/null +++ b/src/cmd/slicer.ts @@ -0,0 +1,54 @@ +/** + * slicer — detect installed slicers and hand a model file to one of them. + * + * Purely local: no API, no credential, no network. `detect` reports the seven + * registered slicers found on this machine (an empty list is a valid answer); + * `open` launches only a registered, detected slicer at its detected path with + * the file as one argument — no shell, no default-application fallback — and + * reports `launch_requested`, which is not a claim that the import succeeded. + */ + +import { Command } from "commander"; +import { emitResult, openCommand, type OpenedCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; +import { detectSlicers, LAUNCHABLE_EXTENSIONS, openInSlicer, SLICERS } from "../internal/slicers.js"; + +/** `-o` downloads assets elsewhere; here it would be silently ignored, so refuse it. */ +function rejectOutputFlag(opened: OpenedCommand): void { + if (opened.flags.output !== undefined) { + throw new UsageError(`--output/-o has no meaning for ${opened.command}; this command writes no files`); + } +} + +const detectCommand = new Command("detect") + .description( + `Detect installed slicers (${SLICERS.map((s) => s.name).join(", ")}). Local only; an empty list is a successful result`, + ) + .action(async (_opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, "slicer.detect", "v1"); + rejectOutputFlag(opened); + buildLocalRuntime(opened.flags); + const detection = detectSlicers(); + await emitResult(opened, detection, detection); + }); + +const openCmd = new Command("open") + .description( + "Open a model file in a detected slicer. Launches the detected executable (macOS: open -a ) with the " + + "file as a single argument and returns once the process started; it never waits for the GUI to close", + ) + .requiredOption("--slicer ", "registered slicer name or id (see: meshy slicer detect)") + .requiredOption("--file ", `model file to open (${[...LAUNCHABLE_EXTENSIONS].join(", ")})`) + .action(async (opts: { slicer: string; file: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "slicer.open", "v1"); + rejectOutputFlag(opened); + buildLocalRuntime(opened.flags); + const launch = await openInSlicer(opts.file, opts.slicer); + await emitResult(opened, launch, launch); + }); + +export const slicerCommand = new Command("slicer") + .description("Detect installed slicers and open models in them (no API key required)") + .addCommand(detectCommand) + .addCommand(openCmd); diff --git a/src/cmd/uv-unwrap.ts b/src/cmd/uv-unwrap.ts new file mode 100644 index 0000000..e180935 --- /dev/null +++ b/src/cmd/uv-unwrap.ts @@ -0,0 +1,54 @@ +/** + * uv-unwrap — https://docs.meshy.ai/en/api/uv-unwrap + * + * Generates fresh UVs for a GLB (≤ 40,000 faces) and returns a "UV white + * model" (grey placeholder material, no textures) for external texturing. + * + * Exactly one source is accepted — `--input-task-id` or `--model-url` — also + * when supplied through --data. The server would prefer input_task_id if both + * were sent; refusing the ambiguity here is cheaper than a surprise. Input + * files must be GLB. A face count above 40k is the server's call (400); the + * CLI never downloads a model just to pre-count faces — `meshy inspect faces` + * answers that from a saved task when the field is available. + */ + +import { UsageError } from "../internal/errors.js"; +import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; + +export const UV_UNWRAP_FACE_CEILING = 40_000; + +const spec: ResourceCommandSpec = { + name: "uv-unwrap", + defaultSchema: "v1", + description: + "Generate fresh UVs for a GLB model (input task or GLB file, at most 40k faces) — outputs a UV " + + "white model for external texturing. Remesh first if the mesh is denser", + create: { + description: "Create a uv-unwrap task", + configure(cmd) { + return cmd + .option("--input-task-id ", "SUCCEEDED source task with a GLB output (mutually exclusive with --model-url)") + .option("--model-url ", "GLB model as http(s) URL, data: URI or local .glb path (mutually exclusive with --input-task-id)"); + }, + toPayload(opts) { + return { + input_task_id: opts.inputTaskId, + model_url: opts.modelUrl, + }; + }, + validatePayload(payload) { + const hasTask = payload.input_task_id !== undefined && payload.input_task_id !== null && payload.input_task_id !== ""; + const hasModel = payload.model_url !== undefined && payload.model_url !== null && payload.model_url !== ""; + if (hasTask && hasModel) { + throw new UsageError("uv-unwrap takes exactly one source: --input-task-id or --model-url (also inside --data), not both"); + } + if (!hasTask && !hasModel) { + throw new UsageError("provide --input-task-id or --model-url"); + } + if (hasTask && typeof payload.input_task_id !== "string") throw new UsageError("input_task_id must be a string"); + if (hasModel && typeof payload.model_url !== "string") throw new UsageError("model_url must be a string"); + }, + }, +}; + +export const uvUnwrapCommand = buildResourceCommand(spec); diff --git a/src/index.ts b/src/index.ts index e2b36a0..f3e4751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,118 @@ #!/usr/bin/env node /** * meshy-cli entry point. + * + * Responsibilities kept here on purpose: + * - decide whether the background update check may run before anything else; + * - turn SIGINT into a cooperative abort (exit 130) instead of a hard kill; + * - route every failure — including Commander parse errors — through one + * exit that renders the payload in the schema the command would have used; + * - flush stdout before exiting. */ import type { Command } from "commander"; -import { buildRootCommand } from "./root.js"; -import { exitCodeFor, reportError } from "./internal/errors.js"; -import { refreshCache } from "./internal/update-notifier.js"; +import { buildRootCommand, V1_ONLY_COMMANDS, LOCAL_COMMANDS } from "./root.js"; +import { currentCommand, markInterrupted, wasInterrupted } from "./internal/context.js"; +import { exitCodeFor, isCommanderInformational, reportError, CliError } from "./internal/errors.js"; +import { emitEnvelope, writeStdout, type OutputFormat } from "./internal/output.js"; +import { errorEnvelope, type OutputSchema } from "./internal/result.js"; +import { refreshCache, shouldSkip } from "./internal/update-notifier.js"; async function main(): Promise { - refreshCache(); // fire-and-forget; internally guarded; never throws + const argv = process.argv; + if (argv.includes("--no-update-check")) { + // Children (none today, but any future spawn) inherit the opt-out. + process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + } + if (shouldRunUpdateCheck(argv)) refreshCache(); // fire-and-forget; internally guarded; never throws + + installSignalHandlers(); + const program = buildRootCommand(); try { - await program.parseAsync(process.argv); + await program.parseAsync(argv); const code = process.exitCode ?? 0; return typeof code === "number" ? code : 1; } catch (err) { - const fmt = resolveErrorFormat(program); - reportError(err, fmt); - return exitCodeFor(err); + if (isCommanderInformational(err)) return err.exitCode; + return await reportFailure(program, argv, err); } } +/** + * The update check never runs for local work: no network, no detached child. + * Local means the command touches neither the API nor the update state, or + * the caller said so. + */ +export function shouldRunUpdateCheck(argv: string[], env: NodeJS.ProcessEnv = process.env): boolean { + if (shouldSkip(env)) return false; + if (argv.includes("--no-update-check")) return false; + const rest = argv.slice(2); + if (rest.length === 0) return false; + if (rest.some((a) => a === "--help" || a === "-h" || a === "--version" || a === "-V")) return false; + const first = rest.find((a) => !a.startsWith("-")); + if (!first) return false; + if (LOCAL_COMMANDS.has(first)) return false; + if (first === "make" && rest.includes("--dry-run")) return false; + return true; +} + +function installSignalHandlers(): void { + let count = 0; + process.on("SIGINT", () => { + count += 1; + if (count === 1) { + markInterrupted(); + process.stderr.write("\ninterrupted — finishing local bookkeeping; press Ctrl-C again to force exit\n"); + return; + } + process.exit(130); + }); +} + +async function reportFailure(program: Command, argv: string[], err: unknown): Promise { + const ctx = currentCommand(); + const schema: OutputSchema = ctx?.schema ?? resolveSchemaHeuristically(program, argv); + const format: OutputFormat = ctx?.format ?? resolveErrorFormat(program); + // A SIGINT that surfaced as some other failure is still reported as + // interrupted — but whatever the command already knew (task id, submission, + // files written so far, recovery command) travels with it. + const interruptedErr = + wasInterrupted() && !(err instanceof CliError && err.code === "interrupted") + ? new CliError({ + code: "interrupted", + message: `interrupted by SIGINT${err instanceof Error && err.message ? ` (${err.message})` : ""}`, + result: err instanceof CliError ? err.result : null, + warnings: err instanceof CliError ? err.warnings : [], + recovery: err instanceof CliError ? err.recovery : null, + hint: err instanceof CliError ? err.hint : undefined, + cause: err, + }) + : err; + + if (schema === "v1") { + const command = ctx?.command ?? commandNameFromArgv(program, argv); + const { envelope, exitCode } = errorEnvelope(command, interruptedErr); + process.stderr.write(`error: ${envelope.error?.message ?? "unknown error"}\n`); + if (envelope.error?.hint) process.stderr.write(`hint: ${envelope.error.hint}\n`); + try { + await emitEnvelope(envelope, format); + } catch { + /* stderr already carries the human message */ + } + return exitCode; + } + + reportError(interruptedErr, format); + try { + await writeStdout(""); + } catch { + /* nothing to flush */ + } + return exitCodeFor(interruptedErr); +} + /** * Resolve the output format for the error path. * @@ -38,9 +128,7 @@ async function main(): Promise { * the user passed it) — scanning only root opts would miss * `meshy --format pretty auth login --json`. */ -function resolveErrorFormat( - program: Command, -): "json" | "pretty" | "ndjson" { +function resolveErrorFormat(program: Command): OutputFormat { const anyJson = (cmd: Command): boolean => Boolean(cmd.opts()["json"]) || cmd.commands.some(anyJson); if (anyJson(program)) return "json"; @@ -49,4 +137,42 @@ function resolveErrorFormat( return v === "pretty" || v === "ndjson" ? v : "json"; } +/** + * Before an action ran we only know argv. `--output-schema` is a root option, + * so a parsed value is authoritative; otherwise the first command token decides + * (new commands are v1-only, everything else is legacy). + */ +function resolveSchemaHeuristically(program: Command, argv: string[]): OutputSchema { + const parsed = program.opts()["outputSchema"]; + if (parsed === "v1" || parsed === "legacy") return parsed; + const idx = argv.indexOf("--output-schema"); + if (idx !== -1) { + const v = argv[idx + 1]; + if (v === "v1" || v === "legacy") return v; + } + const first = firstCommandToken(program, argv); + return first && V1_ONLY_COMMANDS.has(first) ? "v1" : "legacy"; +} + +function firstCommandToken(program: Command, argv: string[]): string | undefined { + const names = new Set(program.commands.map((c) => c.name())); + return argv.slice(2).find((a) => names.has(a)); +} + +function commandNameFromArgv(program: Command, argv: string[]): string { + const first = firstCommandToken(program, argv); + if (!first) return "meshy"; + const rest = argv.slice(argv.indexOf(first) + 1).filter((a) => !a.startsWith("-")); + const sub = program.commands.find((c) => c.name() === first); + const path = [first]; + let node = sub; + for (const token of rest) { + const next = node?.commands.find((c) => c.name() === token); + if (!next) break; + path.push(token); + node = next; + } + return path.join("."); +} + main().then((code) => process.exit(code)); diff --git a/src/internal/artifacts.ts b/src/internal/artifacts.ts new file mode 100644 index 0000000..1ebf821 --- /dev/null +++ b/src/internal/artifacts.ts @@ -0,0 +1,317 @@ +/** + * Asset enumeration — which files a task exposes, under stable keys. + * + * Only known response fields are walked; an unknown string URL anywhere in + * `result` is reported under `unknown_urls` and never downloaded. Keys are + * the stable selectors of `meshy download --asset` and of the v1 download + * manifest; the file name is derived separately and never from the key or + * the URL alone. + * + * Product knowledge that changes the delivery format lives here too: + * - lamp build: `lamp_stl` / `base_stl` are STL files, `bundle_zip` is a ZIP; + * - keychain / fridge-magnet build: `model_urls.obj` is a ZIP bundle + * (model.obj + model.mtl + texture.png), delivered as `.zip`, unextracted; + * - motion clips carry their format in `result.motion_format`. + */ + +import type { TaskResourceDescriptor } from "../client/resource-registry.js"; +import { safeExtension, safeSegment } from "./paths.js"; + +export type AssetKind = "model" | "image" | "texture" | "thumbnail" | "rig" | "animation" | "motion" | "report"; + +export interface Asset { + /** Stable selector, e.g. `model.glb`, `thumbnail.primary`, `result.basic_animations.walking_glb_url`. */ + key: string; + kind: AssetKind; + url: string | null; + /** File format the bytes are expected to be in (extension without dot), or null when unknown. */ + format: string | null; + /** Set when the file is a container for the nominal format (keychain OBJ is a ZIP). */ + containerFormat: string | null; + /** Nominal model format when it differs from the file (e.g. `obj` inside a ZIP). */ + modelFormat: string | null; + /** Response path the asset came from. */ + sourcePath: string; + /** Suggested safe file name (directory mode). */ + filename: string; + /** Keys this asset needs to be usable (OBJ → MTL → textures). */ + dependencies: string[]; + /** Report assets carry their JSON here instead of a URL. */ + report?: unknown; + /** Extra flags for the manifest (e.g. extracted:false for bundles). */ + notes?: Record; +} + +export interface AssetEnumeration { + assets: Asset[]; + /** String URLs found in `result` that no rule recognised. */ + unknown_urls: Array<{ path: string; url: string }>; + product: string | null; +} + +const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "webp", "gif"]); + +function isUrl(v: unknown): v is string { + return typeof v === "string" && /^https?:\/\//i.test(v); +} + +/** Creative Lab product from a task type such as `creative-lab-keychain-build`. */ +export function productFromTaskType(type: unknown): { product: string; stage: string } | null { + if (typeof type !== "string") return null; + const m = /^creative-lab-([a-z-]+?)-(prototype|build)$/.exec(type); + return m ? { product: m[1]!, stage: m[2]! } : null; +} + +/** + * The asset a `model_urls` entry stands for. Shared with the legacy `-o` + * downloader so both paths name Creative Lab parts the same way (`lamp.stl`, + * `base.stl`, `bundle.zip`, the keychain/fridge-magnet OBJ bundle as + * `model.obj.zip`) instead of turning the key into an extension. + */ +export function modelAsset(fmtKey: string, url: string, product: string | null): Asset { + const key = `model.${fmtKey}`; + const sourcePath = `model_urls.${fmtKey}`; + switch (fmtKey) { + case "lamp_stl": + return { key, kind: "model", url, format: "stl", containerFormat: null, modelFormat: "stl", sourcePath, filename: "lamp.stl", dependencies: [], notes: { part: "lampshade" } }; + case "base_stl": + return { key, kind: "model", url, format: "stl", containerFormat: null, modelFormat: "stl", sourcePath, filename: "base.stl", dependencies: [], notes: { part: "fixture_base" } }; + case "bundle_zip": + return { key, kind: "model", url, format: "zip", containerFormat: "zip", modelFormat: null, sourcePath, filename: "bundle.zip", dependencies: [], notes: { extracted: false } }; + case "pre_remeshed_glb": + return { key, kind: "model", url, format: "glb", containerFormat: null, modelFormat: "glb", sourcePath, filename: "model.pre_remeshed.glb", dependencies: [] }; + default: { + const ext = safeExtension(fmtKey) || null; + if (ext === "obj" && (product === "keychain" || product === "fridge-magnet")) { + return { key, kind: "model", url, format: "zip", containerFormat: "zip", modelFormat: "obj", sourcePath, filename: "model.obj.zip", dependencies: [], notes: { extracted: false, bundle_contents: ["model.obj", "model.mtl", "texture.png"] } }; + } + const deps: string[] = ext === "obj" ? ["model.mtl"] : []; + return { key, kind: "model", url, format: ext, containerFormat: null, modelFormat: ext, sourcePath, filename: ext ? `model.${ext}` : `model.${safeSegment(fmtKey)}`, dependencies: deps }; + } + } +} + +/** + * Enumerate every downloadable asset of a task object (raw API shape). + * `descriptor` (when known) supplies the product for Creative Lab builds. + */ +export function enumerateAssets(task: Record, descriptor?: TaskResourceDescriptor | null): AssetEnumeration { + const assets: Asset[] = []; + const unknown: Array<{ path: string; url: string }> = []; + const product = descriptor?.creativeLab?.product ?? productFromTaskType(task["type"])?.product ?? null; + + const modelUrls = task["model_urls"]; + if (modelUrls && typeof modelUrls === "object" && !Array.isArray(modelUrls)) { + for (const [fmt, url] of Object.entries(modelUrls as Record)) { + if (isUrl(url)) assets.push(modelAsset(fmt, url, product)); + } + } + // OBJ depends on MTL only when the MTL exists in this task. + const hasMtl = assets.some((a) => a.key === "model.mtl"); + for (const a of assets) { + if (a.key === "model.obj" && a.containerFormat === null) { + a.dependencies = hasMtl ? ["model.mtl", ...textureKeys(task)] : []; + } + } + + if (isUrl(task["thumbnail_url"])) { + assets.push({ key: "thumbnail.primary", kind: "thumbnail", url: task["thumbnail_url"], format: "png", containerFormat: null, modelFormat: null, sourcePath: "thumbnail_url", filename: "thumbnail.png", dependencies: [] }); + } + const thumbs = task["thumbnail_urls"]; + if (thumbs && typeof thumbs === "object") { + if (Array.isArray(thumbs)) { + thumbs.forEach((url, i) => { + if (isUrl(url)) assets.push({ key: `thumbnail.${i}`, kind: "thumbnail", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `thumbnail_urls[${i}]`, filename: `thumbnail_${i}.png`, dependencies: [] }); + }); + } else { + for (const [view, url] of Object.entries(thumbs as Record)) { + if (isUrl(url)) assets.push({ key: `thumbnail.${safeSegment(view)}`, kind: "thumbnail", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `thumbnail_urls.${view}`, filename: `thumbnail_${safeSegment(view)}.png`, dependencies: [], notes: { view } }); + } + } + } + if (isUrl(task["alpha_thumbnail_url"])) { + assets.push({ key: "thumbnail.alpha", kind: "thumbnail", url: task["alpha_thumbnail_url"], format: "png", containerFormat: null, modelFormat: null, sourcePath: "alpha_thumbnail_url", filename: "thumbnail_alpha.png", dependencies: [] }); + } + + const textures = task["texture_urls"]; + if (Array.isArray(textures)) { + textures.forEach((set, i) => { + if (!set || typeof set !== "object") return; + for (const [channel, url] of Object.entries(set as Record)) { + if (isUrl(url)) { + assets.push({ key: `texture.${i}.${safeSegment(channel)}`, kind: "texture", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `texture_urls[${i}].${channel}`, filename: `texture_${i}_${safeSegment(channel)}.png`, dependencies: [] }); + } + } + }); + } + + const images = task["image_urls"]; + if (Array.isArray(images)) { + images.forEach((url, i) => { + if (isUrl(url)) assets.push({ key: `image.${i}`, kind: "image", url, format: null, containerFormat: null, modelFormat: null, sourcePath: `image_urls[${i}]`, filename: `image_${i}`, dependencies: [] }); + }); + } + + const result = task["result"]; + if (result && typeof result === "object" && !Array.isArray(result)) { + const r = result as Record; + const motionFormat = typeof r["motion_format"] === "string" ? safeExtension(r["motion_format"] as string) : ""; + for (const [field, value] of Object.entries(r)) { + if (field === "basic_animations" && value && typeof value === "object" && !Array.isArray(value)) { + for (const [sub, url] of Object.entries(value as Record)) { + if (!isUrl(url)) continue; + const ext = extFromFieldName(sub); + assets.push({ key: `result.basic_animations.${safeSegment(sub)}`, kind: "animation", url, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.basic_animations.${sub}`, filename: `${safeSegment(sub.replace(/_url$/, ""))}${ext ? `.${ext}` : ""}`, dependencies: [] }); + } + continue; + } + if (!isUrl(value)) { + if (value && typeof value === "object") collectUnknownUrls(value, `result.${field}`, unknown); + continue; + } + if (field === "motion_url") { + const ext = motionFormat === "fbx" || motionFormat === "bvh" ? motionFormat : null; + assets.push({ key: "result.motion_url", kind: "motion", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: "result.motion_url", filename: ext ? `motion.${ext}` : "motion", dependencies: [] }); + continue; + } + if (/^rigged_character_(glb|fbx)_url$/.test(field)) { + const ext = extFromFieldName(field); + assets.push({ key: `result.${field}`, kind: "rig", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.${field}`, filename: `rigged_character.${ext}`, dependencies: [] }); + continue; + } + if (/^(animation_(glb|fbx)_url|processed_[a-z0-9_]+_url)$/.test(field)) { + const ext = extFromFieldName(field); + assets.push({ key: `result.${field}`, kind: "animation", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.${field}`, filename: `${safeSegment(field.replace(/_url$/, ""))}${ext ? `.${ext}` : ""}`, dependencies: [] }); + continue; + } + unknown.push({ path: `result.${field}`, url: value }); + } + } + + const printability = task["printability"]; + if (printability && typeof printability === "object") { + assets.push({ key: "report.printability", kind: "report", url: null, format: "json", containerFormat: null, modelFormat: null, sourcePath: "printability", filename: "printability.json", dependencies: [], report: printability }); + } + + return { assets, unknown_urls: unknown, product }; +} + +function textureKeys(task: Record): string[] { + const keys: string[] = []; + const textures = task["texture_urls"]; + if (Array.isArray(textures)) { + textures.forEach((set, i) => { + if (!set || typeof set !== "object") return; + for (const [channel, url] of Object.entries(set as Record)) { + if (isUrl(url)) keys.push(`texture.${i}.${safeSegment(channel)}`); + } + }); + } + return keys; +} + +function extFromFieldName(field: string): string | null { + const m = /_(glb|fbx|usdz|obj|bvh|png|jpg|jpeg|webp|gltf|stl|3mf)_url$/i.exec(field); + if (m) return m[1]!.toLowerCase(); + // processed_usdz_url → usdz ; processed_armature_fbx_url → fbx handled above ; processed_animation_fps_fbx_url → fbx + return null; +} + +function collectUnknownUrls(value: unknown, path: string, out: Array<{ path: string; url: string }>): void { + if (isUrl(value)) { + out.push({ path, url: value }); + return; + } + if (Array.isArray(value)) { + value.forEach((v, i) => collectUnknownUrls(v, `${path}[${i}]`, out)); + return; + } + if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value as Record)) collectUnknownUrls(v, `${path}.${k}`, out); + } +} + +/** 0.2.0 artifact keys → stable keys, so old scripts and internal tests keep resolving. */ +export const LEGACY_KEY_ALIASES: Readonly> = { + thumbnail: "thumbnail.primary", + motion_url: "result.motion_url", +}; + +export function resolveAssetKey(requested: string, assets: readonly Asset[]): Asset | undefined { + const direct = assets.find((a) => a.key === requested); + if (direct) return direct; + const alias = LEGACY_KEY_ALIASES[requested]; + if (alias) return assets.find((a) => a.key === alias); + let m = /^model_([a-z0-9_]+)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `model.${m![1]}`); + m = /^image_(\d+)$/.exec(requested); + if (m) return assets.find((a) => a.key === `image.${m![1]}`); + m = /^texture_(\d+)_([a-z0-9_]+)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `texture.${m![1]}.${m![2]}`); + m = /^([a-z0-9_]+_url)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `result.${m![1]}` || a.key === `result.basic_animations.${m![1]}`); + return undefined; +} + +export interface Selection { + keys?: string[]; + modelFormat?: string; + kind?: AssetKind; + all?: boolean; +} + +export interface SelectionResult { + selected: Asset[]; + /** Dependencies pulled in for selected OBJ files (empty when geometryOnly). */ + dependencies: Asset[]; + missingDependencies: string[]; +} + +/** + * Apply a selector to the enumeration. Exactly one selector kind must be + * given; contradictory combinations are rejected by the caller (usage). + * With `withDependencies`, an OBJ selection pulls its MTL and textures. + */ +export function selectAssets(enumeration: AssetEnumeration, sel: Selection, opts: { withDependencies: boolean }): SelectionResult { + const { assets } = enumeration; + let selected: Asset[]; + if (sel.all) selected = assets.filter((a) => a.url !== null || a.kind === "report"); + else if (sel.keys && sel.keys.length > 0) { + selected = []; + for (const k of sel.keys) { + const a = resolveAssetKey(k, assets); + if (!a) throw new SelectionError(`no asset with key '${k}'`, assets); + if (!selected.includes(a)) selected.push(a); + } + } else if (sel.modelFormat) { + const fmt = sel.modelFormat.toLowerCase(); + selected = assets.filter((a) => a.kind === "model" && (a.key === `model.${fmt}` || a.modelFormat === fmt || a.format === fmt)); + if (selected.length === 0) throw new SelectionError(`no model asset in format '${sel.modelFormat}'`, assets); + } else if (sel.kind) { + selected = assets.filter((a) => a.kind === sel.kind); + if (selected.length === 0) throw new SelectionError(`no asset of kind '${sel.kind}'`, assets); + } else { + throw new SelectionError("no selector given", assets); + } + const dependencies: Asset[] = []; + const missing: string[] = []; + if (opts.withDependencies) { + for (const a of selected) { + for (const dep of a.dependencies) { + const d = assets.find((x) => x.key === dep); + if (!d) missing.push(dep); + else if (!selected.includes(d) && !dependencies.includes(d)) dependencies.push(d); + } + } + } + return { selected, dependencies, missingDependencies: missing }; +} + +export class SelectionError extends Error { + readonly candidates: Array<{ key: string; kind: AssetKind; format: string | null }>; + constructor(message: string, assets: readonly Asset[]) { + super(message); + this.name = "SelectionError"; + this.candidates = assets.map((a) => ({ key: a.key, kind: a.kind, format: a.format })); + } +} diff --git a/src/internal/atomic-file.ts b/src/internal/atomic-file.ts new file mode 100644 index 0000000..ffa99b3 --- /dev/null +++ b/src/internal/atomic-file.ts @@ -0,0 +1,183 @@ +/** + * Exclusive and atomic file publication. + * + * Two contracts: + * - no-overwrite (default): the target must not exist when the file lands. + * `exists → rename` is a race (POSIX rename replaces a concurrent + * winner), so the temp file is published with `link()`, which fails + * atomically with EEXIST. Where hard links are unsupported the fallback + * opens the target with O_EXCL and copies — still exclusive, no longer + * atomic, and reported as such. + * - overwrite: `rename()` over a target that is a regular file (or absent). + * Directories and symlinks are never replaced. + * + * Temp files always live in the target directory (same filesystem) and are + * removed on every failure path. + */ + +import { + closeSync, + copyFileSync, + constants as fsConstants, + linkSync, + lstatSync, + mkdirSync, + openSync, + renameSync, + unlinkSync, + writeFileSync, + writeSync, + readFileSync, +} from "node:fs"; +import { randomBytes } from "node:crypto"; +import { basename, dirname, join } from "node:path"; +import { CliError } from "./errors.js"; + +export type PublishMethod = "link" | "rename" | "copy-exclusive"; + +export interface PublishOptions { + overwrite?: boolean; +} + +export interface PublishResult { + path: string; + method: PublishMethod; +} + +export function tempPathFor(target: string): string { + return join(dirname(target), `.${basename(target)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`); +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* already gone */ + } +} + +/** Create the target's directory; a parent that is a file (or unwritable) is a local_io error, not an internal one. */ +function ensureParentDir(target: string): void { + try { + mkdirSync(dirname(target), { recursive: true }); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `cannot create directory ${dirname(target)}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } +} + +export function refuseOverwriteError(target: string): CliError { + return new CliError({ + code: "local_io", + message: `refusing to overwrite existing file: ${target} (pass --overwrite, or choose another path)`, + recovery: { action: "choose_path", automatic: false }, + }); +} + +/** + * Move a fully written temp file onto `target`. + * The temp file is consumed (removed) whether or not publication succeeds. + */ +export function publishTempFile(tmp: string, target: string, opts: PublishOptions = {}): PublishResult { + try { + if (opts.overwrite) { + let existing: ReturnType | null = null; + try { + existing = lstatSync(target); + } catch (err) { + if (errno(err) !== "ENOENT") throw err; + } + if (existing && !existing.isFile()) { + throw new CliError({ + code: "local_io", + message: `refusing to replace ${target}: it is not a regular file`, + }); + } + renameSync(tmp, target); + return { path: target, method: "rename" }; + } + try { + linkSync(tmp, target); + unlinkSync(tmp); + return { path: target, method: "link" }; + } catch (err) { + const code = errno(err); + if (code === "EEXIST") throw refuseOverwriteError(target); + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "EOPNOTSUPP" && code !== "EACCES" && code !== "EMLINK") { + throw err; + } + // Hard links unavailable on this filesystem: exclusive create + copy. + let fd: number; + try { + fd = openSync(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + } catch (openErr) { + if (errno(openErr) === "EEXIST") throw refuseOverwriteError(target); + throw openErr; + } + try { + const data = readFileSync(tmp); + let offset = 0; + while (offset < data.length) { + offset += writeSync(fd, data, offset, data.length - offset); + } + } finally { + closeSync(fd); + } + unlinkSync(tmp); + return { path: target, method: "copy-exclusive" }; + } + } catch (err) { + removeQuietly(tmp); + if (err instanceof CliError) throw err; + throw new CliError({ + code: "local_io", + message: `failed to write ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } +} + +/** Serialise `value` as pretty JSON and publish it exclusively (or atomically replace with overwrite). */ +export function writeJsonFile( + target: string, + value: unknown, + opts: PublishOptions & { mode?: number } = {}, +): PublishResult { + ensureParentDir(target); + const tmp = tempPathFor(target); + try { + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: opts.mode ?? 0o600 }); + } catch (err) { + removeQuietly(tmp); + throw new CliError({ + code: "local_io", + message: `failed to write ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + return publishTempFile(tmp, target, opts); +} + +/** Copy an existing file to `target` under the same publish rules. */ +export function copyFilePublished(source: string, target: string, opts: PublishOptions = {}): PublishResult { + ensureParentDir(target); + const tmp = tempPathFor(target); + try { + copyFileSync(source, tmp); + } catch (err) { + removeQuietly(tmp); + throw new CliError({ + code: "local_io", + message: `failed to copy ${source} → ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + return publishTempFile(tmp, target, opts); +} diff --git a/src/internal/command-helpers.ts b/src/internal/command-helpers.ts new file mode 100644 index 0000000..7917612 --- /dev/null +++ b/src/internal/command-helpers.ts @@ -0,0 +1,83 @@ +/** + * Small shared pieces every command action uses: + * - open the command context (name, schema, format) so the error exit can + * render in the right shape; + * - render a result in either schema; + * - save the raw API JSON (--save-json) with exclusive publication. + */ + +import { Command } from "commander"; +import { dirname, resolve as resolvePath } from "node:path"; +import { beginCommand, type CommandContext } from "./context.js"; +import { UsageError, type Warning } from "./errors.js"; +import { emit, emitEnvelope, type OutputFormat } from "./output.js"; +import { okEnvelope, type OutputSchema } from "./result.js"; +import { readGlobalFlags, resolveSchema, type GlobalFlags } from "./runtime.js"; +import { writeJsonFile } from "./atomic-file.js"; +import { resolveWithinRoot, type AuthorisedRoot } from "./paths.js"; + +export interface OpenedCommand extends CommandContext { + flags: GlobalFlags; +} + +/** Read global flags, decide the schema and register the command context. */ +export function openCommand(thisCmd: Command, command: string, defaultSchema: OutputSchema): OpenedCommand { + const flags = readGlobalFlags(thisCmd); + const schema = resolveSchema(flags, defaultSchema); + const ctx = beginCommand({ command, schema, format: flags.format }); + return { ...ctx, flags }; +} + +/** + * Print a command result. Legacy keeps the bare payload (and the historical + * `-o ` meaning "write the JSON there" for non-task commands); v1 prints + * the envelope and treats `-o` on a non-task command as a usage error that + * points at --save-json. + */ +export async function emitResult( + opened: OpenedCommand, + legacyValue: unknown, + v1Result: unknown, + opts: { warnings?: Warning[]; legacyFile?: string | undefined; format?: OutputFormat } = {}, +): Promise { + const format = opts.format ?? opened.format; + if (opened.schema === "v1") { + await emitEnvelope(okEnvelope(opened.command, v1Result, opts.warnings ?? []), format); + return; + } + emit(legacyValue, { format, file: opts.legacyFile }); +} + +/** v1: `-o` is reserved for assets; JSON goes through --save-json. */ +export function rejectOutputFlagForV1(opened: OpenedCommand, saveJson: string | undefined): void { + if (opened.schema !== "v1") return; + if (opened.flags.output && saveJson) { + throw new UsageError("--output/-o and --save-json cannot be combined on this command; -o is for assets, --save-json for the raw JSON"); + } + if (opened.flags.output) { + throw new UsageError("this command produces JSON, not assets: use --save-json instead of --output/-o under --output-schema v1"); + } +} + +export interface SavedJson { + path: string; + bytes: number; +} + +/** + * Save the raw JSON the API returned (never the CLI envelope). The file is + * published exclusively; an existing file is an error unless `overwrite`. + */ +export function saveRawJson( + target: string, + raw: unknown, + opts: { workspace?: string | AuthorisedRoot | undefined; overwrite?: boolean; cwd?: string } = {}, +): SavedJson { + const cwd = opts.cwd ?? process.cwd(); + const abs = resolvePath(cwd, target); + const root: string | AuthorisedRoot = opts.workspace ?? dirname(abs); + const resolved = resolveWithinRoot(abs, root, { cwd, label: "--save-json target" }); + const text = `${JSON.stringify(raw, null, 2)}\n`; + writeJsonFile(resolved.path, raw, { overwrite: opts.overwrite ?? false }); + return { path: resolved.path, bytes: Buffer.byteLength(text, "utf8") }; +} diff --git a/src/internal/config.ts b/src/internal/config.ts index 053ba07..9dd6641 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -1,31 +1,50 @@ /** * Resolve config in this priority order: - * 1. CLI flags (--api-key, --base-url-v1, --base-url-v2, --verbose) + * 1. CLI flags (--api-key, --base-url-v1, --base-url-v2, --base-url-creative-lab, --verbose) * 2. Environment variables (MESHY_*) - * 3. The active profile in the credentials file (written by `meshy auth login`) - * 4. Built-in defaults + * 3. An explicit --api-key-file (only MESHY_API_KEY is read from it) + * 4. The active profile in the credentials file (written by `meshy auth login`) + * 5. Built-in defaults * * The env var keeps priority over the stored credential on purpose: CI and * containers export MESHY_API_KEY and must not be silently overridden by * whatever a developer once logged into on that machine. * + * An empty or placeholder --api-key / MESHY_API_KEY counts as "unset" (0.2.0 + * behaviour; CI and the runtime tests rely on `MESHY_API_KEY=""` meaning + * "fall through to the stored profile"). An explicit --api-key-file is different: + * naming a file is an instruction, so an unreadable, malformed or key-less + * file is an error, never a fall-through to another account. + * * Fail-fast on a missing/placeholder credential — with the command that fixes * it attached, not just a complaint. */ import { credentialsPath, resolveStoredCredential, type CredentialKind } from "./credentials.js"; -import { authRequiredError } from "./errors.js"; +import { loadEnvFile } from "./env-file.js"; +import { authRequiredError, CliError } from "./errors.js"; import { setLogLevel, type LogLevel } from "./logger.js"; const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); +export const DEFAULT_BASE_URL_V1 = "https://api.meshy.ai/openapi/v1"; +export const DEFAULT_BASE_URL_V2 = "https://api.meshy.ai/openapi/v2"; + /** Where the credential in use came from — surfaced by `meshy auth status`. */ -export type CredentialSource = "flag" | "env" | "file"; +export type CredentialSource = "flag" | "env" | "env-file" | "file"; export interface MeshyConfig { apiKey: string; baseUrlV1: string; baseUrlV2: string; + /** + * Creative Lab base. Derived from the v1 origin when v1 uses the standard + * `/openapi/v1` path; null when it cannot be derived and no explicit + * override was given (commands that need it then ask for one). + */ + baseUrlCreativeLab: string | null; + /** Public, unauthenticated catalog base: `/web/public`. */ + publicWebBase: string; connectTimeoutMs: number; readTimeoutMs: number; pollIntervalMs: number; @@ -33,20 +52,32 @@ export interface MeshyConfig { credentialSource: CredentialSource; /** Set only when credentialSource === "file". */ credentialProfile?: string; + /** Set only when credentialSource === "env-file" (the --api-key-file path). */ + envFilePath?: string; /** The credentials file consulted for this invocation, whether or not it exists. */ credentialsFile: string; /** * The kind of credential in use — "oauth" for browser-login tokens, "api_key" for static keys. - * "api_key" when credentialSource is "flag" or "env" (those paths only accept static keys). + * "api_key" when credentialSource is "flag", "env" or "env-file" (those paths only accept static keys). * Derived from the stored profile kind when credentialSource is "file". */ credentialKind: CredentialKind; + /** + * Stable account subject for an OAuth profile (user id), used to bind the + * operation journal to the account rather than to a rotating token. Unset for + * API keys (the key itself is digested) and for profiles without a user id. + */ + credentialSubject?: string; + /** Per-login identifier of an OAuth profile (minted at `auth login`), the fallback identity when no user id exists. */ + credentialLoginId?: string; } export interface ConfigOverrides { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; + envFile?: string; logLevel?: LogLevel; } @@ -73,24 +104,69 @@ function stripTrail(s: string): string { return s.replace(/\/+$/, ""); } +/** Origin (scheme://host[:port]) of a base URL, or null when it does not parse. */ +export function originOf(baseUrl: string): string | null { + try { + return new URL(baseUrl).origin; + } catch { + return null; + } +} + +/** + * Derive the Creative Lab base from the v1 base. Only the standard + * `/openapi/v1` layout is derivable; a custom proxy path makes the + * derivation a guess, and a guessed base must never fall back to production. + */ +export function deriveCreativeLabBase(baseUrlV1: string): string | null { + try { + const u = new URL(baseUrlV1); + if (u.pathname.replace(/\/+$/, "") !== "/openapi/v1") return null; + return `${u.origin}/openapi/creative-lab`; + } catch { + return null; + } +} + +export function derivePublicWebBase(baseUrlV1: string): string { + const origin = originOf(baseUrlV1) ?? "https://api.meshy.ai"; + return `${origin}/web/public`; +} + export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { // Base URLs resolve first: they decide which credentials file applies // (production vs. a staging override). const baseUrlV1 = stripTrail( - overrides.baseUrlV1 ?? process.env.MESHY_BASE_URL_V1 ?? "https://api.meshy.ai/openapi/v1", + overrides.baseUrlV1 ?? process.env.MESHY_BASE_URL_V1 ?? DEFAULT_BASE_URL_V1, ); const baseUrlV2 = stripTrail( - overrides.baseUrlV2 ?? process.env.MESHY_BASE_URL_V2 ?? "https://api.meshy.ai/openapi/v2", + overrides.baseUrlV2 ?? process.env.MESHY_BASE_URL_V2 ?? DEFAULT_BASE_URL_V2, ); + const explicitCreativeLab = overrides.baseUrlCreativeLab ?? process.env.MESHY_BASE_URL_CREATIVE_LAB; + const baseUrlCreativeLab = explicitCreativeLab + ? stripTrail(explicitCreativeLab) + : deriveCreativeLabBase(baseUrlV1); const credFile = credentialsPath(baseUrlV1); const flagKey = overrides.apiKey?.trim() ?? ""; const envKey = process.env.MESHY_API_KEY?.trim() ?? ""; + // An explicit env file is validated even when a higher-priority key wins: + // the user named it, so a broken file is a mistake worth reporting now. + let envFileKey = ""; + let envFilePath: string | undefined; + if (overrides.envFile) { + const loaded = loadEnvFile(overrides.envFile); + envFilePath = loaded.path; + envFileKey = loaded.apiKey?.trim() ?? ""; + } + let apiKey = ""; let credentialSource: CredentialSource = "flag"; let credentialProfile: string | undefined; let credentialKind: CredentialKind = "api_key"; + let credentialSubject: string | undefined; + let credentialLoginId: string | undefined; if (!PLACEHOLDER_KEYS.has(flagKey)) { apiKey = flagKey; @@ -100,6 +176,16 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { apiKey = envKey; credentialSource = "env"; credentialKind = "api_key"; + } else if (overrides.envFile) { + if (PLACEHOLDER_KEYS.has(envFileKey)) { + throw new CliError({ + code: "auth", + message: `--api-key-file ${overrides.envFile} does not define a usable MESHY_API_KEY (missing, empty or placeholder). Fix the file or drop --api-key-file to use another credential source.`, + }); + } + apiKey = envFileKey; + credentialSource = "env-file"; + credentialKind = "api_key"; } else { // A corrupt credentials file throws out of resolveStoredCredential rather // than being swallowed into "not logged in" — see credentials.ts. @@ -110,6 +196,8 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { credentialSource = "file"; credentialProfile = stored.profile; credentialKind = stored.kind; + credentialSubject = stored.userId; + credentialLoginId = stored.loginId; } else { throw authRequiredError( "No credentials found. Pass --api-key, export MESHY_API_KEY, or log in.", @@ -121,16 +209,38 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { apiKey, baseUrlV1, baseUrlV2, + baseUrlCreativeLab, + publicWebBase: derivePublicWebBase(baseUrlV1), connectTimeoutMs: readNumber("MESHY_CONNECT_TIMEOUT_MS", 10_000), readTimeoutMs: readNumber("MESHY_READ_TIMEOUT_MS", 120_000), pollIntervalMs: readNumber("MESHY_POLL_INTERVAL_MS", 3_000), logLevel: overrides.logLevel ?? readLogLevel("warn"), credentialSource, credentialProfile, + envFilePath, credentialsFile: credFile, credentialKind, + credentialSubject, + credentialLoginId, }; setLogLevel(cfg.logLevel); return cfg; } + +/** + * Stored profiles were issued for the v1 origin they were resolved against. + * A different Creative Lab origin only receives an explicitly supplied key. + */ +export function assertCredentialAllowedForOrigin(cfg: MeshyConfig, targetBase: string, label: string): void { + if (cfg.credentialSource !== "file") return; + const v1Origin = originOf(cfg.baseUrlV1); + const targetOrigin = originOf(targetBase); + if (v1Origin && targetOrigin && v1Origin === targetOrigin) return; + throw new CliError({ + code: "auth", + message: + `${label} base ${targetBase} is on a different origin than the v1 API (${cfg.baseUrlV1}); ` + + "the stored profile is not sent there. Pass --api-key, MESHY_API_KEY or --api-key-file for that origin.", + }); +} diff --git a/src/internal/context.ts b/src/internal/context.ts new file mode 100644 index 0000000..0913d00 --- /dev/null +++ b/src/internal/context.ts @@ -0,0 +1,51 @@ +/** + * Per-invocation command context. The CLI runs one command per process; the + * action that owns the command registers its name, output schema and format + * here so the top-level error exit can render the failure in the same shape + * the success path would have used. Parse errors happen before any action + * runs, so the entry point falls back to argv heuristics when this is unset. + * + * The shared AbortController is what SIGINT trips; long operations (wait, + * stream, download) observe `signal` and convert an abort into exit 130. + */ + +import type { OutputFormat } from "./output.js"; +import type { OutputSchema } from "./result.js"; + +export interface CommandContext { + command: string; + schema: OutputSchema; + format: OutputFormat; +} + +let current: CommandContext | null = null; +const controller = new AbortController(); +let interrupted = false; + +export function beginCommand(ctx: CommandContext): CommandContext { + current = ctx; + return ctx; +} + +export function currentCommand(): CommandContext | null { + return current; +} + +export function abortSignal(): AbortSignal { + return controller.signal; +} + +export function markInterrupted(): void { + if (interrupted) return; + interrupted = true; + controller.abort(new Error("interrupted by SIGINT")); +} + +export function wasInterrupted(): boolean { + return interrupted; +} + +/** Test hook: reset module state between in-process runs. */ +export function resetCommandContextForTests(): void { + current = null; +} diff --git a/src/internal/credentials.ts b/src/internal/credentials.ts index b83a182..55abc05 100644 --- a/src/internal/credentials.ts +++ b/src/internal/credentials.ts @@ -52,6 +52,13 @@ export interface CredentialProfile { /** Unix epoch millis. */ expires_at?: number; user_id?: string; + /** + * Random identifier minted by `meshy auth login` for OAuth profiles. It + * survives token refreshes and is replaced by a new login, so the operation + * journal can bind a submission to *this* login even when the token endpoint + * reports no user_id. + */ + login_id?: string; created_at?: number; } @@ -319,6 +326,10 @@ export interface ResolvedStoredCredential { apiKey?: string; accessToken?: string; expiresAt?: number; + /** Stable account subject of an OAuth profile, when the token endpoint reported one. */ + userId?: string; + /** Per-login identifier of an OAuth profile (see CredentialProfile.login_id). */ + loginId?: string; } /** @@ -341,6 +352,8 @@ export function resolveStoredCredential(file: string): ResolvedStoredCredential kind: "oauth", accessToken: profile.access_token, expiresAt: profile.expires_at, + ...(profile.user_id ? { userId: profile.user_id } : {}), + ...(profile.login_id ? { loginId: profile.login_id } : {}), }; } diff --git a/src/internal/doctor.ts b/src/internal/doctor.ts new file mode 100644 index 0000000..efd9e43 --- /dev/null +++ b/src/internal/doctor.ts @@ -0,0 +1,345 @@ +/** + * doctor — read-only environment diagnosis. + * + * The default run is fully local and must complete with no credential, no + * network and an empty config directory. It reports versions, which credential + * sources are *present* — never their values: the stored profile is stat'ed + * and not parsed, the flag and env var become booleans, and an explicit + * `--api-key-file` is run through the same parser API commands use with only + * its verdict kept — the effective base URLs, workspace writability and + * whether the cwd holds a `.env` candidate (named, never read: there is no + * auto-discovery, D-016). Nothing here refreshes an OAuth token or writes. + * + * `--check-api` resolves the credential exactly like an API command (flags → + * env → key file → stored profile) and makes one GET /balance — the only free + * authenticated endpoint. `--check-slicers` runs the local slicer detection. + * Neither is implied by the other (D-024). Everything else stays offline. + */ + +import { accessSync, constants as fsConstants, statSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { MeshyClient } from "../client/index.js"; +import { + DEFAULT_BASE_URL_V1, + DEFAULT_BASE_URL_V2, + deriveCreativeLabBase, + derivePublicWebBase, + loadConfig, + type MeshyConfig, +} from "./config.js"; +import { credentialsPath } from "./credentials.js"; +import { loadEnvFile } from "./env-file.js"; +import { configOverridesFrom, type GlobalFlags } from "./runtime.js"; +import { detectSlicers as detectSlicersImpl, type DetectionEnv, type SlicerDetection } from "./slicers.js"; +import { VERSION } from "./version.js"; + +export type DoctorCheckStatus = "ok" | "warn" | "fail" | "skipped"; + +export interface DoctorCheck { + id: string; + status: DoctorCheckStatus; + detail: string; +} + +export interface DoctorReport { + cli: { version: string; node: string; platform: string; arch: string }; + /** Node satisfies the engine range and the CLI loaded: local commands can run. */ + local_ready: boolean; + /** null unless --check-api was requested. */ + api_ready: boolean | null; + checks: DoctorCheck[]; + credential_sources: { + flag: boolean; + env: boolean; + /** Absolute path of --api-key-file when given (its verdict is a check), else null. */ + api_key_file: string | null; + stored_profile: { path: string; exists: boolean }; + }; + base_urls: { v1: string; v2: string; creative_lab: string | null; public_web: string }; + workspace: { path: string | null; writable: boolean | null }; + /** Names among .env / .env.local present in cwd — never read. */ + cwd_env_candidates: string[]; + slicers?: unknown; + api?: { balance: number } | { error: string } | null; +} + +export type { SlicerDetection } from "./slicers.js"; + +/** Injectable slicer detection (tests pass a fake); the default is internal/slicers.ts. */ +export type SlicerDetector = (env?: Partial) => SlicerDetection | unknown; + +export interface DoctorOptions { + flags: GlobalFlags; + checkApi: boolean; + checkSlicers: boolean; + /** Drives the local checks (default process.env). `--check-api` resolves through loadConfig, which reads process.env like every API command. */ + env?: NodeJS.ProcessEnv; + cwd?: string; + detectSlicers?: SlicerDetector; + probeBalance?: () => Promise; +} + +export interface DoctorApiFailure { + /** `credentials`: no usable credential could be resolved; `balance`: the single GET failed. */ + stage: "credentials" | "balance"; + error: unknown; +} + +export interface DoctorOutcome { + report: DoctorReport; + /** Set when --check-api did not end with api_ready true; the command maps it to an exit code. */ + apiFailure: DoctorApiFailure | null; +} + +const REQUIRED_NODE_MAJOR = 24; +const CWD_ENV_CANDIDATES = [".env", ".env.local"] as const; +/** Mirrors config.ts: an empty or placeholder key means "unset". */ +const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); + +function keyPresent(value: string | null | undefined): boolean { + return typeof value === "string" && !PLACEHOLDER_KEYS.has(value.trim()); +} + +function stripTrail(s: string): string { + return s.replace(/\/+$/, ""); +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** Replace every known secret in free text; paths and verdicts are all a report needs. */ +function scrub(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + if (secret.length >= 4) out = out.split(secret).join("[redacted]"); + } + return out; +} + +function summarizeSlicers(detection: unknown): string { + if (detection && typeof detection === "object") { + const d = detection as Partial; + const found = Array.isArray(d.slicers) ? d.slicers : null; + if (found) { + const names = found.map((s) => (s && typeof s === "object" && typeof s.name === "string" ? s.name : "?")); + const platform = typeof d.platform === "string" ? d.platform : process.platform; + return found.length === 0 + ? `no registered slicer detected on ${platform}` + : `${found.length} slicer(s) detected on ${platform}: ${names.join(", ")}`; + } + } + return "slicer detection completed"; +} + +/** Run the diagnosis and also return the raw API failure for exit-code mapping. */ +export async function runDoctorDetailed(opts: DoctorOptions): Promise { + const { flags } = opts; + const env = opts.env ?? process.env; + const cwd = opts.cwd ?? process.cwd(); + const checks: DoctorCheck[] = []; + const secrets: string[] = []; + if (typeof flags.apiKey === "string") secrets.push(flags.apiKey.trim()); + if (typeof env["MESHY_API_KEY"] === "string") secrets.push(env["MESHY_API_KEY"].trim()); + + // --- CLI and runtime ----------------------------------------------------- + const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); + const nodeOk = Number.isFinite(nodeMajor) && nodeMajor >= REQUIRED_NODE_MAJOR; + checks.push({ id: "cli", status: "ok", detail: `meshy-cli ${VERSION} on node ${process.version} (${process.platform} ${process.arch})` }); + checks.push({ + id: "node", + status: nodeOk ? "ok" : "fail", + detail: nodeOk + ? `node ${process.version} satisfies the required >=${REQUIRED_NODE_MAJOR}` + : `node ${process.version} is below the required >=${REQUIRED_NODE_MAJOR}; install Node ${REQUIRED_NODE_MAJOR} or newer`, + }); + + // --- Base URLs (same precedence as config.ts, resolved without a credential) --- + const v1 = stripTrail(flags.baseUrlV1 ?? env["MESHY_BASE_URL_V1"] ?? DEFAULT_BASE_URL_V1); + const v2 = stripTrail(flags.baseUrlV2 ?? env["MESHY_BASE_URL_V2"] ?? DEFAULT_BASE_URL_V2); + const explicitCreativeLab = flags.baseUrlCreativeLab ?? env["MESHY_BASE_URL_CREATIVE_LAB"]; + const creativeLab = explicitCreativeLab ? stripTrail(explicitCreativeLab) : deriveCreativeLabBase(v1); + const publicWeb = derivePublicWebBase(v1); + let v1Parses = true; + try { + new URL(v1); + } catch { + v1Parses = false; + } + checks.push({ + id: "base_urls", + status: v1Parses && creativeLab !== null ? "ok" : "warn", + detail: !v1Parses + ? `v1 base ${JSON.stringify(v1)} is not a valid URL (check --base-url-v1 / MESHY_BASE_URL_V1)` + : creativeLab === null + ? `v1 ${v1}; v2 ${v2}; Creative Lab base cannot be derived from a non-standard v1 path — pass --base-url-creative-lab when using creative-lab commands` + : `v1 ${v1}; v2 ${v2}; creative-lab ${creativeLab}; public-web ${publicWeb}`, + }); + + // --- Credential sources: presence only ---------------------------------- + const flagPresent = keyPresent(flags.apiKey); + const envPresent = keyPresent(env["MESHY_API_KEY"]); + let apiKeyFilePath: string | null = null; + let apiKeyFileUsable = false; + if (flags.envFile) { + apiKeyFilePath = resolvePath(cwd, flags.envFile); + try { + const loaded = loadEnvFile(flags.envFile, cwd); + apiKeyFilePath = loaded.path; + if (loaded.apiKey) secrets.push(loaded.apiKey.trim()); + const ignored = loaded.otherKeys.length > 0 ? `; ${loaded.otherKeys.length} other key(s) ignored: ${loaded.otherKeys.join(", ")}` : ""; + if (keyPresent(loaded.apiKey)) { + apiKeyFileUsable = true; + checks.push({ id: "api_key_file", status: "ok", detail: `${loaded.path} defines MESHY_API_KEY (value not shown${ignored})` }); + } else { + checks.push({ id: "api_key_file", status: "fail", detail: `${loaded.path} defines no usable MESHY_API_KEY (missing, empty or placeholder${ignored})` }); + } + } catch (err) { + checks.push({ id: "api_key_file", status: "fail", detail: messageOf(err) }); + } + } else { + checks.push({ id: "api_key_file", status: "skipped", detail: "--api-key-file not given" }); + } + const storedPath = credentialsPath(v1, env); + const storedExists = isFile(storedPath); + const anySource = flagPresent || envPresent || apiKeyFileUsable || storedExists; + checks.push({ + id: "credentials", + status: anySource ? "ok" : "warn", + detail: + `sources present: --api-key=${flagPresent ? "yes" : "no"}, MESHY_API_KEY=${envPresent ? "yes" : "no"}, ` + + `--api-key-file=${flags.envFile ? (apiKeyFileUsable ? "usable" : "unusable") : "none"}, ` + + `stored profile=${storedExists ? "present" : "absent"} (${storedPath}); values are never read by doctor` + + (anySource ? "" : ". API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`"), + }); + + // --- Workspace ----------------------------------------------------------- + let workspace: DoctorReport["workspace"] = { path: null, writable: null }; + if (flags.workspace) { + const abs = resolvePath(cwd, flags.workspace); + workspace = { path: abs, writable: null }; + let st: ReturnType | null = null; + try { + st = statSync(abs); + } catch { + st = null; + } + if (!st) { + checks.push({ id: "workspace", status: "warn", detail: `${abs} does not exist yet; local tools will create it on first write` }); + } else if (!st.isDirectory()) { + workspace.writable = false; + checks.push({ id: "workspace", status: "fail", detail: `${abs} is not a directory` }); + } else { + try { + accessSync(abs, fsConstants.W_OK); + workspace.writable = true; + checks.push({ id: "workspace", status: "ok", detail: `${abs} is a writable directory` }); + } catch { + workspace.writable = false; + checks.push({ id: "workspace", status: "fail", detail: `${abs} is not writable by this user` }); + } + } + } else { + checks.push({ id: "workspace", status: "skipped", detail: "--workspace not given (files land next to their targets)" }); + } + + // --- cwd .env candidates: names only -------------------------------------- + const cwdEnvCandidates = CWD_ENV_CANDIDATES.filter((name) => isFile(resolvePath(cwd, name))); + checks.push({ + id: "cwd_env_files", + status: "ok", + detail: + cwdEnvCandidates.length === 0 + ? `no .env or .env.local in ${cwd} (none is ever read automatically)` + : `${cwdEnvCandidates.join(", ")} found in ${cwd}; not read — pass --api-key-file to use one`, + }); + + const report: DoctorReport = { + cli: { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }, + local_ready: nodeOk && VERSION.length > 0, + api_ready: null, + checks, + credential_sources: { + flag: flagPresent, + env: envPresent, + api_key_file: apiKeyFilePath, + stored_profile: { path: storedPath, exists: storedExists }, + }, + base_urls: { v1, v2, creative_lab: creativeLab, public_web: publicWeb }, + workspace, + cwd_env_candidates: cwdEnvCandidates, + slicers: null, + api: null, + }; + + // --- --check-api: one free GET /balance ------------------------------------ + let apiFailure: DoctorApiFailure | null = null; + if (opts.checkApi) { + let config: MeshyConfig | null = null; + try { + config = loadConfig(configOverridesFrom(flags)); + secrets.push(config.apiKey); + } catch (err) { + apiFailure = { stage: "credentials", error: err }; + report.api_ready = false; + report.api = { error: messageOf(err) }; + checks.push({ id: "api", status: "fail", detail: `no usable credential: ${messageOf(err)}` }); + } + if (config) { + const resolved = config; + const probe = opts.probeBalance ?? (async (): Promise => (await new MeshyClient(resolved).balance.get()).balance); + try { + const balance = await probe(); + report.api_ready = true; + report.api = { balance }; + checks.push({ + id: "api", + status: "ok", + detail: `GET ${resolved.baseUrlV1}/balance succeeded with the ${resolved.credentialSource} credential; balance ${balance}`, + }); + } catch (err) { + apiFailure = { stage: "balance", error: err }; + report.api_ready = false; + report.api = { error: messageOf(err) }; + checks.push({ id: "api", status: "fail", detail: `GET /balance failed: ${messageOf(err)}` }); + } + } + } else { + checks.push({ id: "api", status: "skipped", detail: "not requested (pass --check-api for one free GET /balance)" }); + } + + // --- --check-slicers: local detection only --------------------------------- + if (opts.checkSlicers) { + try { + const detect: SlicerDetector = opts.detectSlicers ?? ((overrides) => detectSlicersImpl(overrides)); + // Detection reads the real platform/filesystem; only the environment block is injectable. + const detection = detect(opts.env ? { env: opts.env as Record } : undefined); + report.slicers = detection; + checks.push({ id: "slicers", status: "ok", detail: summarizeSlicers(detection) }); + } catch (err) { + report.slicers = { error: messageOf(err) }; + checks.push({ id: "slicers", status: "fail", detail: `slicer detection failed: ${messageOf(err)}` }); + } + } else { + checks.push({ id: "slicers", status: "skipped", detail: "not requested (pass --check-slicers)" }); + } + + // Free text is the only place a secret could slip through; strip every known value. + for (const check of checks) check.detail = scrub(check.detail, secrets); + if (report.api && "error" in report.api) report.api = { error: scrub(report.api.error, secrets) }; + + return { report, apiFailure }; +} + +/** Run the diagnosis; never throws for a missing credential or a failed check. */ +export async function runDoctor(opts: DoctorOptions): Promise { + return (await runDoctorDetailed(opts)).report; +} diff --git a/src/internal/download.ts b/src/internal/download.ts index d9f264d..bee8699 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -1,32 +1,602 @@ /** - * Download a completed task's artifacts to the local filesystem. + * Asset downloads with explicit boundaries. * - * Two target shapes: - * 1. File path (e.g. `character/front.jpeg`) — used when the task has a - * single downloadable artifact (most 2D image tasks). The Content-Type - * of the HTTP response is authoritative; if the user-supplied extension - * disagrees, the file is saved with the correct extension instead. - * 2. Directory path (anything without a recognized file extension) — used - * when the task emits multiple artifacts (3D models, thumbnails, - * textures, animation outputs). Each artifact is named after its role - * (model.glb, thumbnail.png, texture_0_base_color.png, ...). + * Every byte that lands on disk goes through `fetchToTemp`: http(s) only, no + * embedded credentials, no Authorization or Cookie ever attached (asset hosts + * are not the API), redirects re-validated hop by hop (max 5), private + * network literals refused (loopback is allowed for local test servers), a + * hard size cap enforced while streaming, sha256 computed on the way. The + * temp file lives in the target directory and is published exclusively + * (`link`), or replaced atomically with --overwrite; the final path — after + * any MIME-driven extension change — is checked against the authorised root. * - * A `meta.json` alongside the artifacts records the full task response plus - * the saved paths for later reference. + * Two entry points share the core: + * downloadArtifacts — the 0.2.0 `-o` behaviour (all artifacts, role-based + * names, meta.json sidecar); output shape unchanged. An + * explicit `root` (the --workspace) confines every path. + * downloadAssets — the selective downloader behind `meshy download` and + * the v1 manifest. + * Both relink OBJ → MTL → texture references after the set has landed (see + * material-links.ts), so a saved OBJ loads with the files beside it. */ -import { createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname, extname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs"; +import { Transform } from "node:stream"; +import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import sharp from "sharp"; import type { Task } from "../client/types.js"; -import { UsageError } from "./errors.js"; +import { publishTempFile, tempPathFor, writeJsonFile } from "./atomic-file.js"; +import { CliError, UsageError } from "./errors.js"; import { logger } from "./logger.js"; +import { freezeRoot, isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment, type AuthorisedRoot } from "./paths.js"; +import { USER_AGENT } from "./user-agent.js"; +import { modelAsset, productFromTaskType, type Asset } from "./artifacts.js"; +import { fileDigest, relinkMaterials, type MaterialLinkReport } from "./material-links.js"; /** Extensions sharp can transcode between. */ const CONVERTIBLE_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "tiff", "tif", "avif"]); +export const DEFAULT_DOWNLOAD_LIMITS = { + /** 2 GiB — engineering default, not a Meshy limit. */ + maxBytes: 2 * 1024 * 1024 * 1024, + timeoutMs: 300_000, + maxRedirects: 5, +} as const; + +export interface DownloadLimits { + maxBytes: number; + timeoutMs: number; + maxRedirects: number; +} + +export interface DownloadPolicy { + /** Plain http is accepted only for loopback hosts (local test servers). */ + allowHttpLoopback: boolean; + /** Private-network literals (10/8, 172.16/12, 192.168/16, link-local) are refused unless set. */ + allowPrivateNetwork: boolean; +} + +export const DEFAULT_DOWNLOAD_POLICY: DownloadPolicy = { allowHttpLoopback: true, allowPrivateNetwork: false }; + +export interface FetchOptions { + limits?: Partial; + policy?: Partial; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +export interface FetchedFile { + tmpPath: string; + bytes: number; + sha256: string; + contentType: string | null; + finalUrl: string; + status: number; +} + +function isLoopbackHost(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h); +} + +function isPrivateLiteral(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 0) return true; + return false; + } + if (h.includes(":")) { + if (/^f[cd]/.test(h)) return true; // fc00::/7 + if (/^fe[89ab]/.test(h)) return true; // fe80::/10 + return false; + } + return false; +} + +/** Validate an asset URL against the policy; throws CliError (local_io) with a clear reason. */ +export function validateAssetUrl(raw: string, policy: DownloadPolicy, label = "asset URL"): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new CliError({ code: "validation", message: `${label} is not a valid URL: ${raw}` }); + } + if (url.username || url.password) throw new CliError({ code: "validation", message: `${label} carries embedded credentials; refused` }); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new CliError({ code: "validation", message: `${label} must be http(s), got ${url.protocol}` }); + } + const loopback = isLoopbackHost(url.hostname); + if (!loopback && isPrivateLiteral(url.hostname) && !policy.allowPrivateNetwork) { + throw new CliError({ code: "validation", message: `${label} points at a private network address (${url.hostname}); refused` }); + } + if (url.protocol === "http:" && !(loopback && policy.allowHttpLoopback)) { + throw new CliError({ code: "validation", message: `${label} uses plain http to ${url.hostname}; only https (or http to a loopback test host) is accepted` }); + } + return url; +} + +/** + * Stream a URL into a temp file next to `target`, following at most + * `maxRedirects` re-validated redirects, without any credential header. + */ +export async function fetchToTemp(rawUrl: string, target: string, opts: FetchOptions = {}): Promise { + const limits: DownloadLimits = { ...DEFAULT_DOWNLOAD_LIMITS, ...(opts.limits ?? {}) }; + const policy: DownloadPolicy = { ...DEFAULT_DOWNLOAD_POLICY, ...(opts.policy ?? {}) }; + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + mkdirSync(dirname(target), { recursive: true }); + const tmpPath = tempPathFor(target); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`download timed out after ${limits.timeoutMs}ms`)), limits.timeoutMs); + const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal; + + let url = validateAssetUrl(rawUrl, policy); + try { + let resp: Response | null = null; + for (let hop = 0; ; hop++) { + let r: Response; + try { + r = await fetchImpl(url, { method: "GET", redirect: "manual", signal, headers: { "User-Agent": USER_AGENT, Accept: "*/*" } }); + } catch (err) { + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` }); + if (controller.signal.aborted) throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` }); + throw new CliError({ code: "network", message: `download of ${redact(url)} failed: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + if (r.status >= 300 && r.status < 400) { + const loc = r.headers.get("location"); + await r.body?.cancel().catch(() => undefined); + if (!loc) throw new CliError({ code: "network", message: `redirect from ${redact(url)} without a Location header` }); + if (hop >= limits.maxRedirects) throw new CliError({ code: "network", message: `too many redirects downloading ${redact(url)}` }); + const next = new URL(loc, url); + if (url.protocol === "https:" && next.protocol === "http:") { + throw new CliError({ code: "validation", message: `refusing https → http downgrade redirect from ${redact(url)}` }); + } + url = validateAssetUrl(next.href, policy, "redirect target"); + continue; + } + resp = r; + break; + } + if (!resp.ok) { + await resp.body?.cancel().catch(() => undefined); + throw new CliError({ + code: resp.status === 404 ? "not_found" : resp.status === 401 || resp.status === 403 || resp.status === 410 ? "validation" : "network", + message: `download failed for ${redact(url)} (HTTP ${resp.status}${resp.statusText ? ` ${resp.statusText}` : ""})`, + httpStatus: resp.status, + details: { expired_or_denied: resp.status === 401 || resp.status === 403 || resp.status === 410 }, + }); + } + const declared = resp.headers.get("content-length"); + if (declared && Number(declared) > limits.maxBytes) { + await resp.body?.cancel().catch(() => undefined); + throw new CliError({ code: "local_io", message: `asset ${redact(url)} declares ${declared} bytes, above the ${limits.maxBytes}-byte limit` }); + } + const hash = createHash("sha256"); + let bytes = 0; + const source = resp.body ? Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream) : Readable.from([]); + const counter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + bytes += chunk.length; + if (bytes > limits.maxBytes) { + cb(new CliError({ code: "local_io", message: `asset ${redact(url)} exceeds the ${limits.maxBytes}-byte limit` })); + return; + } + hash.update(chunk); + cb(null, chunk); + }, + }); + try { + await pipeline(source, counter, createWriteStream(tmpPath, { mode: 0o600 })); + } catch (err) { + removeQuietly(tmpPath); + if (err instanceof CliError) throw err; + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` }); + if (controller.signal.aborted) throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` }); + throw new CliError({ code: "network", message: `download of ${redact(url)} failed mid-stream: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + return { tmpPath, bytes, sha256: hash.digest("hex"), contentType: resp.headers.get("content-type"), finalUrl: url.href, status: resp.status }; + } finally { + clearTimeout(timer); + } +} + +/** URL without its query string — signed parameters never reach logs or messages. */ +export function redact(url: URL | string): string { + try { + const u = typeof url === "string" ? new URL(url) : url; + return `${u.origin}${u.pathname}`; + } catch { + return ""; + } +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* gone */ + } +} + +// --------------------------------------------------------------------------- +// Content checks +// --------------------------------------------------------------------------- + +export function extFromContentType(ct: string | null): string { + if (!ct) return ""; + const base = ct.split(";")[0]!.trim().toLowerCase(); + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/tiff": "tiff", + "image/bmp": "bmp", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/obj": "obj", + "model/vnd.usdz+zip": "usdz", + "model/stl": "stl", + "model/3mf": "3mf", + "application/zip": "zip", + "application/json": "json", + "video/mp4": "mp4", + }; + return map[base] ?? ""; +} + +function extEquivalent(a: string, b: string): boolean { + const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])]; + return groups.some((g) => g.has(a) && g.has(b)); +} + +function readHead(path: string, n = 16): Buffer { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(n); + const read = readSync(fd, buf, 0, n, 0); + return buf.subarray(0, read); + } finally { + closeSync(fd); + } +} + +/** + * Reject bodies that cannot be what the asset claims: an HTML error page + * served as a model, a non-GLB under .glb, a non-ZIP under .zip. + */ +export function validateContent(tmpPath: string, expectedFormat: string | null, kind: Asset["kind"], contentType: string | null): void { + const ct = (contentType ?? "").split(";")[0]!.trim().toLowerCase(); + const head = readHead(tmpPath, 16); + const text = head.toString("latin1").toLowerCase(); + if ((kind === "model" || kind === "rig" || kind === "animation" || kind === "motion") && (ct === "text/html" || text.startsWith("= 4 && head.subarray(0, 4).toString("ascii") === "glTF")) { + throw new CliError({ code: "validation", message: "asset saved under .glb does not start with the glTF magic; refusing to keep an invalid GLB" }); + } + if (expectedFormat === "zip" && !(head.length >= 2 && head[0] === 0x50 && head[1] === 0x4b)) { + throw new CliError({ code: "validation", message: "asset expected to be a ZIP container does not start with the PK magic" }); + } +} + +// --------------------------------------------------------------------------- +// Selective downloader (meshy download, v1) +// --------------------------------------------------------------------------- + +export interface DownloadedFile { + key: string; + path: string; + relative_path: string | null; + bytes: number; + sha256: string; + content_type: string | null; + format: string | null; + container_format: string | null; + extracted: boolean | null; + status: "written" | "failed" | "skipped"; + error: string | null; + publish_method: string | null; + /** True when the file's material references were rewritten to the saved names (OBJ/MTL only). */ + relinked: boolean; +} + +export interface DownloadAssetsOptions extends FetchOptions { + /** Directory mode. */ + targetDir?: string; + /** Single-file mode (exactly one asset). */ + targetFile?: string; + overwrite?: boolean; + /** Authorised root every final path must stay inside — frozen before the first transfer (a string is frozen here). */ + root: string | AuthorisedRoot; + /** Validate magic/content-type for models (the legacy wrapper turns this off). */ + validateContent?: boolean; + /** Bounded URL refresh hook (API source): returns fresh URLs by key or null. */ + refreshUrls?: () => Promise | null>; + onFile?: (file: DownloadedFile) => void; +} + +export interface DownloadAssetsResult { + files: DownloadedFile[]; + /** True when every requested asset was written. */ + complete: boolean; + warnings: Array<{ code: string; message: string }>; + /** OBJ/MTL/texture reference report when the set contained a text OBJ. */ + materialLinks: MaterialLinkReport | null; +} + +function plannedName(asset: Asset, targetFile: string | undefined): string { + if (targetFile) return basename(targetFile); + return asset.filename; +} + +/** + * Download the given assets one by one. Each file is published on its own; + * a failure stops the loop and the result lists what was written so far. + * No rollback deletes anything the user already had. + */ +export async function downloadAssets(assets: readonly Asset[], opts: DownloadAssetsOptions): Promise { + if (opts.targetFile && assets.length !== 1) { + throw new UsageError(`--output names a single file but ${assets.length} assets were selected; pass --output-dir instead`); + } + if (!opts.targetFile && !opts.targetDir) throw new UsageError("an output file or directory is required"); + const files: DownloadedFile[] = []; + const warnings: Array<{ code: string; message: string }> = []; + const dir = opts.targetFile ? dirname(resolvePath(opts.targetFile)) : resolvePath(opts.targetDir!); + // The root is frozen once: its real path and directory identity now, re-proven + // at every later check, so a boundary replaced during a transfer is refused. + const root: AuthorisedRoot = typeof opts.root === "string" ? freezeRoot(opts.root, { label: "output root" }) : opts.root; + const rootReal = root.real; + // The directory and every planned leaf must be inside the root (and no + // symlink) before anything at all is created — a refused target must not + // leave a directory behind; the check repeats after any MIME-driven rename. + resolveWithinRoot(dir, root, { label: "output directory" }); + for (const asset of assets) { + resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), root, { label: "planned download path" }); + } + mkdirSync(dir, { recursive: true }); + + let refreshed: Map | null | undefined; + for (const asset of assets) { + const planned = join(dir, plannedName(asset, opts.targetFile)); + let entry: DownloadedFile; + try { + if (asset.kind === "report") { + const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, root, { label: "report path" }).path; + const res = writeJsonFile(target, asset.report, { overwrite: opts.overwrite ?? false }); + const bytes = statSync(target).size; + entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method, relinked: false }; + } else { + if (!asset.url) throw new CliError({ code: "validation", message: `asset ${asset.key} has no URL` }); + let url = asset.url; + let fetched: FetchedFile; + try { + fetched = await fetchToTemp(url, planned, opts); + } catch (err) { + const expired = err instanceof CliError && (err.details as { expired_or_denied?: boolean } | undefined)?.expired_or_denied; + if (expired && opts.refreshUrls) { + if (refreshed === undefined) refreshed = await opts.refreshUrls(); + const fresh = refreshed?.get(asset.key); + if (fresh && fresh !== url) { + warnings.push({ code: "asset_url_refreshed", message: `${asset.key}: signed URL rejected; refreshed once from the task` }); + url = fresh; + fetched = await fetchToTemp(url, planned, opts); + } else throw err; + } else throw err; + } + entry = await placeFetched(asset, fetched, planned, root, opts, warnings); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const failed: DownloadedFile = { key: asset.key, path: planned, relative_path: rel(rootReal, planned), bytes: 0, sha256: "", content_type: null, format: asset.format, container_format: asset.containerFormat, extracted: null, status: "failed", error: message, publish_method: null, relinked: false }; + files.push(failed); + opts.onFile?.(failed); + const code = err instanceof CliError ? err.code : "local_io"; + throw new CliError({ + code: code === "interrupted" ? "interrupted" : code === "network" || code === "not_found" || code === "validation" ? code : "local_io", + message: `${asset.key}: ${message}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + result: { downloads: { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null } }, + warnings, + cause: err, + }); + } + files.push(entry); + opts.onFile?.(entry); + } + const sources = new Map(assets.map((a) => [a.key, a.url ? basenameOfUrl(a.url) : null] as const)); + let materialLinks: MaterialLinkReport | null = null; + try { + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: "interrupted before the material references were relinked" }); + materialLinks = await relinkWritten(files, warnings, sources, opts.signal); + } catch (err) { + // Every file is on disk; say so, with the bytes actually there. + redigest(files); + const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(opts.signal?.aborted); + throw new CliError({ + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `${files.length} file(s) were written but relinking the material references ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + result: { downloads: { state: "partial", files, metadata_path: null, failed_step: "relink" } }, + warnings, + cause: err, + }); + } + return { files, complete: files.every((f) => f.status === "written"), warnings, materialLinks }; +} + +/** Re-take each committed file's digest from disk (a relink may have rewritten it before a later step failed). */ +function redigest(files: Array<{ status: string; path: string; bytes: number; sha256: string; relinked: boolean }>): void { + for (const f of files) { + if (f.status !== "written") continue; + try { + const d = fileDigest(f.path); + if (d.sha256 !== f.sha256) f.relinked = true; + f.bytes = d.bytes; + f.sha256 = d.sha256; + } catch { + /* keep the recorded digest */ + } + } +} + +/** Last path segment of an asset URL (decoded), the name the server knew the file by; null when unparseable. */ +export function basenameOfUrl(url: string): string | null { + try { + const segment = new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? ""; + let name = segment; + try { + name = decodeURIComponent(segment); + } catch { + /* keep the raw segment */ + } + return name || null; + } catch { + return null; + } +} + +/** After the set landed: point OBJ → MTL → textures at the saved names and re-take the digests of rewritten files. */ +async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>, sources: Map, signal: AbortSignal | undefined): Promise { + const written = files.filter((f) => f.status === "written" && f.container_format === null); + const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path, sourceName: sources.get(f.key) ?? null })), { signal }); + if (!links) return null; + for (const path of links.rewritten) { + const entry = files.find((f) => f.path === path); + if (!entry) continue; + const digest = fileDigest(path); + entry.bytes = digest.bytes; + entry.sha256 = digest.sha256; + entry.relinked = true; + } + warnings.push(...links.warnings); + return links; +} + +function rel(root: string, path: string): string | null { + const r = relative(root, path); + return isInside(root, path) ? r.split(/[\\/]/).join("/") : null; +} + +/** Reconcile the extension with the real content type, validate, and publish exclusively. */ +async function placeFetched( + asset: Asset, + fetched: FetchedFile, + planned: string, + root: AuthorisedRoot, + opts: DownloadAssetsOptions, + warnings: Array<{ code: string; message: string }>, +): Promise { + const rootReal = root.real; + const actualExt = extFromContentType(fetched.contentType); + const requestedExt = safeExtension(extname(planned)); + let finalPath = planned; + let tmp = fetched.tmpPath; + let bytes = fetched.bytes; + let sha = fetched.sha256; + + if (asset.containerFormat === "zip") { + // Bundles are delivered as ZIP whatever the content-type says. + if (requestedExt !== "zip") finalPath = `${stripExt(planned)}.zip`; + } else if (!requestedExt && actualExt) { + finalPath = `${planned}.${actualExt}`; + } else if (requestedExt && actualExt && requestedExt !== actualExt && !extEquivalent(requestedExt, actualExt)) { + if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { + const converted = `${tmp}.conv`; + await convertImage(readFileSync(tmp), requestedExt, converted); + removeQuietly(tmp); + tmp = converted; + bytes = statSync(converted).size; + sha = createHash("sha256").update(readFileSync(converted)).digest("hex"); + warnings.push({ code: "image_transcoded", message: `${asset.key}: server sent ${actualExt}, transcoded to ${requestedExt} as requested` }); + } else { + finalPath = `${stripExt(planned)}.${actualExt}`; + warnings.push({ code: "extension_corrected", message: `${asset.key}: requested .${requestedExt} but the server sent ${fetched.contentType ?? "unknown"}; saved as ${basename(finalPath)}` }); + } + } + + const expectedFormat = asset.containerFormat === "zip" ? "zip" : safeExtension(extname(finalPath)) || null; + if (opts.validateContent !== false) { + try { + validateContent(tmp, expectedFormat, asset.kind, fetched.contentType); + } catch (err) { + removeQuietly(tmp); + throw err; + } + } + + // The final path — possibly renamed — must still be inside the root, and + // must not be a symlink or an existing file (unless --overwrite). + let resolved: string; + try { + resolved = resolveWithinRoot(finalPath, root, { label: "final download path" }).path; + } catch (err) { + removeQuietly(tmp); + throw err; + } + const res = publishTempFile(tmp, resolved, { overwrite: opts.overwrite ?? false }); + return { + key: asset.key, + path: resolved, + relative_path: rel(rootReal, resolved), + bytes, + sha256: sha, + content_type: fetched.contentType, + format: asset.containerFormat === "zip" ? "zip" : safeExtension(extname(resolved)) || asset.format, + container_format: asset.containerFormat, + extracted: asset.containerFormat ? false : null, + status: "written", + error: null, + publish_method: res.method, + relinked: false, + }; +} + +function stripExt(path: string): string { + const ext = extname(path); + return ext ? path.slice(0, -ext.length) : path; +} + +async function convertImage(buffer: Buffer, targetExt: string, targetPath: string): Promise { + const pipe = sharp(buffer); + switch (targetExt) { + case "jpg": + case "jpeg": + await pipe.jpeg({ quality: 92 }).toFile(targetPath); + return; + case "png": + await pipe.png().toFile(targetPath); + return; + case "webp": + await pipe.webp({ quality: 92 }).toFile(targetPath); + return; + case "gif": + await pipe.gif().toFile(targetPath); + return; + case "tiff": + case "tif": + await pipe.tiff().toFile(targetPath); + return; + case "avif": + await pipe.avif({ quality: 60 }).toFile(targetPath); + return; + default: + throw new Error(`unsupported image target extension: ${targetExt}`); + } +} + +// --------------------------------------------------------------------------- +// Legacy `-o` wrapper (0.2.0 layout preserved) +// --------------------------------------------------------------------------- + export interface Artifact { /** Stable slot name ("model_glb", "image_0", "texture_0_base_color", ...) */ key: string; @@ -34,15 +604,24 @@ export interface Artifact { url: string; /** Expected extension inferred from the slot name (no dot, may be empty). */ preferredExt: string; + /** File name to save under when the slot name is not `.` (Creative Lab parts, bundles). */ + filename?: string; } export function enumerateArtifacts(task: Task): Artifact[] { const out: Artifact[] = []; if (task.model_urls) { + // `model_urls` keys are usually formats (glb, obj) but Creative Lab builds + // use part names (`lamp_stl`, `base_stl`, `bundle_zip`) and deliver the + // keychain / fridge-magnet OBJ as a ZIP bundle. The selective downloader + // already knows this (artifacts.ts); the legacy layout names the files the + // same way while keeping its slot keys (`model_lamp_stl`). + const product = productFromTaskType(task.type)?.product ?? null; for (const [ext, url] of Object.entries(task.model_urls)) { if (typeof url === "string" && url) { - out.push({ key: `model_${ext}`, url, preferredExt: ext.toLowerCase() }); + const asset = modelAsset(ext, url, product); + out.push({ key: `model_${ext}`, url, preferredExt: asset.format ?? ext.toLowerCase(), filename: asset.filename }); } } } @@ -69,10 +648,19 @@ export function enumerateArtifacts(task: Task): Artifact[] { }); } - // Rigging / animate-style endpoints nest outputs under `result`. + // Rigging / animate-style endpoints nest outputs under `result`; the + // bundled walking/running clips live one level deeper. if (task.result && typeof task.result === "object") { const motionFormat = task.result["motion_format"]; for (const [key, value] of Object.entries(task.result)) { + if (key === "basic_animations" && value && typeof value === "object" && !Array.isArray(value)) { + for (const [sub, url] of Object.entries(value as Record)) { + if (typeof url !== "string" || !/^https?:/.test(url)) continue; + const extMatch = sub.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i); + out.push({ key: `basic_animations_${sub}`, url, preferredExt: extMatch ? extMatch[1]!.toLowerCase() : "" }); + } + continue; + } if (typeof value !== "string" || !/^https?:/.test(value)) continue; const extMatch = key.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i); let preferredExt = extMatch ? extMatch[1]!.toLowerCase() : ""; @@ -91,23 +679,115 @@ export function looksLikeFile(path: string): boolean { return /\.[A-Za-z0-9]{2,6}$/.test(path); } +export interface LegacyDownloadedFile { + /** Legacy artifact slot ("model_glb", "thumbnail", "texture_0_base_color", …). */ + key: string; + /** Final absolute path (after any content-type driven rename) — or the planned path for a failed entry. */ + path: string; + bytes: number; + sha256: string; + content_type: string | null; + status: "written" | "failed"; + error: string | null; + /** True when the file's material references were rewritten to the saved names. */ + relinked: boolean; +} + export interface DownloadResult { savedFiles: string[]; metadataPath: string; + /** OBJ/MTL/texture reference report when the artifacts contained a text OBJ. */ + materialLinks: MaterialLinkReport | null; + /** + * Per-file manifest in download order. When the download stops early the + * same list (written files + the failed one) travels on the thrown + * CliError's `result.downloads`, so nothing already on disk is forgotten. + */ + files: LegacyDownloadedFile[]; +} + +export interface DownloadArtifactsOptions { + /** Authorised root (the --workspace, frozen when the flags were read); every directory and file must resolve inside it. Default: the output directory itself. A string is frozen here. */ + root?: string | AuthorisedRoot; + /** Cooperative cancellation (SIGINT): aborts the in-flight transfer and stops every later download, relink and publish. */ + signal?: AbortSignal; +} + +function interruptedBefore(what: string): CliError { + return new CliError({ code: "interrupted", message: `interrupted before ${what}; nothing further was written` }); +} + +/** + * A failure *after* every transfer landed (relink, digest refresh, sidecar). + * The manifest keeps every committed file with the bytes actually on disk — + * a relink may already have rewritten some — and names the step that failed; + * a cooperative interrupt is `interrupted` (130), everything else keeps its class. + */ +function finalisationFailure(step: "relink" | "digest" | "sidecar", err: unknown, files: LegacyDownloadedFile[], signal: AbortSignal | undefined): CliError { + redigest(files); + const downloads = { state: "partial", files, metadata_path: null, failed_step: step }; + const written = files.filter((f) => f.status === "written").length; + const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(signal?.aborted); + const message = `${written} file(s) were written but the ${step} step ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`; + if (err instanceof CliError) { + return new CliError({ + code: interrupted ? "interrupted" : err.code, + message, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...(err.result ?? {}), downloads }, + cause: err, + }); + } + return new CliError({ code: interrupted ? "interrupted" : "local_io", message, result: { downloads }, cause: err }); +} + +/** + * Wrap a per-artifact failure so the caller sees what already landed. The + * original classification (network / not_found / validation / interrupted / + * local_io, HTTP status, recovery) is kept; only the message names the artifact. + */ +function downloadFailure(artifact: Artifact, err: unknown, files: LegacyDownloadedFile[]): CliError { + const message = err instanceof Error ? err.message : String(err); + const downloads = { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null }; + if (err instanceof CliError) { + return new CliError({ + code: err.code, + message: `download failed for ${artifact.key}: ${message}`, + exitCode: err.exitCode, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...(err.result ?? {}), downloads }, + cause: err, + }); + } + return new CliError({ code: "local_io", message: `download failed for ${artifact.key}: ${message}`, result: { downloads }, cause: err }); } export async function downloadArtifacts( task: Task, outputPath: string, resource: string, + opts: DownloadArtifactsOptions = {}, ): Promise { const artifacts = enumerateArtifacts(task); + // An explicit workspace is the root for everything written here — the + // directory, every planned file and the sidecar — checked before mkdir. + const workspaceRoot: AuthorisedRoot | null = opts.root === undefined ? null : typeof opts.root === "string" ? freezeRoot(opts.root, { label: "--workspace" }) : opts.root; if (artifacts.length === 0) { // Report-only tasks (analyze-printability) carry their result in a // structured field instead of downloadable files. Persist the full task // JSON so `-o` still means "give me the result on disk". if (task.printability != null) { - return saveReportOnly(task, outputPath, resource); + return saveReportOnly(task, outputPath, resource, workspaceRoot); } throw new Error(`task ${task.id} has no downloadable artifacts`); } @@ -124,15 +804,13 @@ export async function downloadArtifacts( // work when a destination already exists. This prevents silent overwrite // of prior runs. let targetDir: string; - const plannedArtifactPaths: string[] = []; + const plan: Array<{ artifact: Artifact; target: string }> = []; if (singleFileMode) { targetDir = dirname(outputPath) || "."; - plannedArtifactPaths.push(outputPath); + plan.push({ artifact: artifacts[0]!, target: outputPath }); } else { targetDir = outputPath; - for (const artifact of artifacts) { - plannedArtifactPaths.push(join(targetDir, deriveFilename(artifact))); - } + for (const artifact of artifacts) plan.push({ artifact, target: join(targetDir, deriveFilename(artifact)) }); } // Per-file meta in single-file mode (`-o a.png` → `a_meta.json`) so two // outputs can share a directory without trampling each other. Directory @@ -141,7 +819,7 @@ export async function downloadArtifacts( ? `${stripExt(outputPath)}_meta.json` : join(targetDir, "meta.json"); - const existing = [...plannedArtifactPaths, metadataPath].filter((p) => existsSync(p)); + const existing = [...plan.map((p) => p.target), metadataPath].filter((p) => existsSync(p)); if (existing.length > 0) { throw new UsageError( `refusing to overwrite existing file(s):\n ${existing.join("\n ")}\n` + @@ -149,21 +827,61 @@ export async function downloadArtifacts( ); } + if (workspaceRoot) { + resolveWithinRoot(targetDir, workspaceRoot, { label: "output directory" }); + for (const p of [...plan.map((x) => x.target), metadataPath]) resolveWithinRoot(p, workspaceRoot, { label: "planned download path" }); + } + mkdirSync(targetDir, { recursive: true }); + const root: AuthorisedRoot = workspaceRoot ?? freezeRoot(targetDir, { label: "output directory" }); + const files: LegacyDownloadedFile[] = []; const saved: string[] = []; - if (singleFileMode) { - saved.push(await downloadArtifact(artifacts[0]!, outputPath)); - } else { - for (const artifact of artifacts) { - const targetPath = join(targetDir, deriveFilename(artifact)); - saved.push(await downloadArtifact(artifact, targetPath)); + for (const { artifact, target } of plan) { + if (opts.signal?.aborted) throw downloadFailure(artifact, interruptedBefore(`${artifact.key} was downloaded`), files); + try { + const placed = await downloadArtifact(artifact, target, root, opts.signal); + saved.push(placed.path); + files.push({ key: artifact.key, path: placed.path, bytes: placed.bytes, sha256: placed.sha256, content_type: placed.contentType, status: "written", error: null, relinked: false }); + } catch (err) { + files.push({ key: artifact.key, path: target, bytes: 0, sha256: "", content_type: null, status: "failed", error: err instanceof Error ? err.message : String(err), relinked: false }); + throw downloadFailure(artifact, err, files); + } + } + // --- Finalisation: relink, refresh digests, publish the sidecar. Every step + // runs under the same failure handling as the transfers: whatever fails or is + // interrupted, the manifest still lists every committed file with the bytes + // actually on disk, and the sidecar is published like an asset (root re-proven + // at publication, symlink refused, exclusive — never truncating a file that + // appeared since the preflight). + const linkables = plan.map((p, i) => ({ key: p.artifact.key, path: resolvePath(saved[i]!), sourceName: basenameOfUrl(p.artifact.url) })); + let step: "relink" | "digest" | "sidecar" = "relink"; + let materialLinks: MaterialLinkReport | null = null; + try { + if (opts.signal?.aborted) throw interruptedBefore("the material references were relinked"); + materialLinks = await relinkMaterials(linkables, { signal: opts.signal }); + step = "digest"; + if (materialLinks) { + for (const path of materialLinks.rewritten) { + const entry = files.find((f) => resolvePath(f.path) === path); + if (!entry) continue; + const digest = fileDigest(path); + entry.bytes = digest.bytes; + entry.sha256 = digest.sha256; + entry.relinked = true; + } + for (const w of materialLinks.warnings) logger.warn(w.message); } + step = "sidecar"; + if (opts.signal?.aborted) throw interruptedBefore("the sidecar was written"); + writeMeta(task, resource, metadataPath, saved, root); + } catch (err) { + throw finalisationFailure(step, err, files, opts.signal); } - writeMeta(task, resource, metadataPath, saved); - return { savedFiles: saved, metadataPath }; + return { savedFiles: saved, metadataPath, materialLinks, files }; } function deriveFilename(artifact: Artifact): string { + if (artifact.filename) return artifact.filename; if (artifact.key.startsWith("model_")) { const ext = artifact.key.slice("model_".length); return `model.${ext}`; @@ -180,122 +898,59 @@ function deriveFilename(artifact: Artifact): string { return artifact.preferredExt ? `${stem}.${artifact.preferredExt}` : stem; } -function stripExt(path: string): string { - const ext = extname(path); - return ext ? path.slice(0, -ext.length) : path; +interface PlacedArtifact { + path: string; + bytes: number; + sha256: string; + contentType: string | null; } -async function downloadArtifact(artifact: Artifact, targetPath: string): Promise { - logger.debug(`GET ${artifact.url}`); - const resp = await fetch(artifact.url); - if (!resp.ok) { - throw new Error(`download failed for ${artifact.key} (${resp.status} ${resp.statusText})`); - } - if (!resp.body) throw new Error(`empty body for ${artifact.url}`); - - const contentType = resp.headers.get("content-type") ?? ""; - const actualExt = extFromContentType(contentType); +/** Fetch one artifact into place. Errors keep their class: a CliError from the fetch/publish core is rethrown untouched. */ +async function downloadArtifact(artifact: Artifact, targetPath: string, root: AuthorisedRoot, signal: AbortSignal | undefined): Promise { + logger.debug(`GET ${redact(artifact.url)}`); + const fetched = await fetchToTemp(artifact.url, targetPath, { signal }); + const actualExt = extFromContentType(fetched.contentType); const requestedExt = extname(targetPath).slice(1).toLowerCase(); - mkdirSync(dirname(targetPath), { recursive: true }); - - // No extension requested (e.g. unknown-format image artifact) — pick one - // from the content-type so the file has a reasonable suffix. - if (!requestedExt && actualExt) { - const finalPath = `${targetPath}.${actualExt}`; - await pipeline( - Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream), - createWriteStream(finalPath), - ); - return finalPath; - } - - // Fast path: extensions agree (or the server didn't specify one) — stream through. - if ( - !actualExt || - !requestedExt || - actualExt === requestedExt || - extEquivalent(actualExt, requestedExt) - ) { - await pipeline( - Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream), - createWriteStream(targetPath), - ); - return targetPath; - } - - // Extensions differ. Buffer the body so we can either transcode or rename. - const buffer = Buffer.from(await resp.arrayBuffer()); - - // Both sides are image formats sharp understands — convert. - if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { - logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); - await convertImage(buffer, requestedExt, targetPath); - return targetPath; - } - - // Can't convert safely — save with the true extension so the file isn't a lie. - const base = targetPath.slice(0, targetPath.length - (requestedExt.length + 1)); - const fallback = `${base}.${actualExt}`; - logger.warn( - `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${contentType || "?"}; ` + - `cannot transcode ${actualExt} → ${requestedExt}, saving as ${fallback}`, - ); - await pipeline(Readable.from(buffer), createWriteStream(fallback)); - return fallback; -} + let finalPath = targetPath; + let tmp = fetched.tmpPath; + let bytes = fetched.bytes; + let sha = fetched.sha256; -async function convertImage(buffer: Buffer, targetExt: string, targetPath: string): Promise { - const pipe = sharp(buffer); - switch (targetExt) { - case "jpg": - case "jpeg": - await pipe.jpeg({ quality: 92 }).toFile(targetPath); - return; - case "png": - await pipe.png().toFile(targetPath); - return; - case "webp": - await pipe.webp({ quality: 92 }).toFile(targetPath); - return; - case "gif": - await pipe.gif().toFile(targetPath); - return; - case "tiff": - case "tif": - await pipe.tiff().toFile(targetPath); - return; - case "avif": - await pipe.avif({ quality: 60 }).toFile(targetPath); - return; - default: - throw new Error(`unsupported image target extension: ${targetExt}`); + try { + if (!requestedExt && actualExt) { + // No extension requested (e.g. unknown-format image artifact) — pick one + // from the content-type so the file has a reasonable suffix. + finalPath = `${targetPath}.${actualExt}`; + } else if (actualExt && requestedExt && actualExt !== requestedExt && !extEquivalent(actualExt, requestedExt)) { + if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { + // Both sides are image formats sharp understands — convert. + logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); + const converted = `${tmp}.conv`; + await convertImage(readFileSync(tmp), requestedExt, converted); + removeQuietly(tmp); + tmp = converted; + const digest = fileDigest(converted); + bytes = digest.bytes; + sha = digest.sha256; + } else { + // Can't convert safely — save with the true extension so the file isn't a lie. + finalPath = `${targetPath.slice(0, targetPath.length - (requestedExt.length + 1))}.${actualExt}`; + logger.warn( + `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${fetched.contentType || "?"}; ` + + `cannot transcode ${actualExt} → ${requestedExt}, saving as ${finalPath}`, + ); + } + } + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before ${artifact.key} was published` }); + // The final name may differ from the pre-checked one; it still may not + // clobber anything and must stay under the output directory. + const resolved = resolveWithinRoot(finalPath, root, { label: "download target" }).path; + publishTempFile(tmp, resolved, { overwrite: false }); + } catch (err) { + removeQuietly(tmp); + throw err; } -} - -function extFromContentType(ct: string): string { - const base = ct.split(";")[0]!.trim().toLowerCase(); - const map: Record = { - "image/jpeg": "jpg", - "image/png": "png", - "image/webp": "webp", - "image/gif": "gif", - "image/tiff": "tiff", - "image/bmp": "bmp", - "model/gltf-binary": "glb", - "model/gltf+json": "gltf", - "model/obj": "obj", - "model/vnd.usdz+zip": "usdz", - "model/stl": "stl", - "model/3mf": "3mf", - "application/json": "json", - "video/mp4": "mp4", - }; - return map[base] ?? ""; -} - -function extEquivalent(a: string, b: string): boolean { - const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])]; - return groups.some((g) => g.has(a) && g.has(b)); + return { path: finalPath === targetPath ? targetPath : finalPath, bytes, sha256: sha, contentType: fetched.contentType }; } /** @@ -305,50 +960,60 @@ function extEquivalent(a: string, b: string): boolean { * - `-o some/dir/` (or any non-file path): write `meta.json` inside, * matching the directory-mode layout used elsewhere. * Other single-file extensions are rejected — the data is JSON, lying about - * the extension would be worse than a clear error. + * the extension would be worse than a clear error. Every path is proven inside + * the workspace (when given) before any directory or file is created. */ -function saveReportOnly(task: Task, outputPath: string, resource: string): DownloadResult { +function saveReportOnly(task: Task, outputPath: string, resource: string, workspaceRoot: AuthorisedRoot | null): DownloadResult { const singleFileMode = looksLikeFile(outputPath); + const abs = resolvePath(outputPath); if (singleFileMode) { - const ext = extname(outputPath).slice(1).toLowerCase(); + const ext = extname(abs).slice(1).toLowerCase(); if (ext !== "json") { throw new UsageError( `${resource} produces a JSON report — pass '-o .json' or a directory path (got '${outputPath}').`, ); } - if (existsSync(outputPath)) { + if (existsSync(abs)) { throw new UsageError( `refusing to overwrite existing file:\n ${outputPath}\n` + `(delete it or choose a different --output path to rerun)`, ); } - mkdirSync(dirname(outputPath) || ".", { recursive: true }); - writeFileSync( - outputPath, - `${JSON.stringify({ resource, task, downloaded_at: new Date().toISOString() }, null, 2)}\n`, - "utf8", - ); - return { savedFiles: [], metadataPath: outputPath }; + if (workspaceRoot) resolveWithinRoot(abs, workspaceRoot, { label: "report path" }); + writeJsonFile(abs, { resource, task, downloaded_at: new Date().toISOString() }, { overwrite: false, mode: 0o644 }); + return { savedFiles: [], metadataPath: abs, materialLinks: null, files: [] }; } - const metadataPath = join(outputPath, "meta.json"); + const metadataPath = join(abs, "meta.json"); if (existsSync(metadataPath)) { throw new UsageError( `refusing to overwrite existing file:\n ${metadataPath}\n` + `(delete it or choose a different --output path to rerun)`, ); } - mkdirSync(outputPath, { recursive: true }); - writeMeta(task, resource, metadataPath, []); - return { savedFiles: [], metadataPath }; + if (workspaceRoot) { + resolveWithinRoot(abs, workspaceRoot, { label: "output directory" }); + resolveWithinRoot(metadataPath, workspaceRoot, { label: "planned download path" }); + } + mkdirSync(abs, { recursive: true }); + writeMeta(task, resource, metadataPath, [], workspaceRoot ?? freezeRoot(abs, { label: "output directory" })); + return { savedFiles: [], metadataPath, materialLinks: null, files: [] }; } -function writeMeta(task: Task, resource: string, path: string, savedFiles: string[]): void { +/** + * The legacy sidecar (`meta.json` / `_meta.json`), published under the + * same rules as an asset: the real path is re-proven inside `root` at + * publication time (a symlink or file that appeared since the preflight is + * refused, never followed or truncated) and the write is exclusive and atomic. + */ +function writeMeta(task: Task, resource: string, path: string, savedFiles: string[], root: AuthorisedRoot): void { const meta = { resource, task, saved_files: savedFiles, downloaded_at: new Date().toISOString(), }; - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); + const resolved = resolveWithinRoot(path, root, { label: "sidecar path" }).path; + writeJsonFile(resolved, meta, { overwrite: false, mode: 0o644 }); } + +export { safeSegment as _safeSegmentForTests }; diff --git a/src/internal/env-file.ts b/src/internal/env-file.ts new file mode 100644 index 0000000..95c84aa --- /dev/null +++ b/src/internal/env-file.ts @@ -0,0 +1,101 @@ +/** + * Explicit `--api-key-file` support (a dotenv-style file read for MESHY_API_KEY only). + * + * Only MESHY_API_KEY is read. The file is parsed, never executed: no variable + * expansion, no command substitution, no `source`. Other keys are ignored and + * never change process configuration (PATH, NODE_OPTIONS, base URLs …). + * + * Grammar (one assignment per line): + * [export ]KEY=value # value may be 'single' or "double" quoted + * # comment # blank lines and comments are skipped + * An unquoted value ends at the first ` #` (whitespace then hash). Quoted + * values keep their text verbatim, including `#`, `$` and spaces. `${…}`, + * backticks and `$(…)` are not expanded — a key containing them is invalid. + */ + +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, resolve as resolvePath } from "node:path"; +import { CliError } from "./errors.js"; + +export const ENV_FILE_MAX_BYTES = 64 * 1024; +const TARGET_KEY = "MESHY_API_KEY"; + +export interface EnvFileResult { + path: string; + /** null when the file is valid but carries no MESHY_API_KEY assignment. */ + apiKey: string | null; + /** Other keys present in the file, reported so doctor can list them without values. */ + otherKeys: string[]; +} + +export function parseEnvFile(text: string, path = ""): { apiKey: string | null; otherKeys: string[] } { + let apiKey: string | null = null; + let seenTarget = false; + const otherKeys: string[] = []; + const lines = text.split(/\r?\n|\r/); + for (let i = 0; i < lines.length; i++) { + const rawLine = lines[i] ?? ""; + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (!m) { + throw invalid(path, `line ${i + 1} is not a KEY=value assignment`); + } + const key = m[1]!; + const rest = m[2] ?? ""; + let value: string; + if (rest.startsWith('"') || rest.startsWith("'")) { + const quote = rest[0]!; + const end = rest.indexOf(quote, 1); + if (end === -1) throw invalid(path, `line ${i + 1} has an unterminated ${quote} quote`); + value = rest.slice(1, end); + const trailing = rest.slice(end + 1).trim(); + if (trailing && !trailing.startsWith("#")) { + throw invalid(path, `line ${i + 1} has unexpected text after the closing quote`); + } + } else { + // Unquoted: comment starts at whitespace followed by '#'. + const hash = rest.search(/\s#/); + value = (hash === -1 ? rest : rest.slice(0, hash)).trim(); + } + if (key !== TARGET_KEY) { + otherKeys.push(key); + continue; + } + if (seenTarget) throw invalid(path, `${TARGET_KEY} is assigned more than once`); + seenTarget = true; + if (/\$\{|\$\(|`|\$[A-Za-z_]/.test(value)) { + throw new CliError({ + code: "auth", + message: `${path}: ${TARGET_KEY} contains shell expansion syntax; env files are never evaluated — write the literal key`, + }); + } + if (/\s/.test(value)) { + throw new CliError({ code: "auth", message: `${path}: ${TARGET_KEY} contains whitespace; quote the value or remove the stray text` }); + } + apiKey = value; + } + return { apiKey, otherKeys }; +} + +/** Resolve, check and parse an explicit env file. Throws for anything that is not a readable, valid file. */ +export function loadEnvFile(path: string, cwd: string = process.cwd()): EnvFileResult { + const abs = isAbsolute(path) ? path : resolvePath(cwd, path); + let st: ReturnType; + try { + st = statSync(abs); + } catch { + throw new CliError({ code: "usage", message: `--api-key-file: file not found: ${path}` }); + } + if (!st.isFile()) throw new CliError({ code: "usage", message: `--api-key-file: not a regular file: ${path}` }); + if (st.size > ENV_FILE_MAX_BYTES) { + throw new CliError({ code: "usage", message: `--api-key-file: ${path} is larger than ${ENV_FILE_MAX_BYTES} bytes; env files hold keys, not data` }); + } + const text = readFileSync(abs, "utf8"); + const parsed = parseEnvFile(text, path); + return { path: abs, apiKey: parsed.apiKey, otherKeys: parsed.otherKeys }; +} + +function invalid(path: string, detail: string): CliError { + return new CliError({ code: "usage", message: `--api-key-file: ${path}: ${detail}` }); +} diff --git a/src/internal/errors.ts b/src/internal/errors.ts index 484db0b..e65bb04 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -1,9 +1,16 @@ /** * Terminal error presentation — routes a caught error to stderr with a * human-friendly line, emits the structured payload when --format=json, and - * maps MeshyApiError codes to conventional exit codes for scripting. + * maps error classes to conventional exit codes for scripting. + * + * Two output schemas share this module: + * - legacy: the additive `{name,message,status,code,path,hint,docs}` payload + * that 0.2.0 consumers parse (toErrorPayload / reportError). + * - v1: `classifyError` yields the stable code/exit/http/recovery tuple that + * result.ts wraps into the `meshy.cli/v1` envelope. */ +import { CommanderError } from "commander"; import { MeshyApiError } from "../client/errors.js"; import { emit, type OutputFormat } from "./output.js"; @@ -18,8 +25,69 @@ export const EXIT_CODES = { NETWORK: 7, TIMED_OUT: 8, CREDIT: 9, + SUBMISSION_UNKNOWN: 10, + LOCAL_IO: 11, + CHECK_FAILED: 12, + CHECK_UNKNOWN: 13, + INTERRUPTED: 130, } as const; +/** Stable machine vocabulary for v1 `error.code`. */ +export type CliErrorCode = + | "usage" + | "auth" + | "validation" + | "not_found" + | "rate_limit" + | "network" + | "timed_out" + | "credit" + | "server" + | "task_failed" + | "submission_unknown" + | "local_io" + | "check_failed" + | "check_unknown" + | "interrupted" + | "protocol" + | "operation_conflict" + | "internal"; + +export const EXIT_FOR_CODE: Record = { + usage: EXIT_CODES.USAGE, + auth: EXIT_CODES.AUTH, + validation: EXIT_CODES.VALIDATION, + not_found: EXIT_CODES.NOT_FOUND, + rate_limit: EXIT_CODES.RATE_LIMIT, + network: EXIT_CODES.NETWORK, + timed_out: EXIT_CODES.TIMED_OUT, + credit: EXIT_CODES.CREDIT, + server: EXIT_CODES.GENERIC, + task_failed: EXIT_CODES.GENERIC, + submission_unknown: EXIT_CODES.SUBMISSION_UNKNOWN, + local_io: EXIT_CODES.LOCAL_IO, + check_failed: EXIT_CODES.CHECK_FAILED, + check_unknown: EXIT_CODES.CHECK_UNKNOWN, + interrupted: EXIT_CODES.INTERRUPTED, + protocol: EXIT_CODES.GENERIC, + operation_conflict: EXIT_CODES.USAGE, + internal: EXIT_CODES.GENERIC, +}; + +export interface ErrorRecovery { + /** What the caller should do: reconcile | retry | wait | login | none … */ + action: string; + /** Whether the CLI performed it. Always false in S1 — nothing is retried for the caller. */ + automatic: boolean; + /** A command the caller can run verbatim, when one is known. */ + command?: string; +} + +export interface Warning { + code: string; + message: string; +} + export class UsageError extends Error { constructor(message: string) { super(message); @@ -27,6 +95,49 @@ export class UsageError extends Error { } } +/** + * Structured failure with everything the v1 envelope needs. `result` carries + * whatever partial state must survive the failure (a task id after a journal + * write error, the files already downloaded, the last task seen by a stream). + */ +export class CliError extends Error { + readonly code: CliErrorCode; + readonly exitCode: number; + readonly httpStatus: number | null; + readonly retryable: boolean; + readonly recovery: ErrorRecovery | null; + readonly result: Record | null; + readonly warnings: Warning[]; + readonly hint?: string; + readonly details?: unknown; + + constructor(params: { + code: CliErrorCode; + message: string; + exitCode?: number; + httpStatus?: number | null; + retryable?: boolean; + recovery?: ErrorRecovery | null; + result?: Record | null; + warnings?: Warning[]; + hint?: string; + details?: unknown; + cause?: unknown; + }) { + super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined); + this.name = "CliError"; + this.code = params.code; + this.exitCode = params.exitCode ?? EXIT_FOR_CODE[params.code]; + this.httpStatus = params.httpStatus ?? null; + this.retryable = params.retryable ?? false; + this.recovery = params.recovery ?? null; + this.result = params.result ?? null; + this.warnings = params.warnings ?? []; + this.hint = params.hint; + this.details = params.details; + } +} + /** * An error that carries the command which fixes it. * @@ -100,8 +211,23 @@ function hintForApiError(err: MeshyApiError): { hint?: string; docs?: string } { } } +/** Commander error codes that mean "help/version was printed", not a failure. */ +export function isCommanderInformational(err: unknown): err is CommanderError { + return ( + err instanceof CommanderError && + (err.code === "commander.helpDisplayed" || + err.code === "commander.version" || + err.code === "commander.help" || + err.exitCode === 0) + ); +} + export function exitCodeFor(err: unknown): number { + if (err instanceof CliError) return err.exitCode; if (err instanceof UsageError) return EXIT_CODES.USAGE; + if (err instanceof CommanderError) { + return isCommanderInformational(err) ? err.exitCode : EXIT_CODES.USAGE; + } if (err instanceof HintedError) { // An explicit exitCode wins — the raiser knew the conventional code for // its own failure (a chained `make` step that times out is a timeout). @@ -128,6 +254,98 @@ export function exitCodeFor(err: unknown): number { return EXIT_CODES.GENERIC; } +export interface ClassifiedError { + code: CliErrorCode; + exitCode: number; + message: string; + httpStatus: number | null; + retryable: boolean; + recovery: ErrorRecovery | null; + hint?: string; + details?: unknown; + result: Record | null; + warnings: Warning[]; +} + +/** Map any thrown value onto the stable v1 error tuple. Never throws. */ +export function classifyError(err: unknown): ClassifiedError { + if (err instanceof CliError) { + return { + code: err.code, + exitCode: err.exitCode, + message: err.message, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + result: err.result, + warnings: err.warnings, + }; + } + if (err instanceof UsageError) { + return base("usage", err.message); + } + if (err instanceof CommanderError) { + return base("usage", err.message.replace(/^error:\s*/i, "").trim(), { details: { code: err.code } }); + } + if (err instanceof MeshyApiError) { + const code: CliErrorCode = err.code === "server" ? "server" : err.code; + const hints = hintForApiError(err); + const recovery: ErrorRecovery | null = + err.code === "auth" + ? { action: "login", automatic: false, command: "meshy auth login" } + : err.code === "rate_limit" + ? { action: "wait", automatic: false } + : err.code === "credit" + ? { action: "top_up", automatic: false, command: "meshy balance" } + : null; + return { + ...base(code, err.message, { httpStatus: err.status || null, details: { path: err.path, body: err.body } }), + recovery, + hint: hints.hint, + }; + } + if (err instanceof HintedError) { + let code: CliErrorCode = "internal"; + if (err.code === "unauthenticated") code = "auth"; + else if (err.code === "oauth_timeout" || err.code === "step_timeout") code = "timed_out"; + else if (err.code === "step_failed") code = "task_failed"; + const exitCode = err.exitCode ?? EXIT_FOR_CODE[code]; + return { + ...base(code, err.message, { details: { code: err.code, docs: err.docs } }), + exitCode, + hint: err.hint, + recovery: err.hint ? { action: "run_hint", automatic: false, command: err.hint } : null, + }; + } + if (err instanceof Error) { + if (err.name === "CredentialsFileError") return base("local_io", err.message); + if (err.name === "AbortError") return base("interrupted", err.message); + return base("internal", err.message, { details: { name: err.name } }); + } + return base("internal", String(err)); +} + +function base( + code: CliErrorCode, + message: string, + extra: { httpStatus?: number | null; details?: unknown } = {}, +): ClassifiedError { + return { + code, + exitCode: EXIT_FOR_CODE[code], + message, + httpStatus: extra.httpStatus ?? null, + retryable: false, + recovery: null, + details: extra.details, + result: null, + warnings: [], + }; +} + +/** Legacy stderr + payload reporter (unchanged shape for 0.2.0 consumers). */ export function reportError(err: unknown, format: OutputFormat): void { const payload = toErrorPayload(err); process.stderr.write(`error: ${payload.message}\n`); @@ -154,6 +372,27 @@ export function toErrorPayload(err: unknown): { name: string; message: string; [ if (err instanceof MeshyApiError) { return { ...err.toJSON(), ...hintForApiError(err), name: err.name, message: err.message }; } + if (err instanceof CliError) { + // Additive convenience fields: a legacy consumer reading an error after a + // create must find the accepted task without digging into `result`. + const result = err.result ?? undefined; + const taskId = typeof result?.["task_id"] === "string" ? (result["task_id"] as string) : undefined; + const submission = result?.["submission"] as { operation_id?: unknown } | undefined; + const operationId = typeof submission?.operation_id === "string" ? submission.operation_id : undefined; + return { + name: err.name, + message: err.message, + code: err.code, + ...(err.httpStatus !== null ? { status: err.httpStatus } : {}), + ...(err.hint ? { hint: err.hint } : {}), + ...(taskId ? { task_id: taskId } : {}), + ...(operationId ? { operation_id: operationId } : {}), + ...(result ? { result } : {}), + }; + } + if (err instanceof CommanderError) { + return { name: "UsageError", message: err.message.replace(/^error:\s*/i, "").trim(), code: err.code }; + } if (err instanceof HintedError) { // DeviceFlowError (a HintedError subclass) carries an oauthErrorCode field // that machine consumers need to distinguish access_denied / invalid_grant / diff --git a/src/internal/file-input.ts b/src/internal/file-input.ts index b832c09..01c6d1e 100644 --- a/src/internal/file-input.ts +++ b/src/internal/file-input.ts @@ -1,25 +1,57 @@ /** - * Resolve user-supplied image and model inputs into something Meshy accepts. + * Media inputs: turn what the user typed into what the API accepts. * - * Accepted inputs (both images and 3D models): - * - http(s) URLs — preflighted with HEAD so unreachable sources fail fast. - * - Local file paths (absolute or relative to cwd) — read, MIME-sniffed, - * and inlined as data URIs. Users don't have to host files themselves. + * Accepted for every declared media field (see resource-registry mediaFields): + * - http(s) URLs — preflighted with an unauthenticated HEAD (GET fallback, + * aborted right after the headers) so unreachable sources fail before a + * billable POST. The API credential is never attached to a preflight. + * - data: URIs — validated (base64, sane MIME, size cap) and passed through. + * - Local file paths (absolute or relative to cwd) — must be regular files + * under the size cap and of an accepted format; read, MIME-sniffed and + * inlined as data URIs. * - * Base64 data URIs on the command line aren't accepted — the whole point of - * local-path support is to spare the user from encoding them manually. + * Normalisation runs on the *final merged payload* (defaults < --data < + * flags), so a path inside `--data '{"texture_image_url":"./tex.png"}'` is + * handled exactly like the typed flag. Only fields the resource declares are + * touched — no string that merely looks like a path is ever read. */ import { readFileSync, statSync } from "node:fs"; import { extname, isAbsolute, resolve as resolvePath } from "node:path"; +import type { MediaField, MediaKind } from "../client/resource-registry.js"; import { UsageError } from "./errors.js"; import { logger } from "./logger.js"; +import { USER_AGENT } from "./user-agent.js"; -/** camelCase option names that hold image inputs. */ +export const DEFAULT_MEDIA_LIMITS = { + /** 50 MiB — an engineering default, not a Meshy product limit. */ + maxFileBytes: 50 * 1024 * 1024, + preflightTimeoutMs: 10_000, +} as const; + +export type MediaLimits = { maxFileBytes: number; preflightTimeoutMs: number }; + +export interface NormalizeOptions { + cwd?: string; + limits?: Partial; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +export interface NormalizedMedia { + field: string; + index: number | null; + source: "url" | "data-uri" | "local-file"; + mime: string | null; + bytes: number | null; +} + +/** camelCase option names that hold image inputs (legacy flag-level API). */ export const IMAGE_FIELDS = { scalar: [ "imageUrl", // image-to-3d "imageStyleUrl", // retexture + "textureImageUrl", // text-to-3d refine / rigging ] as const, list: [ "imageUrls", // multi-image-to-3d @@ -28,32 +60,30 @@ export const IMAGE_FIELDS = { ] as const, }; -/** camelCase option names that hold 3D model inputs. */ +/** camelCase option names that hold 3D model inputs (legacy flag-level API). */ export const MODEL_FIELDS = { - scalar: ["modelUrl"] as const, // remesh, retexture, rigging + scalar: ["modelUrl"] as const, // remesh, retexture, rigging, uv-unwrap … list: [] as const, }; -export async function resolveImageFields(opts: Record): Promise { - await resolveGroup(opts, IMAGE_FIELDS, detectImageMime, "image"); +export async function resolveImageFields(opts: Record, o: NormalizeOptions = {}): Promise { + await resolveGroup(opts, IMAGE_FIELDS, "image", o); } -export async function resolveModelFields(opts: Record): Promise { - await resolveGroup(opts, MODEL_FIELDS, detectModelMime, "3d model"); +export async function resolveModelFields(opts: Record, o: NormalizeOptions = {}): Promise { + await resolveGroup(opts, MODEL_FIELDS, "model", o); } -type MimeDetector = (buffer: Buffer, path: string) => string; - async function resolveGroup( opts: Record, fields: { scalar: readonly string[]; list: readonly string[] }, - detect: MimeDetector, - kind: string, + kind: MediaKind, + o: NormalizeOptions, ): Promise { for (const key of fields.scalar) { const v = opts[key]; if (typeof v === "string" && v) { - opts[key] = await resolveOne(v, `--${toFlag(key)}`, detect, kind); + opts[key] = (await resolveOne(v, `--${toFlag(key)}`, kind, undefined, o)).value; } } for (const key of fields.list) { @@ -62,7 +92,7 @@ async function resolveGroup( const resolved: string[] = []; for (const entry of v) { if (typeof entry === "string" && entry) { - resolved.push(await resolveOne(entry, `--${toFlag(key)}`, detect, kind)); + resolved.push((await resolveOne(entry, `--${toFlag(key)}`, kind, undefined, o)).value); } } opts[key] = resolved; @@ -74,35 +104,136 @@ function toFlag(camel: string): string { return camel.replace(/([A-Z])/g, "-$1").toLowerCase(); } +function flagForField(path: string): string { + return `--${path.replace(/_/g, "-")}`; +} + +/** + * Normalise every declared media field of a final payload in place-free + * fashion (a new object is returned). Non-string values are left untouched + * and reported by the endpoint schema instead. + */ +export async function normalizeMediaPayload( + payload: Record, + fields: readonly MediaField[], + o: NormalizeOptions = {}, +): Promise<{ payload: Record; media: NormalizedMedia[] }> { + const out: Record = { ...payload }; + const media: NormalizedMedia[] = []; + for (const field of fields) { + const value = out[field.path]; + if (value === undefined || value === null) continue; + const label = flagForField(field.path); + if (field.many) { + if (!Array.isArray(value)) continue; + const resolved: unknown[] = []; + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + if (typeof entry !== "string" || !entry) { + resolved.push(entry); + continue; + } + const r = await resolveOne(entry, `${label}[${i}]`, field.kind, field.formats, o); + resolved.push(r.value); + media.push({ field: field.path, index: i, source: r.source, mime: r.mime, bytes: r.bytes }); + } + out[field.path] = resolved; + } else if (typeof value === "string" && value) { + const r = await resolveOne(value, label, field.kind, field.formats, o); + out[field.path] = r.value; + media.push({ field: field.path, index: null, source: r.source, mime: r.mime, bytes: r.bytes }); + } + } + return { payload: out, media }; +} + +interface Resolved { + value: string; + source: NormalizedMedia["source"]; + mime: string | null; + bytes: number | null; +} + async function resolveOne( input: string, flagLabel: string, - detect: MimeDetector, - kind: string, -): Promise { - if (input.startsWith("data:")) { - throw new UsageError( - `${flagLabel}: data: URIs aren't accepted on the command line — pass a local file path instead`, - ); + kind: MediaKind, + formats: readonly string[] | undefined, + o: NormalizeOptions, +): Promise { + const limits: MediaLimits = { ...DEFAULT_MEDIA_LIMITS, ...(o.limits ?? {}) }; + if (/^data:/i.test(input)) { + return validateDataUri(input, flagLabel, kind, formats, limits); } if (/^https?:\/\//i.test(input)) { - await preflightUrl(input, flagLabel); - return input; + await preflightUrl(input, flagLabel, limits, o); + return { value: input, source: "url", mime: null, bytes: null }; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) { + throw new UsageError(`${flagLabel}: only http(s) URLs, data: URIs and local file paths are accepted (got ${input.split(":")[0]}: URL)`); } - return loadLocalFile(input, flagLabel, detect, kind); + return loadLocalFile(input, flagLabel, kind, formats, limits, o.cwd ?? process.cwd()); } -async function preflightUrl(url: string, flagLabel: string): Promise { +const DATA_URI_RE = /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+)((?:;[a-z0-9-]+=[^;,]*)*)(;base64)?,(.*)$/is; + +function validateDataUri( + input: string, + flagLabel: string, + kind: MediaKind, + formats: readonly string[] | undefined, + limits: MediaLimits, +): Resolved { + const m = DATA_URI_RE.exec(input); + if (!m) throw new UsageError(`${flagLabel}: malformed data: URI (expected data:;base64,)`); + const mime = m[1]!.toLowerCase(); + const isBase64 = Boolean(m[3]); + const body = m[4] ?? ""; + if (!isBase64) throw new UsageError(`${flagLabel}: data: URI must be base64-encoded`); + if (!/^[A-Za-z0-9+/=\s]*$/.test(body)) throw new UsageError(`${flagLabel}: data: URI payload is not valid base64`); + const bytes = Math.floor((body.replace(/\s+/g, "").length * 3) / 4); + if (bytes > limits.maxFileBytes) { + throw new UsageError(`${flagLabel}: inline data is ${bytes} bytes, above the ${limits.maxFileBytes}-byte limit`); + } + if (kind === "image" && !mime.startsWith("image/")) { + throw new UsageError(`${flagLabel}: expected an image data: URI, got ${mime}`); + } + if (kind === "model" && !(mime.startsWith("model/") || mime === "application/octet-stream")) { + throw new UsageError(`${flagLabel}: expected a 3D-model data: URI (model/* or application/octet-stream), got ${mime}`); + } + if (formats && formats.length > 0) { + const ext = extForMime(mime); + if (!ext || !formats.includes(ext)) { + throw new UsageError(`${flagLabel}: this field accepts ${formats.join("/")} only (data: URI is ${mime})`); + } + } + return { value: input, source: "data-uri", mime, bytes }; +} + +async function preflightUrl(url: string, flagLabel: string, limits: MediaLimits, o: NormalizeOptions): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`${flagLabel}: invalid URL ${url}`); + } + if (parsed.username || parsed.password) throw new UsageError(`${flagLabel}: URLs with embedded credentials are not accepted`); + const fetchImpl = o.fetchImpl ?? globalThis.fetch; + const headers = { "User-Agent": USER_AGENT }; + const timeout = AbortSignal.timeout(limits.preflightTimeoutMs); + const signal = o.signal ? AbortSignal.any([timeout, o.signal]) : timeout; let resp: Response; try { - resp = await fetch(url, { method: "HEAD", redirect: "follow" }); + resp = await fetchImpl(url, { method: "HEAD", redirect: "follow", headers, signal }); if (resp.status === 405 || resp.status === 501) { const controller = new AbortController(); - resp = await fetch(url, { method: "GET", signal: controller.signal, redirect: "follow" }); + const getSignal = AbortSignal.any([controller.signal, signal]); + resp = await fetchImpl(url, { method: "GET", redirect: "follow", headers, signal: getSignal }); controller.abort(); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); + if (timeout.aborted) throw new UsageError(`${flagLabel}: preflight of ${url} timed out after ${limits.preflightTimeoutMs}ms`); throw new UsageError(`${flagLabel}: cannot reach ${url} (${msg})`); } if (!resp.ok) { @@ -114,10 +245,12 @@ async function preflightUrl(url: string, flagLabel: string): Promise { function loadLocalFile( input: string, flagLabel: string, - detect: MimeDetector, - kind: string, -): string { - const absPath = isAbsolute(input) ? input : resolvePath(process.cwd(), input); + kind: MediaKind, + formats: readonly string[] | undefined, + limits: MediaLimits, + cwd: string, +): Resolved { + const absPath = isAbsolute(input) ? input : resolvePath(cwd, input); let stat: ReturnType; try { stat = statSync(absPath); @@ -127,16 +260,43 @@ function loadLocalFile( if (!stat.isFile()) { throw new UsageError(`${flagLabel}: not a regular file: ${input}`); } + if (stat.size > limits.maxFileBytes) { + throw new UsageError(`${flagLabel}: ${input} is ${stat.size} bytes, above the ${limits.maxFileBytes}-byte limit`); + } const buffer = readFileSync(absPath); - const mime = detect(buffer, absPath); + const mime = kind === "image" ? detectImageMime(buffer, absPath) : detectModelMime(buffer, absPath); if (!mime) { - throw new UsageError(`${flagLabel}: could not detect ${kind} MIME type for ${absPath}`); + throw new UsageError(`${flagLabel}: could not detect ${kind === "image" ? "image" : "3d model"} MIME type for ${absPath}`); + } + if (formats && formats.length > 0) { + const ext = extForMime(mime) ?? extname(absPath).slice(1).toLowerCase(); + if (!formats.includes(ext)) { + throw new UsageError(`${flagLabel}: this field accepts ${formats.join("/")} only (got ${ext || "unknown"})`); + } } logger.debug(`${flagLabel} inlined ${absPath} as ${mime} data URI (${buffer.length} bytes)`); - return `data:${mime};base64,${buffer.toString("base64")}`; + return { value: `data:${mime};base64,${buffer.toString("base64")}`, source: "local-file", mime, bytes: buffer.length }; +} + +function extForMime(mime: string): string | null { + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/bmp": "bmp", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/obj": "obj", + "model/stl": "stl", + "model/vnd.usdz+zip": "usdz", + "model/3mf": "3mf", + "application/octet-stream": "fbx", + }; + return map[mime] ?? null; } -function detectImageMime(buffer: Buffer, path: string): string { +export function detectImageMime(buffer: Buffer, path: string): string { if (buffer.length >= 12) { if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; if ( @@ -164,7 +324,7 @@ function detectImageMime(buffer: Buffer, path: string): string { return map[ext] ?? ""; } -function detectModelMime(buffer: Buffer, path: string): string { +export function detectModelMime(buffer: Buffer, path: string): string { // GLB: magic "glTF" at offset 0 if ( buffer.length >= 4 && diff --git a/src/internal/global-options.ts b/src/internal/global-options.ts index 95f872b..fc6481d 100644 --- a/src/internal/global-options.ts +++ b/src/internal/global-options.ts @@ -3,10 +3,11 @@ * regardless of position in argv (e.g. `meshy-cli balance --format pretty` * as well as `meshy-cli --format pretty balance`). * - * We declare them once on the root with visible help + defaults, and mirror - * them (hidden, no defaults) onto every descendant command. `optsWithGlobals` - * then merges any set value — a flag on the innermost command wins, and - * unset leaf options fall through to the parent's value or its default. + * Commander parses the root command's known options wherever they appear in + * argv (positional options are disabled), so a flag declared here is consumed + * by the root even when typed after the subcommand. The hidden mirrors on + * every descendant keep `optsWithGlobals()` uniform and make the flag visible + * to ` --help` walkers. */ import { Command, Option } from "commander"; @@ -17,13 +18,40 @@ const FACTORIES: OptionFactory[] = [ () => new Option("--api-key ", "Meshy API key (overrides MESHY_API_KEY)"), () => new Option("--base-url-v1 ", "override v1 base URL"), () => new Option("--base-url-v2 ", "override v2 base URL"), + () => + new Option( + "--base-url-creative-lab ", + "override the Creative Lab base URL (default: /openapi/creative-lab)", + ), () => new Option("--format ", "output format").choices(["json", "pretty", "ndjson"]), () => new Option("--json", "output as JSON (alias for --format json)"), + () => + new Option( + "--output-schema ", + "stdout data model: legacy (0.2.0-compatible, default for existing commands) | v1 (stable envelope; default for new commands)", + ).choices(["legacy", "v1"]), () => new Option( "-o, --output ", "download artifacts + write meta.json; switches stdout to a status report", ), + () => + new Option( + "--api-key-file ", + "read MESHY_API_KEY from this dotenv-style file (only that key is read; parsed, never executed; no .env auto-discovery)", + ), + // Node.js itself scans the whole argv for `--env-file` — even after the + // script name — loads the entire file into process.env (NODE_OPTIONS + // included) and exits 9 when it is missing. The flag therefore cannot be + // offered safely; it is registered only so a habitual `--env-file` gets an + // explanation instead of a silent, Node-side environment load. + () => new Option("--env-file ", "(unsupported) use --api-key-file").hideHelp(), + () => + new Option( + "--workspace ", + "restrict every file written by local tools and downloads to this directory", + ), + () => new Option("--no-update-check", "never query npm for a newer version in this process"), () => new Option("-v, --verbose", "debug logging"), () => new Option("--log-level ", "log level").choices([ @@ -57,6 +85,10 @@ export function mirrorGlobalOptionsToDescendants(root: Command): void { }); } +export function walkCommands(cmd: Command, visit: (c: Command) => void): void { + walk(cmd, visit); +} + function walk(cmd: Command, visit: (c: Command) => void): void { visit(cmd); for (const sub of cmd.commands) walk(sub, visit); diff --git a/src/internal/inspect.ts b/src/internal/inspect.ts new file mode 100644 index 0000000..26fc824 --- /dev/null +++ b/src/internal/inspect.ts @@ -0,0 +1,139 @@ +/** + * Face-count gate — the verdict logic behind `meshy inspect faces`. + * + * The legacy Skill's check-faces script defaulted a missing face_count to 0 and + * printed a passing line for a model it never measured. Nothing here fabricates + * a number: only a finite, non-negative integer that the task actually carries + * is "known"; everything else is `unknown`, which the command turns into exit + * 13 rather than a pass (D-009). Strings are never parsed into numbers — a + * `"1234"` that the server never meant as a count must not become one here. + * + * The gate answers exactly one question — is face_count <= the caller's limit? + * It knows nothing about rigging eligibility (mesh shape, textures, humanoid + * proportions), so nothing in this module says "rig-ready". When it fails it + * can describe a remesh that would help; it never runs one. + */ + +import { isTerminalStatus } from "../client/types.js"; + +export type FaceVerdictKind = "pass" | "fail" | "unknown"; + +export interface FaceVerdict { + /** The measured value when known, null for `unknown` (never a substituted 0). */ + face_count: number | null; + limit: number; + comparison: "lte"; + verdict: FaceVerdictKind; + /** Why the verdict is `fail` or `unknown`; null for `pass`. */ + reason: string | null; +} + +export interface FaceCountSource { + /** The raw value exactly as the task carries it (undefined when absent). */ + value: unknown; + /** Where the value came from; `none` when the task has no top-level face_count key. */ + source: "face_count" | "none"; + status: string | null; +} + +export interface RemeshSuggestion { + description: string; + /** A command the caller may run; null when it cannot be built safely. */ + command: string | null; + /** Always false: inspect never submits a billable task. */ + executed: false; +} + +/** The remesh endpoint's accepted target_polycount range (see cmd/remesh.ts). */ +export const REMESH_POLYCOUNT_RANGE = { min: 100, max: 300_000 } as const; + +/** Task ids are echoed into a shell command only when they are plain tokens. */ +const SAFE_TASK_ID = /^[A-Za-z0-9._:-]+$/; + +function assertLimit(limit: number): void { + if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) { + throw new RangeError(`face-count limit must be a positive integer (got ${String(limit)})`); + } +} + +function describe(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +/** + * Judge a raw face_count against `limit`. Only a finite, non-negative integer is + * a known count; `<= limit` passes, anything larger fails, everything else is + * unknown with a reason that names what was wrong. + */ +export function judgeFaceCount(raw: unknown, limit: number): FaceVerdict { + assertLimit(limit); + const unknown = (reason: string): FaceVerdict => ({ face_count: null, limit, comparison: "lte", verdict: "unknown", reason }); + if (raw === undefined) return unknown("face_count missing"); + if (raw === null) return unknown("face_count is null"); + if (typeof raw === "string") { + const shown = raw.length > 40 ? `${raw.slice(0, 40)}…` : raw; + return unknown(`face_count is a string, not a number (${JSON.stringify(shown)} is not parsed)`); + } + if (typeof raw !== "number") return unknown(`face_count is not a number (got ${describe(raw)})`); + if (!Number.isFinite(raw)) return unknown(`face_count is not a finite number (got ${String(raw)})`); + if (!Number.isInteger(raw) || raw < 0) return unknown(`face_count is not a non-negative integer (got ${raw})`); + if (raw <= limit) return { face_count: raw, limit, comparison: "lte", verdict: "pass", reason: null }; + return { + face_count: raw, + limit, + comparison: "lte", + verdict: "fail", + reason: `face_count ${raw} exceeds the limit ${limit} by ${raw - limit}`, + }; +} + +/** + * Read the top-level `face_count` of a task object. Nothing is derived from + * other fields and nothing is defaulted: an absent key is reported as absent. + */ +export function faceCountFromTask(task: Record): FaceCountSource { + const status = typeof task["status"] === "string" ? (task["status"] as string) : null; + if (Object.prototype.hasOwnProperty.call(task, "face_count")) { + return { value: task["face_count"], source: "face_count", status }; + } + return { value: undefined, source: "none", status }; +} + +/** + * Judge a whole task: the top-level face_count against `limit`. A task that + * has not finished and carries no count is unknown *because it is still + * running* — the reason says so instead of implying the field will never come. + */ +export function judgeTask(task: Record, limit: number): FaceVerdict { + const { value, status } = faceCountFromTask(task); + const verdict = judgeFaceCount(value, limit); + if (verdict.verdict === "unknown" && (value === undefined || value === null) && status !== null && !isTerminalStatus(status)) { + return { ...verdict, reason: `task is ${status}; no face count yet` }; + } + return verdict; +} + +/** + * Describe — never execute — the remesh that would bring a model under `limit`. + * The target is clamped to the range the remesh endpoint accepts. + */ +export function remeshSuggestion(taskId: string | null, limit: number): RemeshSuggestion { + const target = Math.min(Math.max(Math.trunc(limit), REMESH_POLYCOUNT_RANGE.min), REMESH_POLYCOUNT_RANGE.max); + const flags = `--target-polycount ${target} --output-schema v1`; + if (taskId && SAFE_TASK_ID.test(taskId)) { + const command = `meshy remesh create --input-task-id ${taskId} ${flags}`; + return { + description: `Reduce the polycount with a remesh task targeting ${target} faces (billable; not executed by inspect — run it yourself): ${command}`, + command, + executed: false, + }; + } + const why = taskId ? "the task id contains characters that are not safe to echo into a command" : "the source task id is unknown"; + return { + description: `Reduce the polycount with a remesh task targeting ${target} faces (billable; not executed by inspect). ${why}; run: meshy remesh create --model-url ${flags}`, + command: null, + executed: false, + }; +} diff --git a/src/internal/lock.ts b/src/internal/lock.ts new file mode 100644 index 0000000..2053051 --- /dev/null +++ b/src/internal/lock.ts @@ -0,0 +1,71 @@ +/** + * Cross-process exclusive lock on a directory-scoped lock file. + * + * O_EXCL creation is the atomic primitive (Node exposes no flock). A lock + * older than `staleMs` is treated as abandoned by a crashed process and + * broken. Waiting is a blocking sleep because every store that uses this is + * synchronous by design; the wait is bounded and a timeout is a structured + * error rather than a hang. + */ + +import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; +import { CliError } from "./errors.js"; + +export interface LockOptions { + timeoutMs?: number; + staleMs?: number; + /** Test hook to observe contention. */ + onWait?: () => void; +} + +export const DEFAULT_LOCK_TIMEOUT_MS = 10_000; +export const DEFAULT_LOCK_STALE_MS = 60_000; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +export function withFileLock(lockPath: string, fn: () => T, opts: LockOptions = {}): T { + const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; + const staleMs = opts.staleMs ?? DEFAULT_LOCK_STALE_MS; + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + const deadline = Date.now() + timeoutMs; + let fd: number; + for (;;) { + try { + fd = openSync(lockPath, "wx", 0o600); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + try { + if (Date.now() - statSync(lockPath).mtimeMs > staleMs) { + unlinkSync(lockPath); + continue; + } + } catch { + // Holder released between open and stat — retry immediately. + continue; + } + if (Date.now() >= deadline) { + throw new CliError({ + code: "local_io", + message: `timed out after ${timeoutMs}ms waiting for the lock ${lockPath}. If no other meshy process is running, delete that file.`, + recovery: { action: "retry", automatic: false }, + }); + } + opts.onWait?.(); + sleepSync(25); + } + } + try { + return fn(); + } finally { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch { + /* already gone */ + } + } +} diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts new file mode 100644 index 0000000..dea72c9 --- /dev/null +++ b/src/internal/material-links.ts @@ -0,0 +1,644 @@ +/** + * Material relinking for downloaded OBJ sets. + * + * Meshy serves an OBJ, its MTL and the textures as separate URLs and the CLI + * saves them under stable names (model.obj, model.mtl, texture_0_base_color.png + * …). The OBJ, however, names its MTL the way the server knew it (`mtllib + * box.mtl`) and the MTL names its textures the same way, so a faithfully + * downloaded set can still be unloadable. Once every file of a set has landed, + * this module rewrites those references to the names actually on disk and + * reports every link it resolved — and every one it could not. Only the two + * text files the CLI itself just wrote are touched; nothing is renamed, the + * rewritten paths are listed, and their digests are re-taken by the caller. + * + * A texture reference is resolved only when exactly one downloaded texture + * matches, in this order: the name the server served a texture under (the + * URL's last segment — `body.png` for `…/body.png`), which is the only evidence + * of *which* image the MTL meant; a saved file of that name, but only when it + * is not known to come from a different source (the CLI's generated names + * `texture__` can collide with a server-side name of another + * texture — that is an ambiguity, not a match); the same name ignoring + * extension and directories; a channel word inside the referenced name + * (…_normal.png); the channel implied by the MTL key (map_Kd → base color); + * finally "the only texture there is" when the MTL has exactly one distinct + * reference. Several candidates for the same rule is an *ambiguity*: the + * reference stays as written, the candidates are listed and the report is + * `incomplete` — the CLI never picks the first of several material groups' + * textures. + * + * The first three rules are *identity* evidence; the channel and only-texture + * rules are *heuristics*. Resolution therefore runs in two passes over the + * whole MTL: every distinct reference is resolved on its own first, then the + * heuristic results are checked against each other — a texture that several + * different references would fall back to (whichever channel rule each one + * took to get there, in whichever order they appear) serves none of them, and a + * reference that different keys would send to different textures is not + * rewritten either. Two material groups collapsing onto one image without + * evidence that they name the same file is exactly the guess this module must + * not make; a reference with identity evidence keeps its texture regardless. + * Unresolved references are reported the same way. The whole pass is + * cooperative: an abort signal stops it before the next read, write or + * publication, leaving no temp file behind. + */ + +import { createHash } from "node:crypto"; +import { closeSync, createReadStream, createWriteStream, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs"; +import { once } from "node:events"; +import { basename } from "node:path"; +import { finished } from "node:stream/promises"; +import { StringDecoder } from "node:string_decoder"; +import { publishTempFile, tempPathFor } from "./atomic-file.js"; +import { CliError, type Warning } from "./errors.js"; +import { warning } from "./result.js"; + +export interface LinkableFile { + /** Stable asset key (`model.obj`, `model_mtl`, `texture.0.base_color`, `texture_0_normal` …). */ + key: string; + /** Absolute path as written. */ + path: string; + /** The file name the server served the asset under (last URL path segment), when known. */ + sourceName?: string | null; +} + +export type LinkMethod = + | "unchanged" + | "exact" + | "source_name" + | "source_stem" + | "channel_in_name" + | "channel_of_key" + | "only_texture" + | "downloaded_mtl" + | "ambiguous" + | "unresolved"; + +export interface ReferenceLink { + /** 1-based line in the file that carried the reference. */ + line: number; + /** `newmtl` group the map belongs to (MTL only). */ + material: string | null; + /** The reference as written before relinking. */ + reference: string; + /** Saved file name the reference now points at, or null when it stays as written. */ + resolved_to: string | null; + /** How the link was decided. */ + method: LinkMethod; + /** Saved names that matched when the reference was ambiguous. */ + candidates?: string[]; + /** Why an apparently matching saved name was not accepted (identity conflict). */ + note?: string; +} + +export interface TextureDescriptor { + key: string; + /** Saved file name. */ + name: string; + /** Name the server served it under, when known. */ + source_name: string | null; + /** `texture_urls` set index (material group) the texture came from. */ + set: number | null; + /** Canonical channel (basecolor, normal, …) from the asset key. */ + channel: string | null; +} + +export interface MaterialLinkReport { + obj: string; + mtl: string | null; + textures: TextureDescriptor[]; + mtllib: ReferenceLink[]; + texture_maps: ReferenceLink[]; + /** Absolute paths whose content was rewritten. */ + rewritten: string[]; + /** `complete` when every reference points at a downloaded file; `incomplete` when any stayed as written. */ + status: "complete" | "incomplete"; + warnings: Warning[]; +} + +const WS = /\s+/; +const TEXTURE_KEY_RE = /^(?:map_[A-Za-z0-9_]+|bump|disp|decal|refl|norm)$/i; +/** MTL files larger than this are not what Meshy produces; leave them alone. */ +const MAX_MTL_BYTES = 16 * 1024 * 1024; + +const CHANNEL_SYNONYMS: Record = { + basecolor: "basecolor", + albedo: "basecolor", + diffuse: "basecolor", + color: "basecolor", + colour: "basecolor", + metallic: "metallic", + metalness: "metallic", + metal: "metallic", + roughness: "roughness", + rough: "roughness", + normal: "normal", + normals: "normal", + nrm: "normal", + bump: "normal", + emissive: "emissive", + emission: "emissive", + emit: "emissive", + occlusion: "occlusion", + ao: "occlusion", + ambientocclusion: "occlusion", + opacity: "opacity", + alpha: "opacity", + transparency: "opacity", + displacement: "displacement", + height: "displacement", + disp: "displacement", + specular: "specular", + spec: "specular", +}; + +const MAP_KEY_CHANNEL: Record = { + map_kd: "basecolor", + map_ka: "basecolor", + map_pm: "metallic", + map_pr: "roughness", + norm: "normal", + map_bump: "normal", + bump: "normal", + map_kn: "normal", + map_ke: "emissive", + map_d: "opacity", + disp: "displacement", + map_ks: "specular", + map_ao: "occlusion", +}; + +function canonicalChannel(raw: string): string | null { + const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, ""); + return CHANNEL_SYNONYMS[compact] ?? null; +} + +/** Set index and channel encoded in an asset key: `texture.0.base_color` / `texture_0_base_color`. */ +export function describeTextureKey(key: string): { set: number | null; channel: string | null } { + const m = /^texture[._](\d+)[._](.+)$/.exec(key); + if (!m) return { set: null, channel: null }; + return { set: Number(m[1]), channel: canonicalChannel(m[2]!) }; +} + +/** @deprecated kept for callers of the round-1 API. */ +export function channelOfTextureKey(key: string): string | null { + return describeTextureKey(key).channel; +} + +/** Channel word inside a referenced file name (`texture_normal.png`, `Body_BaseColor.jpg`). */ +export function channelInFileName(name: string): string | null { + const stem = basename(name).replace(/\.[A-Za-z0-9]+$/, ""); + const tokens = stem.split(/[^A-Za-z0-9]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean); + for (let i = 0; i < tokens.length; i++) { + const pair = i + 1 < tokens.length ? canonicalChannel(`${tokens[i]}${tokens[i + 1]}`) : null; + if (pair) return pair; + const single = canonicalChannel(tokens[i]!); + if (single) return single; + } + const compact = stem.toLowerCase().replace(/[^a-z0-9]/g, ""); + for (const word of Object.keys(CHANNEL_SYNONYMS).sort((a, b) => b.length - a.length)) { + if (word.length >= 5 && compact.includes(word)) return CHANNEL_SYNONYMS[word]!; + } + return null; +} + +function keywordOf(t: string): string { + const m = WS.exec(t); + return m ? t.slice(0, m.index) : t; +} + +function stemOf(name: string): string { + return name.replace(/\.[A-Za-z0-9]+$/, "").toLowerCase(); +} + +function looksBinary(path: string): boolean { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(512); + const n = readSync(fd, buf, 0, 512, 0); + const head = buf.subarray(0, n); + if (n >= 2 && head[0] === 0x50 && head[1] === 0x4b) return true; // PK: a ZIP bundle, not a text OBJ + return head.includes(0); + } finally { + closeSync(fd); + } +} + +/** sha256 + size of a file, for manifests that must describe what is actually on disk. */ +export function fileDigest(path: string): { bytes: number; sha256: string } { + const data = readFileSync(path); + return { bytes: data.length, sha256: createHash("sha256").update(data).digest("hex") }; +} + +/** + * Rewrite `path` line by line through a temp file in the same directory. The + * callback returns the replacement line (without its terminator) or null to + * keep the line. Returns true when the file was actually replaced. + */ +async function rewriteLines(path: string, transform: (line: string, lineNo: number) => string | null, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before ${basename(path)} was rewritten` }); + const tmp = tempPathFor(path); + const out = createWriteStream(tmp, { flags: "wx" }); + let writeError: Error | null = null; + out.on("error", (err) => { + writeError = err; + }); + let changed = false; + let buf = ""; + const flush = async (): Promise => { + if (writeError) throw writeError; + if (buf.length === 0) return; + const chunk = buf; + buf = ""; + if (!out.write(chunk)) await once(out, "drain"); + if (writeError) throw writeError; + }; + const decoder = new StringDecoder("utf8"); + let carry = ""; + let lineNo = 0; + const emitLine = (text: string, eol: string): void => { + lineNo += 1; + const next = transform(text, lineNo); + if (next !== null && next !== text) { + changed = true; + buf += next + eol; + } else { + buf += text + eol; + } + }; + try { + const stream = createReadStream(path, { highWaterMark: 256 * 1024 }); + for await (const chunk of stream) { + if (signal?.aborted) { + stream.destroy(); + throw new CliError({ code: "interrupted", message: `interrupted while rewriting ${basename(path)}` }); + } + carry += decoder.write(chunk as Buffer); + let start = 0; + let nl: number; + while ((nl = carry.indexOf("\n", start)) !== -1) { + let end = nl; + let eol = "\n"; + if (end > start && carry.charCodeAt(end - 1) === 13) { + end -= 1; + eol = "\r\n"; + } + emitLine(carry.slice(start, end), eol); + start = nl + 1; + } + carry = start > 0 ? carry.slice(start) : carry; + await flush(); + } + carry += decoder.end(); + if (carry.length > 0) emitLine(carry, ""); + await flush(); + out.end(); + await finished(out); + } catch (err) { + out.destroy(); + try { + unlinkSync(tmp); + } catch { + /* gone */ + } + if (err instanceof CliError) throw err; + throw new CliError({ code: "local_io", message: `failed to rewrite ${path}: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + if (!changed || signal?.aborted) { + try { + unlinkSync(tmp); + } catch { + /* gone */ + } + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before the rewritten ${basename(path)} was published; the original is untouched` }); + return false; + } + publishTempFile(tmp, path, { overwrite: true }); + return true; +} + +function isObjKey(key: string): boolean { + return key === "model.obj" || key === "model_obj"; +} + +function isMtlKey(key: string): boolean { + return key === "model.mtl" || key === "model_mtl"; +} + +function isTextureKey(key: string): boolean { + return /^texture[._]\d+[._]/.test(key); +} + +/** Split an MTL map line into indentation, key, options and the referenced file. */ +function parseMapLine(line: string): { indent: string; key: string; options: string; ref: string } | null { + const indent = /^\s*/.exec(line)?.[0] ?? ""; + const t = line.trim(); + if (!t || t.startsWith("#")) return null; + const key = keywordOf(t); + if (!TEXTURE_KEY_RE.test(key)) return null; + const rest = t.slice(key.length).trim(); + if (!rest) return null; + if (rest.startsWith("-")) { + const tokens = rest.split(WS); + return { indent, key, options: tokens.slice(0, -1).join(" "), ref: tokens.at(-1)! }; + } + return { indent, key, options: "", ref: rest }; +} + +/** + * One reference's verdict. `identity` marks evidence of *which* file was meant + * (source name, saved name, source stem) as opposed to a channel/only-texture + * heuristic, which the second pass may still veto. + */ +type Resolution = + | { kind: "hit"; name: string; method: LinkMethod; identity: boolean } + | { kind: "ambiguous"; method: LinkMethod; candidates: string[]; note?: string } + | { kind: "none" }; + +/** Apply one rule: exactly one candidate resolves, several are an ambiguity, none falls through. */ +function pick(candidates: TextureDescriptor[], method: LinkMethod, identity: boolean): Resolution | null { + if (candidates.length === 1) return { kind: "hit", name: candidates[0]!.name, method, identity }; + if (candidates.length > 1) return { kind: "ambiguous", method, candidates: candidates.map((c) => c.name) }; + return null; +} + +/** Channel rule: one texture of that channel is a (heuristic) hit, several are an ambiguity, none falls through. */ +function pickByChannel(channel: string, textures: TextureDescriptor[], method: LinkMethod): Resolution | null { + const candidates = textures.filter((t) => t.channel === channel); + if (candidates.length === 0) return null; + if (candidates.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: candidates.map((c) => c.name) }; + return { kind: "hit", name: candidates[0]!.name, method, identity: false }; +} + +/** First pass: resolve one (key, reference) pair on its own evidence; competition between references is decided afterwards. */ +function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number): Resolution { + const refBase = basename(ref.replaceAll("\\", "/")); + const lower = refBase.toLowerCase(); + // 1. The name the server served a texture under is the only evidence of which image the MTL meant. + const bySource = pick(textures.filter((t) => t.source_name !== null && t.source_name.toLowerCase() === lower), "source_name", true); + if (bySource) return bySource; + // 2. A saved file of that name — unless it is known to come from a different + // source: the CLI's generated names can collide with another texture's + // server-side name, and "the file exists" says nothing about its identity. + const named = textures.filter((t) => t.name.toLowerCase() === lower); + if (named.length === 1) { + const t = named[0]!; + if (t.source_name === null || t.source_name.toLowerCase() === lower) { + return { kind: "hit", name: t.name, method: t.name === ref ? "unchanged" : "exact", identity: true }; + } + return { + kind: "ambiguous", + method: "ambiguous", + candidates: [t.name], + note: `'${refBase}' is the CLI's name for a texture the server served as '${t.source_name}', so it cannot be the file this reference meant`, + }; + } + if (named.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: named.map((t) => t.name) }; + const stem = stemOf(refBase); + const byStem = pick(textures.filter((t) => t.source_name !== null && stemOf(t.source_name) === stem), "source_stem", true); + if (byStem) return byStem; + // 3. Heuristics: a channel word in the referenced name, then the channel the + // MTL key implies. Whichever one lands is what the second pass compares. + const inName = channelInFileName(refBase); + if (inName) { + const r = pickByChannel(inName, textures, "channel_in_name"); + if (r) return r; + } + const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; + if (ofKey) { + const r = pickByChannel(ofKey, textures, "channel_of_key"); + if (r) return r; + } + if (textures.length === 1 && distinctRefs === 1) return { kind: "hit", name: textures[0]!.name, method: "only_texture", identity: false }; + return { kind: "none" }; +} + +interface MapPair { + key: string; + ref: string; + res: Resolution; +} + +function pairId(key: string, ref: string): string { + return `${key.toLowerCase()}\u0000${ref}`; +} + +/** + * Second pass, two steps. + * + * Step 1 — one reference is one file. Every line that names the same file is + * reconciled across its keys: with identity evidence the keys agree by + * construction; otherwise each key's channel rule yields a candidate set (a hit + * is a set of one, an ambiguity its candidates, a key whose channel has no + * texture contributes nothing) and all of them must be the same single texture. + * When they are not — one key would make the file the base color while another + * could only make it one of two normal maps — no single texture fits every use, + * so every line of that reference stays as written and says why. + * + * Step 2 — one texture is one file too. A heuristic hit is vetoed when any + * *other* distinct reference contends for the same texture, by a hit of either + * kind or as an ambiguity it could not decide: nothing shows those references + * name the same image. Identity hits are never vetoed. + */ +function arbitrate(pairs: MapPair[]): Map { + const decided = new Map(); + // --- step 1: reconcile every reference across its keys + const byRef = new Map(); + for (const p of pairs) { + const group = byRef.get(p.ref) ?? []; + group.push(p); + byRef.set(p.ref, group); + } + const candidatesOf = (res: Resolution): string[] | null => (res.kind === "hit" ? [res.name] : res.kind === "ambiguous" ? res.candidates : null); + const sameSet = (a: string[], b: string[]): boolean => a.length === b.length && a.every((x) => b.includes(x)); + for (const [ref, group] of byRef) { + const evidence = group.filter((p) => candidatesOf(p.res) !== null); + const identity = group.some((p) => p.res.kind === "hit" && p.res.identity); + const first = evidence[0] ? candidatesOf(evidence[0].res)! : null; + const consistent = identity || evidence.length <= 1 || evidence.every((p) => sameSet(candidatesOf(p.res)!, first!)); + if (consistent) { + for (const p of group) decided.set(pairId(p.key, p.ref), p.res); + continue; + } + const uses = evidence + .map((p) => (p.res.kind === "hit" ? `${p.key} would make it ${p.res.name} (${p.res.method})` : `${p.key} could only make it ${(p.res as { candidates: string[] }).candidates.join(" or ")}`)) + .join(" while "); + const note = `'${ref}' is used by ${evidence.map((p) => p.key).join(" and ")} but resolves differently per key: ${uses}; one reference names one file, so none of its lines is rewritten`; + for (const p of group) { + const own = candidatesOf(p.res); + decided.set(pairId(p.key, p.ref), own ? { kind: "ambiguous", method: "ambiguous", candidates: own, note } : p.res); + } + } + // --- step 2: heuristic hits compete on the texture they actually reached + const contenders = new Map>(); + const contend = (name: string, ref: string, method: LinkMethod, identity: boolean): void => { + const byRefName = contenders.get(name) ?? new Map(); + if (!byRefName.has(ref) || identity) byRefName.set(ref, method); + contenders.set(name, byRefName); + }; + for (const { key, ref } of pairs) { + const res = decided.get(pairId(key, ref))!; + if (res.kind === "hit") contend(res.name, ref, res.method, res.identity); + else if (res.kind === "ambiguous") for (const c of res.candidates) contend(c, ref, "ambiguous", false); + } + for (const { key, ref } of pairs) { + const id = pairId(key, ref); + const res = decided.get(id)!; + if (res.kind !== "hit" || res.identity) continue; + const rivals = [...(contenders.get(res.name) ?? new Map())].filter(([r]) => r !== ref); + if (rivals.length === 0) continue; + // The note is the same for every member of the group, so the caller's + // warning can say it once. + const note = `${[[ref, res.method] as [string, LinkMethod], ...rivals] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([r, m]) => `'${r}' (${m})`) + .join(" and ")} compete for ${res.name}; different references cannot share one texture without evidence that they name the same image`; + decided.set(id, { kind: "ambiguous", method: "ambiguous", candidates: [res.name], note }); + } + return decided; +} + +/** + * Relink the OBJ/MTL/texture files of one download. Returns null when the set + * has no text OBJ (nothing to relink). Never throws for unresolved or + * ambiguous references; those become warnings, `resolved_to: null` entries + * and `status: "incomplete"`. + */ +export async function relinkMaterials(files: readonly LinkableFile[], opts: { signal?: AbortSignal } = {}): Promise { + const signal = opts.signal; + const obj = files.find((f) => isObjKey(f.key) && /\.obj$/i.test(f.path)); + if (!obj) return null; + if (signal?.aborted) throw new CliError({ code: "interrupted", message: "interrupted before the material references were relinked" }); + if (looksBinary(obj.path)) return null; + const mtl = files.find((f) => isMtlKey(f.key)) ?? null; + const textures: TextureDescriptor[] = files + .filter((f) => isTextureKey(f.key)) + .map((f) => { + const d = describeTextureKey(f.key); + return { key: f.key, name: basename(f.path), source_name: f.sourceName ?? null, set: d.set, channel: d.channel }; + }); + const report: MaterialLinkReport = { + obj: obj.path, + mtl: mtl?.path ?? null, + textures, + mtllib: [], + texture_maps: [], + rewritten: [], + status: "complete", + warnings: [], + }; + + // --- OBJ: every mtllib points at the MTL that was actually saved. + const mtlName = mtl ? basename(mtl.path) : null; + const objChanged = await rewriteLines(obj.path, (line, lineNo) => { + const t = line.trim(); + if (!t || keywordOf(t) !== "mtllib") return null; + const ref = t.slice("mtllib".length).trim(); + if (!ref) return null; + if (!mtlName) { + report.mtllib.push({ line: lineNo, material: null, reference: ref, resolved_to: null, method: "unresolved" }); + return null; + } + if (ref === mtlName) { + report.mtllib.push({ line: lineNo, material: null, reference: ref, resolved_to: mtlName, method: "unchanged" }); + return null; + } + report.mtllib.push({ line: lineNo, material: null, reference: ref, resolved_to: mtlName, method: "downloaded_mtl" }); + const indent = /^\s*/.exec(line)?.[0] ?? ""; + return `${indent}mtllib ${mtlName}`; + }, signal); + if (objChanged) report.rewritten.push(obj.path); + if (!mtlName && report.mtllib.length > 0) { + report.status = "incomplete"; + report.warnings.push( + warning( + "material_reference_unresolved", + `${basename(obj.path)} references ${report.mtllib.map((l) => `'${l.reference}'`).join(", ")} but no MTL was downloaded with it; the geometry loads without materials`, + ), + ); + } + + // --- MTL: every map_* points at a texture that was actually saved, and only when the match is unambiguous. + if (mtl) { + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before ${basename(mtl.path)} was relinked` }); + let size = 0; + try { + size = statSync(mtl.path).size; + } catch { + size = 0; + } + if (size > MAX_MTL_BYTES) { + report.status = "incomplete"; + report.warnings.push(warning("material_reference_unresolved", `${basename(mtl.path)} is ${size} bytes; too large for an MTL, texture references were not checked`)); + return report; + } + // Pass 1: every distinct (key, reference) pair on its own evidence. + const parsedLines = readFileSync(mtl.path, "utf8") + .split(/\r?\n/) + .map(parseMapLine) + .filter((p): p is NonNullable> => p !== null); + const distinctRefs = new Set(parsedLines.map((p) => p.ref)); + const pairs: MapPair[] = []; + const seenPairs = new Set(); + for (const p of parsedLines) { + const id = pairId(p.key, p.ref); + if (seenPairs.has(id)) continue; + seenPairs.add(id); + pairs.push({ key: p.key, ref: p.ref, res: resolveTextureReference(p.key, p.ref, textures, distinctRefs.size) }); + } + // Pass 2: heuristic hits compete on the texture they actually reached. + const decided = arbitrate(pairs); + let material: string | null = null; + const mtlChanged = await rewriteLines(mtl.path, (line, lineNo) => { + const t = line.trim(); + if (keywordOf(t) === "newmtl") { + material = t.slice("newmtl".length).trim() || null; + return null; + } + const parsed = parseMapLine(line); + if (!parsed) return null; + const res: Resolution = decided.get(pairId(parsed.key, parsed.ref)) ?? { kind: "none" }; + if (res.kind === "none") { + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: null, method: "unresolved" }); + return null; + } + if (res.kind === "ambiguous") { + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: null, method: "ambiguous", candidates: res.candidates, ...(res.note ? { note: res.note } : {}) }); + return null; + } + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: res.name, method: res.method }); + if (res.name === parsed.ref) return null; + return `${parsed.indent}${parsed.key}${parsed.options ? ` ${parsed.options}` : ""} ${res.name}`; + }, signal); + if (mtlChanged) report.rewritten.push(mtl.path); + const ambiguous = report.texture_maps.filter((l) => l.method === "ambiguous"); + const unresolved = report.texture_maps.filter((l) => l.method === "unresolved"); + if (ambiguous.length > 0) { + report.status = "incomplete"; + // One sentence per distinct reason; a note shared by a group of + // references (they compete for one texture) is said once, naming the + // material groups involved. + const reasons = new Map(); + for (const l of ambiguous) { + const text = l.note ?? `'${l.reference}' could be ${l.candidates!.join(" or ")}`; + const materials = reasons.get(text) ?? []; + if (l.material && !materials.includes(l.material)) materials.push(l.material); + reasons.set(text, materials); + } + report.warnings.push( + warning( + "material_reference_ambiguous", + `${basename(mtl.path)}: ${[...reasons].map(([text, materials]) => `${text}${materials.length ? ` (${materials.join(", ")})` : ""}`).join("; ")}; the references stay as written — the CLI does not guess between material groups or sources`, + ), + ); + } + if (unresolved.length > 0) { + report.status = "incomplete"; + report.warnings.push( + warning( + "material_reference_unresolved", + `${basename(mtl.path)} references ${[...new Set(unresolved.map((l) => `'${l.reference}'`))].join(", ")} which ${textures.length === 0 ? "were not downloaded" : "match none of the downloaded textures"}; those maps stay as written`, + ), + ); + } + } + return report; +} diff --git a/src/internal/obj-transform.ts b/src/internal/obj-transform.ts new file mode 100644 index 0000000..2cc7be7 --- /dev/null +++ b/src/internal/obj-transform.ts @@ -0,0 +1,766 @@ +/** + * OBJ print preparation — the legacy `fix_obj.py` transform as a streaming module. + * + * Meshy exports Y-up OBJ files in arbitrary units; slicers want Z-up + * millimetres with the model standing on the build plate. The transform is + * + * R(x, y, z) = (x, -z, y) Y-up → Z-up; determinant +1, so face winding is kept + * v' = s·R(v) + (tx, ty, tz) s = height_mm / (zmax − zmin) of the rotated box, + * XY centred on the origin, minZ on the plate (0) + * n' = R(n) normals only rotate — no scale, no translation + * + * Every other line (vt, f, o, g, s, usemtl, mtllib, comments, blanks) is copied + * verbatim, so topology, UVs and material bindings cannot drift. Extra fields on + * a `v` line (vertex colours, w) are kept verbatim after the three coordinates. + * + * Two streaming passes keep memory constant regardless of file size: pass 1 folds + * every vertex into a bounding box, pass 2 rewrites lines through a write stream + * into a temp file in the target directory. The temp file is published only after + * the whole rewrite succeeded — nothing is written when validation fails, an + * existing output is never overwritten, and the input is replaced only with + * `inPlace` (temp file + rename on the same filesystem). + * + * Material dependencies (`mtllib`, and `map_*` textures inside those MTLs) are + * resolved relative to the input and only inside the input's directory tree. + * When the output lands in another directory they are copied alongside it; a + * missing or escaping dependency is a validation error there unless the caller + * asked for `geometryOnly`, because a "success" with a silently broken material + * is worse than a refusal. Every copy target is proven to lie inside the write + * root (`root`, i.e. the --workspace, else the output directory) on its *real* + * path before any directory is created or byte copied, and again right before + * publication — a symlinked `materials/` inside the target cannot redirect a + * copy outside. No env, no credentials, no network. + */ + +import { + chmodSync, + closeSync, + createReadStream, + createWriteStream, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + type Stats, +} from "node:fs"; +import { once } from "node:events"; +import { basename, dirname, extname, join, posix, relative, resolve as resolvePath, win32 } from "node:path"; +import { finished } from "node:stream/promises"; +import { StringDecoder } from "node:string_decoder"; +import { copyFilePublished, publishTempFile, tempPathFor } from "./atomic-file.js"; +import { CliError, UsageError, type Warning } from "./errors.js"; +import { freezeRoot, isInside, realpathLenient, resolveWithinRoot, type AuthorisedRoot } from "./paths.js"; +import { warning } from "./result.js"; + +export type Vec3 = [number, number, number]; + +export interface Bbox { + min: Vec3; + max: Vec3; +} + +export const OBJ_ROTATION = "(x,y,z)->(x,-z,y)" as const; +export const DEFAULT_HEIGHT_MM = 75; +/** Same engineering ceiling as endpoint-contracts.json `limits.obj_bytes`. */ +export const DEFAULT_MAX_OBJ_BYTES = 2 * 1024 * 1024 * 1024; +/** A rotated height at or below this is treated as degenerate (flat model). */ +export const MIN_MODEL_HEIGHT = 1e-6; + +const READ_CHUNK_BYTES = 256 * 1024; +/** A "line" longer than this is not a text OBJ; refusing keeps the carry buffer bounded. */ +const MAX_LINE_CHARS = 16 * 1024 * 1024; +const WS = /\s+/; +const NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; +const TEXTURE_KEY_RE = /^(?:map_[A-Za-z0-9_]+|bump|disp|decal|refl|norm)$/i; +const URL_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//; + +export interface ObjTransformReport { + /** Absolute input path. */ + input: string; + /** Absolute path of the written file (equals `input` when in place). */ + output: string; + height_mm: number; + scale: number; + rotation: typeof OBJ_ROTATION; + translation: Vec3; + /** Bounding box of the input as written (its own axes and units). */ + before_bbox: Bbox; + /** Bounding box of the output (Z-up, millimetres). */ + after_bbox: Bbox; + counts: { + vertices: number; + normals: number; + uvs: number; + faces: number; + lines_total: number; + }; + material: { + /** `mtllib` references as written, in order, deduplicated. */ + mtllib: string[]; + /** Absolute paths of dependencies copied next to the output by this run. */ + copied: string[]; + /** References (as written) that could not be resolved inside the input's directory. */ + missing: string[]; + }; + in_place: boolean; + warnings: Warning[]; +} + +export interface PrepareObjOptions { + /** Target height in millimetres (default 75). Must be finite and > 0. */ + heightMm?: number; + /** Explicit output file (or existing directory). Mutually exclusive with `inPlace`. */ + outputPath?: string; + /** Replace the input itself via temp file + rename. */ + inPlace?: boolean; + /** Never copy MTL/texture dependencies; missing ones become warnings only. */ + geometryOnly?: boolean; + /** Refuse inputs larger than this many bytes (default 2 GiB). */ + maxBytes?: number; + /** + * Authorised root for every write (the output and each copied dependency). + * Defaults to the output's directory; pass the --workspace (frozen with the + * flags) to confine writes to it. Checked on real paths against the frozen + * directory, so symlinked parents cannot escape it and a root replaced + * mid-run is refused. + */ + root?: string | AuthorisedRoot; +} + +export function rotateYUpToZUp(v: Vec3): Vec3 { + return [v[0], -v[2], v[1]]; +} + +/** Default output beside the input: `.print.obj`. */ +export function defaultOutputPath(input: string): string { + const ext = extname(input); + const stem = ext ? basename(input, ext) : basename(input); + return join(dirname(input), `${stem}.print.obj`); +} + +/** + * Six fixed decimals (error ≤ 5e-7, inside the 1e-5 mm oracle tolerance) with + * trailing zeros trimmed so a unit box does not become a wall of `.000000`. + * `-0` never appears: a slicer gains nothing from a signed zero. + */ +export function formatObjNumber(n: number): string { + if (n === 0) return "0"; + let s = n.toFixed(6); + if (s.includes(".")) s = s.replace(/0+$/, "").replace(/\.$/, ""); + return s === "-0" ? "0" : s; +} + +function validation(message: string, extra: { hint?: string; details?: unknown } = {}): CliError { + return new CliError({ code: "validation", message, hint: extra.hint, details: extra.details }); +} + +function localIo(message: string, cause?: unknown): CliError { + return new CliError({ code: "local_io", message, cause }); +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* already gone */ + } +} + +function noNegZero(n: number): number { + return n === 0 ? 0 : n; +} + +function parseCoord(token: string | undefined): number | null { + if (token === undefined || !NUMBER_RE.test(token)) return null; + const n = Number(token); + return Number.isFinite(n) ? n : null; +} + +/** First whitespace-delimited token (the OBJ keyword) of an already-trimmed line. */ +function keywordOf(t: string): string { + const m = WS.exec(t); + return m ? t.slice(0, m.index) : t; +} + +function parseVec3(tokens: string[], lineNo: number, kind: string): Vec3 { + if (tokens.length < 4) { + throw validation(`${kind} line ${lineNo} has ${tokens.length - 1} coordinate(s); expected 3`); + } + const out: number[] = []; + for (let i = 1; i <= 3; i++) { + const n = parseCoord(tokens[i]); + if (n === null) { + throw validation(`non-finite or unparseable coordinate '${tokens[i] ?? ""}' in ${kind} line ${lineNo}`); + } + out.push(n); + } + return [out[0]!, out[1]!, out[2]!]; +} + +/** Negate a numeric token textually so the input's precision survives the rotation. */ +function negateToken(token: string): string { + const n = Number(token); + if (n === 0) return "0"; + if (token.startsWith("-")) return token.slice(1); + if (token.startsWith("+")) return `-${token.slice(1)}`; + return `-${token}`; +} + +interface MutableBbox { + min: Vec3; + max: Vec3; + seen: boolean; +} + +function emptyBbox(): MutableBbox { + return { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity], seen: false }; +} + +function fold(b: MutableBbox, v: Vec3): void { + for (let i = 0; i < 3; i++) { + const c = v[i]!; + if (c < b.min[i]!) b.min[i] = c; + if (c > b.max[i]!) b.max[i] = c; + } + b.seen = true; +} + +type LineSink = (text: string, eol: string, lineNo: number) => void; + +/** + * Stream `path` line by line. Each line's own terminator ("\n", "\r\n", or "" + * for a final unterminated line) is handed to the sink so pass 2 can preserve + * the input's style. UTF-8 is decoded across chunk boundaries. `afterChunk` + * runs after every read chunk so a writer can apply backpressure. + */ +async function forEachLine(path: string, onLine: LineSink, afterChunk: (() => Promise) | null): Promise { + const stream = createReadStream(path, { highWaterMark: READ_CHUNK_BYTES }); + const decoder = new StringDecoder("utf8"); + let carry = ""; + let lineNo = 0; + try { + for await (const chunk of stream) { + carry += decoder.write(chunk as Buffer); + let start = 0; + let nl: number; + while ((nl = carry.indexOf("\n", start)) !== -1) { + let end = nl; + let eol = "\n"; + if (end > start && carry.charCodeAt(end - 1) === 13) { + end -= 1; + eol = "\r\n"; + } + lineNo += 1; + onLine(carry.slice(start, end), eol, lineNo); + start = nl + 1; + } + carry = start > 0 ? carry.slice(start) : carry; + if (carry.length > MAX_LINE_CHARS) { + throw validation(`line ${lineNo + 1} exceeds ${MAX_LINE_CHARS} characters; not a text OBJ file`); + } + if (afterChunk) await afterChunk(); + } + carry += decoder.end(); + if (carry.length > 0) { + lineNo += 1; + onLine(carry, "", lineNo); + if (afterChunk) await afterChunk(); + } + return lineNo; + } catch (err) { + stream.destroy(); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`, err); + } +} + +interface Scan { + raw: MutableBbox; + rotated: MutableBbox; + vertices: number; + normals: number; + uvs: number; + faces: number; + lines: number; + mtllib: string[]; +} + +/** Pass 1: bounding boxes, counts and material references. Constant memory. */ +async function scanObj(input: string): Promise { + const scan: Scan = { raw: emptyBbox(), rotated: emptyBbox(), vertices: 0, normals: 0, uvs: 0, faces: 0, lines: 0, mtllib: [] }; + const seenMtl = new Set(); + scan.lines = await forEachLine( + input, + (text, _eol, lineNo) => { + const t = text.trim(); + if (t.length === 0) return; + switch (keywordOf(t)) { + case "v": { + const v = parseVec3(t.split(WS), lineNo, "v"); + fold(scan.raw, v); + fold(scan.rotated, rotateYUpToZUp(v)); + scan.vertices += 1; + break; + } + case "vn": + parseVec3(t.split(WS), lineNo, "vn"); + scan.normals += 1; + break; + case "vt": + scan.uvs += 1; + break; + case "f": + scan.faces += 1; + break; + case "mtllib": { + const ref = t.slice("mtllib".length).trim(); + if (ref && !seenMtl.has(ref)) { + seenMtl.add(ref); + scan.mtllib.push(ref); + } + break; + } + default: + break; + } + }, + null, + ); + return scan; +} + +interface Transform { + scale: number; + translation: Vec3; +} + +function rewriteLine(text: string, lineNo: number, tf: Transform): string { + const t = text.trim(); + if (t.length === 0) return text; + const key = keywordOf(t); + if (key === "v") { + const tokens = t.split(WS); + const [rx, ry, rz] = rotateYUpToZUp(parseVec3(tokens, lineNo, "v")); + const parts = [ + "v", + formatObjNumber(rx * tf.scale + tf.translation[0]), + formatObjNumber(ry * tf.scale + tf.translation[1]), + formatObjNumber(rz * tf.scale + tf.translation[2]), + ]; + for (let i = 4; i < tokens.length; i++) parts.push(tokens[i]!); + return parts.join(" "); + } + if (key === "vn") { + const tokens = t.split(WS); + parseVec3(tokens, lineNo, "vn"); + // (nx, ny, nz) → (nx, -nz, ny), reordering the original tokens so no precision is lost. + const parts = ["vn", tokens[1]!, negateToken(tokens[3]!), tokens[2]!]; + for (let i = 4; i < tokens.length; i++) parts.push(tokens[i]!); + return parts.join(" "); + } + return text; +} + +/** Pass 2: rewrite into `tmp`, streaming with backpressure. */ +async function rewriteObj(input: string, tmp: string, tf: Transform): Promise { + const out = createWriteStream(tmp, { flags: "wx" }); + let writeError: Error | null = null; + out.on("error", (err) => { + writeError = err; + }); + let buf = ""; + const flush = async (): Promise => { + if (writeError) throw writeError; + if (buf.length === 0) return; + const chunk = buf; + buf = ""; + if (!out.write(chunk)) await once(out, "drain"); + if (writeError) throw writeError; + }; + try { + await forEachLine( + input, + (text, eol, lineNo) => { + buf += rewriteLine(text, lineNo, tf) + eol; + }, + flush, + ); + await flush(); + out.end(); + await finished(out); + } catch (err) { + out.destroy(); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to write ${tmp}: ${err instanceof Error ? err.message : String(err)}`, err); + } +} + +type Located = { kind: "found"; path: string; real: string } | { kind: "outside" } | { kind: "missing" }; + +/** + * Resolve a material reference relative to `baseDir`, admitting only regular + * files whose real path stays inside `rootDir`. URLs, absolute paths (POSIX or + * Windows) and anything escaping the tree are never read. + */ +function locateDependency(ref: string, baseDir: string, rootReal: string): Located { + if (!ref || URL_RE.test(ref) || /^data:/i.test(ref)) return { kind: "outside" }; + const candidates = [ref]; + if (ref.includes("\\")) candidates.push(ref.replaceAll("\\", "/")); + let sawMissing = false; + for (const candidate of candidates) { + if (posix.isAbsolute(candidate) || win32.isAbsolute(candidate)) return { kind: "outside" }; + const abs = resolvePath(baseDir, candidate); + let real: string; + try { + real = realpathLenient(abs); + } catch { + sawMissing = true; + continue; + } + if (!isInside(rootReal, real)) return { kind: "outside" }; + let st: Stats | null = null; + try { + st = statSync(real); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw localIo(`cannot access ${abs}: ${(err as Error).message}`, err); + } + if (st?.isFile()) return { kind: "found", path: abs, real }; + sawMissing = true; + } + return sawMissing ? { kind: "missing" } : { kind: "outside" }; +} + +/** Texture references (`map_Kd [-options] file`) inside an MTL. */ +export function textureReferencesInMtl(text: string): string[] { + const refs: string[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const key = keywordOf(line); + if (!TEXTURE_KEY_RE.test(key)) continue; + const rest = line.slice(key.length).trim(); + if (!rest) continue; + // Options (`-s 1 1 1`, `-bm 0.5`) precede the filename; without options the + // whole remainder is the name so spaces in filenames survive. + const ref = rest.startsWith("-") ? rest.split(WS).at(-1)! : rest; + if (!refs.includes(ref)) refs.push(ref); + } + return refs; +} + +interface MaterialPlan { + /** `target` is the path as planned (beside the output); `real` is its proven location inside the write root. */ + copies: Array<{ ref: string; source: string; target: string; real: string }>; + missing: string[]; + warnings: Warning[]; +} + +function planMaterials( + refs: string[], + inputDir: string, + outputDir: string, + wantCopies: boolean, +): MaterialPlan { + const plan: MaterialPlan = { copies: [], missing: [], warnings: [] }; + const inputReal = realpathLenient(inputDir); + const targets = new Set(); + + const note = (ref: string, located: Located, origin: string): void => { + plan.missing.push(ref); + if (located.kind === "outside") { + plan.warnings.push( + warning( + "material_dependency_outside_input_dir", + `${origin} references '${ref}', which is a URL, an absolute path or escapes the input's directory; it was not read or copied`, + ), + ); + } else { + plan.warnings.push(warning("material_dependency_missing", `${origin} references '${ref}', which does not exist next to the input`)); + } + }; + + const schedule = (ref: string, real: string): void => { + if (!wantCopies) return; + const target = join(outputDir, relative(inputReal, real)); + if (targets.has(target)) return; + targets.add(target); + plan.copies.push({ ref, source: real, target, real: target }); + }; + + for (const written of refs) { + // `mtllib a.mtl b.mtl` lists several files, but names with spaces exist in + // the wild: try the whole remainder first, split only when it is not a file. + let names = [written]; + if (WS.test(written) && locateDependency(written, inputDir, inputReal).kind !== "found") { + names = written.split(WS); + } + for (const name of names) { + const located = locateDependency(name, inputDir, inputReal); + if (located.kind !== "found") { + note(name, located, "mtllib"); + continue; + } + schedule(name, located.real); + let mtlText: string; + try { + mtlText = readFileSync(located.real, "utf8"); + } catch (err) { + throw localIo(`failed to read material file ${located.path}: ${(err as Error).message}`, err); + } + const mtlDir = dirname(located.real); + for (const texRef of textureReferencesInMtl(mtlText)) { + const tex = locateDependency(texRef, mtlDir, inputReal); + if (tex.kind !== "found") { + note(texRef, tex, `${name}`); + continue; + } + schedule(texRef, tex.real); + } + } + } + return plan; +} + +function sameContent(a: string, b: string): boolean { + const sa = statSync(a); + const sb = statSync(b); + if (sa.size !== sb.size) return false; + return readFileSync(a).equals(readFileSync(b)); +} + +/** Copy a dependency without overwriting; an identical file already there counts as present. */ +function copyDependency(source: string, target: string): "copied" | "present" { + let existing: Stats | null = null; + try { + existing = lstatSync(target); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw localIo(`cannot access ${target}: ${(err as Error).message}`, err); + } + if (existing) { + if (existing.isFile() && sameContent(source, target)) return "present"; + throw localIo(`refusing to overwrite ${target}: a different file already exists there (needed for material dependency ${basename(source)})`); + } + copyFilePublished(source, target); + return "copied"; +} + +function statInput(input: string): { stat: Stats; lstat: Stats } { + let lst: Stats; + try { + lst = lstatSync(input); + } catch (err) { + if (errno(err) === "ENOENT" || errno(err) === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `input file not found: ${input}` }); + } + throw localIo(`cannot access ${input}: ${(err as Error).message}`, err); + } + let st: Stats; + try { + st = statSync(input); + } catch (err) { + if (errno(err) === "ENOENT" || errno(err) === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `input file not found: ${input} (dangling symbolic link)` }); + } + throw localIo(`cannot access ${input}: ${(err as Error).message}`, err); + } + if (!st.isFile()) throw validation(`input is not a regular file: ${input}`); + return { stat: st, lstat: lst }; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function exists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +/** + * Rotate, scale and ground an OBJ for printing. See the module comment for the + * maths and the file contract. Throws CliError (`validation` / `local_io` / + * `not_found`) or UsageError; on any failure no output file exists. + */ +export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOptions = {}): Promise { + const heightMm = opts.heightMm ?? DEFAULT_HEIGHT_MM; + if (typeof heightMm !== "number" || !Number.isFinite(heightMm) || heightMm <= 0) { + throw validation(`--height-mm must be a finite number greater than 0 (got ${String(heightMm)})`); + } + const inPlace = opts.inPlace === true; + const geometryOnly = opts.geometryOnly === true; + if (opts.outputPath !== undefined && inPlace) { + throw new UsageError("--output/-o and --in-place are mutually exclusive: pick a new file or replace the input, not both"); + } + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_OBJ_BYTES; + + const input = resolvePath(inputPath); + const { stat, lstat } = statInput(input); + if (stat.size === 0) throw validation(`input is empty: ${input}`); + if (stat.size > maxBytes) { + throw localIo(`input is ${stat.size} bytes, above the ${maxBytes}-byte limit for local OBJ processing: ${input}`); + } + if (inPlace && lstat.isSymbolicLink()) { + throw localIo(`refusing to replace ${input} in place: it is a symbolic link`); + } + const inputDir = dirname(input); + + let output: string; + if (inPlace) { + output = input; + } else { + output = opts.outputPath !== undefined ? resolvePath(opts.outputPath) : defaultOutputPath(input); + if (isDirectory(output)) output = join(output, basename(defaultOutputPath(input))); + if (exists(output)) { + throw new CliError({ + code: "local_io", + message: `refusing to overwrite existing file: ${output} (choose another --output path, or pass --in-place to replace the input itself)`, + recovery: { action: "choose_path", automatic: false }, + }); + } + } + const outputDir = dirname(output); + + // Pass 1 — validate and measure. Nothing has been written yet. + const scan = await scanObj(input); + if (scan.vertices === 0) throw validation(`no vertex (v) lines found in ${input}`); + const rot = scan.rotated; + const height = rot.max[2] - rot.min[2]; + if (!(height > MIN_MODEL_HEIGHT)) { + throw validation( + `degenerate model: rotated height is ${height} (≤ ${MIN_MODEL_HEIGHT}); every vertex shares the same up-axis value, so no scale can be derived`, + ); + } + const scale = heightMm / height; + const translation: Vec3 = [ + noNegZero((-(rot.min[0] + rot.max[0]) / 2) * scale), + noNegZero((-(rot.min[1] + rot.max[1]) / 2) * scale), + noNegZero(-rot.min[2] * scale), + ]; + const tf: Transform = { scale, translation }; + + // Materials — decided before any write so a broken binding is refused up front. + const crossDir = !inPlace && realpathLenient(outputDir) !== realpathLenient(inputDir); + const wantCopies = crossDir && !geometryOnly; + const plan = planMaterials(scan.mtllib, inputDir, outputDir, wantCopies); + const warnings: Warning[] = [...plan.warnings]; + + // Every path written by this run must resolve inside the write root: the + // output itself and each dependency copy, checked on real paths before any + // directory is created. A `materials/` symlink pointing elsewhere fails here. + const writeRoot: AuthorisedRoot = opts.root === undefined ? freezeRoot(outputDir, { label: "output directory" }) : typeof opts.root === "string" ? freezeRoot(opts.root, { label: "--workspace" }) : opts.root; + if (!inPlace) resolveWithinRoot(output, writeRoot, { label: "output" }); + for (const copy of plan.copies) { + copy.real = resolveWithinRoot(copy.target, writeRoot, { label: `material dependency target for '${copy.ref}'` }).path; + } + if (wantCopies && plan.missing.length > 0) { + throw validation( + `material dependencies of ${input} cannot be carried to ${outputDir}: ${plan.missing.join(", ")} — the output would reference files that are not there. ` + + "Pass --geometry-only to write only the geometry, or keep the output in the input's directory", + { hint: "pass --geometry-only, or write the output next to the input" }, + ); + } + if (crossDir && geometryOnly && scan.mtllib.length > 0) { + warnings.push( + warning( + "material_dependencies_not_copied", + `--geometry-only: ${scan.mtllib.join(", ")} referenced by the output were not copied to ${outputDir}`, + ), + ); + } + + // Pass 2 — rewrite into a temp file beside the target, then publish. + if (!inPlace) { + try { + mkdirSync(outputDir, { recursive: true }); + } catch (err) { + throw localIo(`cannot create output directory ${outputDir}: ${(err as Error).message}`, err); + } + } + const tmp = tempPathFor(output); + const copied: string[] = []; + try { + await rewriteObj(input, tmp, tf); + if (inPlace) { + const fd = openSync(tmp, "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + try { + chmodSync(tmp, stat.mode & 0o777); + } catch { + /* keep default mode */ + } + renameSync(tmp, output); + } else { + for (const copy of plan.copies) { + // Re-proven at publication time: the tree may have changed since planning. + copy.real = resolveWithinRoot(copy.target, writeRoot, { label: `material dependency target for '${copy.ref}'` }).path; + if (copyDependency(copy.source, copy.real) === "copied") { + copied.push(copy.target); + } else { + warnings.push( + warning("material_dependency_already_present", `${copy.target} already exists with identical content; not copied again`), + ); + } + } + resolveWithinRoot(output, writeRoot, { label: "output" }); + publishTempFile(tmp, output); + } + } catch (err) { + removeQuietly(tmp); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to write ${output}: ${err instanceof Error ? err.message : String(err)}`, err); + } + + const afterBbox: Bbox = { + min: [ + noNegZero(rot.min[0] * scale + translation[0]), + noNegZero(rot.min[1] * scale + translation[1]), + noNegZero(rot.min[2] * scale + translation[2]), + ], + max: [ + noNegZero(rot.max[0] * scale + translation[0]), + noNegZero(rot.max[1] * scale + translation[1]), + noNegZero(rot.max[2] * scale + translation[2]), + ], + }; + + return { + input, + output, + height_mm: heightMm, + scale, + rotation: OBJ_ROTATION, + translation, + before_bbox: { min: [...scan.raw.min], max: [...scan.raw.max] }, + after_bbox: afterBbox, + counts: { + vertices: scan.vertices, + normals: scan.normals, + uvs: scan.uvs, + faces: scan.faces, + lines_total: scan.lines, + }, + material: { mtllib: scan.mtllib, copied, missing: plan.missing }, + in_place: inPlace, + warnings, + }; +} diff --git a/src/internal/operation-store.ts b/src/internal/operation-store.ts new file mode 100644 index 0000000..e6e91f5 --- /dev/null +++ b/src/internal/operation-store.ts @@ -0,0 +1,302 @@ +/** + * Operation journal — a local, best-effort record of every billable create. + * + * It exists so a lost response can be reconciled instead of blindly re-sent: + * the record is written *before* the POST (state `started`), then updated to + * `accepted` (task id known), `rejected` (server said no), `not_submitted` + * (transport proves the request never left) or `unknown` (anything else after + * the request was sent). A repeated `--operation-id` returns the stored record + * instead of submitting again when the request fingerprints match, and refuses + * with `operation_conflict` when they do not. + * + * Identity of a request = resource + API origin + credential fingerprint + + * payload fingerprint. The credential fingerprint binds to the actual account: + * a keyed digest of the API key, or the stable OAuth subject (user id), or — + * when the token endpoint reported no user id — the per-login identifier + * minted at `meshy auth login`. Never the rotating access token, so a routine + * refresh is still the same identity while a different key under the same env + * variable, or a new login under the same profile name, is not. An OAuth + * profile that carries neither a user id nor a login id (written before login + * ids existed) has *no* verifiable identity: such a credential can start new + * operations but is refused a replay of an existing one, because "unknown" + * must never be read as "the same account". The payload + * fingerprint hashes media *content* (decoded bytes of every data URI), so two + * different images of the same size never collide. + * + * This is a local record only. It is not a server-side idempotency key and it + * cannot guarantee the server did not bill a request whose response was lost. + * Nothing secret is stored: no key material, no base64 media, no signed URLs — + * only one-way digests. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { writeJsonFile } from "./atomic-file.js"; +import { configDir } from "./credentials.js"; +import { CliError } from "./errors.js"; +import { withFileLock } from "./lock.js"; +import { safeSegment } from "./paths.js"; +import { VERSION } from "./version.js"; + +export type OperationState = "started" | "accepted" | "rejected" | "unknown" | "not_submitted"; + +export interface OperationRecord { + schema_version: 1; + operation_id: string; + state: OperationState; + resource: string; + endpoint: string; + api_origin: string; + credential_fingerprint: string; + payload_fingerprint: string; + started_at: string; + updated_at: string; + task_id: string | null; + request_id: string | null; + http_status: number | null; + error: string | null; + pid: number; + cli_version: string; + /** Optional link to the project that owns the task (set by --project). */ + project: string | null; +} + +export interface OperationIdentity { + resource: string; + endpoint: string; + apiOrigin: string; + credentialFingerprint: string; + /** + * False when nothing stable identifies the credential (an OAuth profile + * without user id or login id). Such a credential may start a new operation + * but is never granted a replay of an existing record. Default true. + */ + credentialVerified?: boolean; + payloadFingerprint: string; + project?: string | null; +} + +export function operationsRoot(env: NodeJS.ProcessEnv = process.env): string { + return join(configDir(env), "operations"); +} + +export function newOperationId(): string { + return randomUUID(); +} + +export interface CredentialIdentityParts { + /** Where the credential came from: flag | env | env-file | file. */ + source: string; + /** Stored profile name (credentialSource === "file"). */ + profile?: string | null; + /** API origin the credential is used against. */ + origin: string; + /** api_key | oauth. */ + kind?: string; + /** The static API key itself (api_key kinds). Digested with a domain prefix; never stored. */ + secret?: string | null; + /** Stable account subject for OAuth profiles (user id). Tokens rotate; the subject does not. */ + subject?: string | null; + /** Per-login identifier of an OAuth profile (minted at `auth login`), used when no subject exists. */ + loginId?: string | null; +} + +export interface CredentialBinding { + /** What the fingerprint binds to; `unverified` when nothing stable identifies the account. */ + binding: string; + verified: boolean; +} + +/** Decide what identifies this credential — and whether anything does. */ +export function credentialBinding(parts: CredentialIdentityParts): CredentialBinding { + if (parts.kind === "oauth") { + if (parts.subject) return { binding: `subject:${parts.subject}`, verified: true }; + if (parts.loginId) return { binding: `login:${parts.loginId}`, verified: true }; + return { binding: "unverified", verified: false }; + } + if (parts.secret) return { binding: `key:${sha256(`${CREDENTIAL_DIGEST_DOMAIN}|${parts.secret}`)}`, verified: true }; + return { binding: "key:none", verified: false }; +} + +const CREDENTIAL_DIGEST_DOMAIN = "meshy-cli/credential-binding/v1"; + +/** + * `sha256(source|profile|kind|origin|binding)` where the binding is a keyed + * digest of the API key, the OAuth subject, or the OAuth login id. Two + * different keys from the same source therefore have different fingerprints; + * a refreshed OAuth token keeps its fingerprint as long as the account (or the + * login) is the same. Never reversible to a key. Callers must also consult + * `credentialBinding(parts).verified`: an unverified fingerprint identifies + * nothing and must not be matched against an existing record. + */ +export function credentialFingerprint(parts: CredentialIdentityParts): string { + const { binding } = credentialBinding(parts); + return sha256(`${parts.source}|${parts.profile ?? ""}|${parts.kind ?? ""}|${parts.origin}|${binding}`); +} + +/** + * Canonical JSON with every data URI replaced by `data:;sha256=`: two submissions of the same file match (whatever the + * base64 line wrapping), two different files of equal size do not, and the + * journal never holds the content itself. + */ +export function payloadFingerprint(payload: unknown): string { + return sha256(canonical(payload)); +} + +export function dataUriDigest(uri: string): string { + const comma = uri.indexOf(","); + const header = comma === -1 ? uri.slice(5) : uri.slice(5, comma); + const payload = comma === -1 ? "" : uri.slice(comma + 1); + const mime = (header.split(";")[0] ?? "").toLowerCase(); + const isBase64 = /(^|;)base64$/i.test(header) || /;base64(;|$)/i.test(header); + let bytes: Buffer; + if (isBase64) { + bytes = Buffer.from(payload.replace(/\s+/g, ""), "base64"); + } else { + let text = payload; + try { + text = decodeURIComponent(payload); + } catch { + /* keep the raw payload */ + } + bytes = Buffer.from(text, "utf8"); + } + return `data:${mime};sha256=${createHash("sha256").update(bytes).digest("hex")}`; +} + +function canonical(v: unknown): string { + if (v === null || typeof v !== "object") { + if (typeof v === "string" && /^data:/i.test(v)) { + return JSON.stringify(dataUriDigest(v)); + } + return JSON.stringify(v); + } + if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; + const keys = Object.keys(v as Record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`).join(",")}}`; +} + +function sha256(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +function recordPath(root: string, id: string): string { + return join(root, `${safeSegment(id, "op")}.json`); +} + +function lockPath(root: string): string { + return join(root, "locks", "operations.lock"); +} + +export function readOperation(root: string, id: string): OperationRecord | null { + try { + const raw = JSON.parse(readFileSync(recordPath(root, id), "utf8")) as OperationRecord; + return raw && raw.schema_version === 1 ? raw : null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new CliError({ code: "local_io", message: `operation record ${recordPath(root, id)} is unreadable: ${(err as Error).message}` }); + } +} + +export interface BeginResult { + /** `created`: this process owns the submission. `existing`: an earlier run already has a record. */ + outcome: "created" | "existing"; + record: OperationRecord; +} + +/** + * Under the journal lock: return the existing record when one exists for + * `operationId` with the same identity, throw `operation_conflict` when the + * identity differs, or write a fresh `started` record and return it. + */ +export function beginOperation(root: string, operationId: string, identity: OperationIdentity, now: () => Date = () => new Date()): BeginResult { + mkdirSync(root, { recursive: true, mode: 0o700 }); + return withFileLock(lockPath(root), () => { + const existing = readOperation(root, operationId); + if (existing) { + if (identity.credentialVerified === false) { + throw new CliError({ + code: "operation_conflict", + message: + `operation ${operationId} already exists but the current OAuth login has no account identity (profile without user_id or login_id), so it cannot be confirmed as the same account; ` + + "nothing was submitted — run `meshy auth login` to bind this login, or use a new --operation-id", + recovery: { action: "login", automatic: false, command: "meshy auth login" }, + result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id }, conflict: ["credential_unverified"] }, + }); + } + const differs: string[] = []; + if (existing.resource !== identity.resource) differs.push("resource"); + if (existing.api_origin !== identity.apiOrigin) differs.push("origin"); + if (existing.credential_fingerprint !== identity.credentialFingerprint) differs.push("credential"); + if (existing.payload_fingerprint !== identity.payloadFingerprint) differs.push("payload"); + if (differs.length > 0) { + throw new CliError({ + code: "operation_conflict", + message: `operation ${operationId} already exists for a different request (${differs.join(", ")} differ); nothing was submitted — use a new --operation-id for a new request`, + result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id }, conflict: differs }, + }); + } + return { outcome: "existing", record: existing }; + } + const ts = now().toISOString(); + const record: OperationRecord = { + schema_version: 1, + operation_id: operationId, + state: "started", + resource: identity.resource, + endpoint: identity.endpoint, + api_origin: identity.apiOrigin, + credential_fingerprint: identity.credentialFingerprint, + payload_fingerprint: identity.payloadFingerprint, + started_at: ts, + updated_at: ts, + task_id: null, + request_id: null, + http_status: null, + error: null, + pid: process.pid, + cli_version: VERSION, + project: identity.project ?? null, + }; + writeJsonFile(recordPath(root, operationId), record, { overwrite: false, mode: 0o600 }); + return { outcome: "created", record }; + }); +} + +export function updateOperation( + root: string, + operationId: string, + patch: Partial>, + now: () => Date = () => new Date(), +): OperationRecord { + return withFileLock(lockPath(root), () => { + const existing = readOperation(root, operationId); + if (!existing) { + throw new CliError({ code: "local_io", message: `operation record ${operationId} disappeared before it could be updated` }); + } + const next: OperationRecord = { ...existing, ...patch, updated_at: now().toISOString() }; + writeJsonFile(recordPath(root, operationId), next, { overwrite: true, mode: 0o600 }); + return next; + }); +} + +export function listOperations(root: string): OperationRecord[] { + let names: string[]; + try { + names = readdirSync(root).filter((n) => n.endsWith(".json")); + } catch { + return []; + } + const out: OperationRecord[] = []; + for (const n of names) { + try { + const rec = JSON.parse(readFileSync(join(root, n), "utf8")) as OperationRecord; + if (rec && rec.schema_version === 1) out.push(rec); + } catch { + /* skip unreadable */ + } + } + return out.sort((a, b) => a.started_at.localeCompare(b.started_at)); +} diff --git a/src/internal/output.ts b/src/internal/output.ts index 0c61811..37c758a 100644 --- a/src/internal/output.ts +++ b/src/internal/output.ts @@ -2,12 +2,18 @@ * Output rendering. Keep stdout machine-parseable by default; `pretty` is * opt-in for human eyes. * - * Note: stdout JSON may carry `_notice.update` when a newer meshy-cli version - * is available. See update-notifier.ts for the two-channel design. + * Legacy path (`emit`): bare payloads, optionally decorated with + * `_notice.update` when a newer meshy-cli version is available. See + * update-notifier.ts for the two-channel design. + * + * v1 path (`emitEnvelope`): exactly one envelope object, never decorated — + * the six top-level keys are the contract. Humans on a TTY still get the + * update hint on stderr. */ import { writeFileSync } from "node:fs"; import { attachUpdateNotice, getUpdateNotice, printHumanUpdateHint } from "./update-notifier.js"; +import type { StreamEventEnvelope, V1Envelope } from "./result.js"; export type OutputFormat = "json" | "pretty" | "ndjson"; @@ -31,7 +37,33 @@ export function emit(value: unknown, opts: OutputOptions): void { printHumanUpdateHint(notice, process); } -function render(value: unknown, format: OutputFormat): string { +/** + * Write to stdout and resolve once the bytes are handed to the OS. Needed + * before an explicit process.exit(), which does not wait for pending writes + * on pipes — large JSON or a cancelled stream would otherwise be truncated. + */ +export function writeStdout(text: string): Promise { + return new Promise((resolve, reject) => { + const ok = process.stdout.write(text, (err) => (err ? reject(err) : resolve())); + if (ok) { + // Callback still fires; nothing else to do. + } + }); +} + +/** Print one v1 envelope in the requested rendering. */ +export async function emitEnvelope(envelope: V1Envelope, format: OutputFormat): Promise { + const text = format === "pretty" ? renderPretty(envelope) : format === "ndjson" ? JSON.stringify(envelope) : JSON.stringify(envelope, null, 2); + await writeStdout(`${text}\n`); + printHumanUpdateHint(getUpdateNotice(), process); +} + +/** Print one stream event (ndjson only; json/pretty callers print the final envelope instead). */ +export async function emitStreamEvent(event: StreamEventEnvelope): Promise { + await writeStdout(`${JSON.stringify(event)}\n`); +} + +export function render(value: unknown, format: OutputFormat): string { switch (format) { case "json": return JSON.stringify(value, null, 2); @@ -43,7 +75,7 @@ function render(value: unknown, format: OutputFormat): string { } } -function renderPretty(value: unknown, indent = 0): string { +export function renderPretty(value: unknown, indent = 0): string { const pad = " ".repeat(indent); if (value === null || value === undefined) return `${pad}-`; if (typeof value !== "object") return `${pad}${String(value)}`; diff --git a/src/internal/paths.ts b/src/internal/paths.ts new file mode 100644 index 0000000..de19daf --- /dev/null +++ b/src/internal/paths.ts @@ -0,0 +1,202 @@ +/** + * Path containment and safe naming. + * + * Every file the CLI writes is checked against an authorised root: the + * explicit --workspace when given, otherwise the root the command itself + * authorised (a project directory, or the parent of an explicit output path). + * Containment is decided on real paths — the deepest existing ancestor is + * resolved with realpath so a symlinked directory cannot redirect a write — + * never with a string prefix test. + * + * The root itself is *frozen* when the command starts (`freezeRoot`): its real + * path and the identity (device, inode) of the physical directory behind it are + * captured before the first request or write, and every later check + * (`resolveWithinRoot` with an `AuthorisedRoot`) first proves that this very + * directory is still there — not a symlink that appeared at its path, not a + * different directory — and then proves the target's real path inside it. A + * root that is re-resolved at check time would move with whatever now sits at + * its path; a frozen root cannot. Stable aliases (macOS `/var` → `/private/var`, + * a symlinked parent, a workspace given through a symlink that keeps pointing + * at the same directory) resolve to the same physical directory and pass. + */ + +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve as resolvePath, sep, basename } from "node:path"; +import { CliError } from "./errors.js"; + +export interface ResolvedTarget { + /** Absolute, normalised path as given (symlink-free for the existing part). */ + path: string; + /** The authorised root the path was checked against. */ + root: string; + /** Path relative to root, POSIX separators. */ + relative: string; +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +/** Resolve the real path of the deepest existing ancestor and re-append the missing tail. */ +export function realpathLenient(path: string): string { + const abs = resolvePath(path); + const missing: string[] = []; + let cursor = abs; + for (;;) { + try { + const real = realpathSync.native(cursor); + return missing.length ? resolvePath(real, ...missing.reverse()) : real; + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw err; + const parent = dirname(cursor); + if (parent === cursor) return abs; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +export function isInside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * A write boundary fixed at the start of a command. `real` is where writes may + * land; `anchor` is the deepest part of it that existed when frozen (the whole + * root when it exists) and `dev`/`ino` identify that directory. A later check + * re-proves the identity, so replacing the directory at that path — a symlink + * to somewhere else, a different directory swapped in — is refused rather than + * silently becoming the new root. + */ +export interface AuthorisedRoot { + /** The root as authorised, absolute (what the user named). */ + given: string; + /** Real path of the root when frozen. */ + real: string; + /** Real path of the deepest existing ancestor when frozen (equals `real` when the root existed). */ + anchor: string; + dev: number; + ino: number; + /** Whether `anchor` was a directory when frozen (a file is refused at the first write, not earlier). */ + directory: boolean; + /** Name used in messages: "--workspace", "--project", "output directory" … */ + label: string; +} + +function errnoOf(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +/** Freeze a write boundary: resolve it once and remember which directory it is. */ +export function freezeRoot(path: string, opts: { cwd?: string; label?: string } = {}): AuthorisedRoot { + const given = resolvePath(opts.cwd ?? process.cwd(), path); + const real = realpathLenient(given); + let anchor = real; + for (;;) { + try { + const st = statSync(anchor); + return { given, real, anchor, dev: st.dev, ino: st.ino, directory: st.isDirectory(), label: opts.label ?? "root" }; + } catch (err) { + if (errnoOf(err) !== "ENOENT" && errnoOf(err) !== "ENOTDIR") throw err; + const parent = dirname(anchor); + if (parent === anchor) throw err; + anchor = parent; + } + } +} + +/** + * The directory a root was frozen on must still be the one at its path: a + * directory (never a symlink), with the same device and inode. Anything else + * means the authorised boundary was moved or replaced since the command started. + */ +export function assertRootIntact(root: AuthorisedRoot): void { + if (!root.directory) { + throw new CliError({ code: "local_io", message: `${root.label} ${root.given} is not a directory; refusing to write` }); + } + let st: ReturnType; + try { + st = lstatSync(root.anchor); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `${root.label} ${root.given} changed since the command started: ${root.anchor} is gone (${errnoOf(err) ?? "stat failed"}); refusing to write outside the authorised boundary`, + cause: err, + }); + } + if (st.isSymbolicLink() || !st.isDirectory() || st.dev !== root.dev || st.ino !== root.ino) { + throw new CliError({ + code: "local_io", + message: `${root.label} ${root.given} changed since the command started: ${root.anchor} is now ${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a different directory" : "not a directory"}; refusing to write outside the authorised boundary`, + }); + } +} + +/** + * Resolve `target` (file or directory) against cwd and prove it lies inside + * `root`. A symlink at the leaf is rejected for write targets: replacing or + * following it could land the bytes outside the root. + * + * With an `AuthorisedRoot` the root is not resolved again: its frozen real path + * is the boundary and its identity is re-proven first. A plain string root is + * resolved here (for one-shot local checks with no request in between). + */ +export function resolveWithinRoot( + target: string, + root: string | AuthorisedRoot, + opts: { cwd?: string; allowSymlinkLeaf?: boolean; label?: string } = {}, +): ResolvedTarget { + const cwd = opts.cwd ?? process.cwd(); + const label = opts.label ?? "path"; + let rootReal: string; + if (typeof root === "string") { + rootReal = realpathLenient(resolvePath(cwd, root)); + } else { + assertRootIntact(root); + rootReal = root.real; + } + const absTarget = resolvePath(cwd, target); + let leaf: ReturnType | null = null; + try { + leaf = lstatSync(absTarget); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw err; + } + if (leaf?.isSymbolicLink() && !opts.allowSymlinkLeaf) { + throw new CliError({ + code: "local_io", + message: `${label} ${target} is a symbolic link; refusing to write through it`, + }); + } + const real = realpathLenient(absTarget); + if (!isInside(rootReal, real)) { + throw new CliError({ + code: "local_io", + message: `${label} ${target} resolves outside the authorised root ${rootReal}`, + }); + } + return { path: real, root: rootReal, relative: relative(rootReal, real).split(sep).join("/") }; +} + +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; + +/** + * Turn an arbitrary string (asset key, task id, project name) into a single + * safe path segment: lowercase-insensitive ASCII letters, digits, `.`, `_`, + * `-`; everything else becomes `_`; no leading dots, no reserved names, no + * empty result. + */ +export function safeSegment(input: string, fallback = "item"): string { + let s = input.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "").replace(/\.+$/, ""); + s = s.replace(/_{2,}/g, "_"); + if (!s) s = fallback; + if (WINDOWS_RESERVED.test(s)) s = `_${s}`; + return s.slice(0, 120); +} + +/** Lowercase file extension without the dot, or "" when the name has none or it is not a plain token. */ +export function safeExtension(ext: string): string { + const e = ext.replace(/^\./, "").toLowerCase(); + return /^[a-z0-9]{1,8}$/.test(e) ? e : ""; +} diff --git a/src/internal/payload.ts b/src/internal/payload.ts index 6ea0adb..a8c3a8f 100644 --- a/src/internal/payload.ts +++ b/src/internal/payload.ts @@ -2,23 +2,40 @@ * Build a request payload from structured CLI flags and optional raw JSON. * Merge order (later wins): CLI defaults < --data < flag-supplied fields — * pinned defaults must never clobber an explicit --data payload. + * + * Merging is shallow on purpose: arrays and plain fields replace wholesale so a + * `--data` array is exactly what is sent. The one exception is the small set of + * nested option objects a resource declares (`nestedObjectKeys`, e.g. Creative + * Lab `options`/`output`): those are merged field by field across the same + * layers, so `--data '{"options":{...}}'` and `--options '{...}'` compose + * instead of the later one silently deleting the earlier one's settings. */ import { readFileSync } from "node:fs"; +import { UsageError } from "./errors.js"; export function parseJsonFlag(raw: string | undefined, flag: string): Record { if (!raw) return {}; const trimmed = raw.trim(); - const text = trimmed.startsWith("@") ? readFileSync(trimmed.slice(1), "utf8") : trimmed; + let text: string; + if (trimmed.startsWith("@")) { + try { + text = readFileSync(trimmed.slice(1), "utf8"); + } catch (err) { + throw new UsageError(`${flag}: cannot read ${trimmed.slice(1)}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + text = trimmed; + } let parsed: unknown; try { parsed = JSON.parse(text); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error(`invalid JSON passed to ${flag}: ${msg}`); + throw new UsageError(`invalid JSON passed to ${flag}: ${msg}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`${flag} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed})`); + throw new UsageError(`${flag} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed})`); } return parsed as Record; } @@ -40,3 +57,34 @@ export function mergePayload(...layers: Record[]): Record { + return Boolean(v) && typeof v === "object" && !Array.isArray(v); +} + +/** + * Field-level merge for the declared nested object keys, applied on top of a + * shallow `mergePayload` result. Layers are ordered weakest → strongest; within + * a key, a later layer's fields win, unspecified fields survive, and explicit + * `false`/`0`/`""` are kept. If the strongest layer that names the key is not + * an object (a deliberate replacement), the shallow result stands. + */ +export function mergeNestedObjects( + merged: Record, + layers: readonly Record[], + keys: readonly string[], +): Record { + const out: Record = { ...merged }; + for (const key of keys) { + const present = layers.filter((l) => l[key] !== undefined && l[key] !== null); + if (present.length === 0) continue; + const strongest = present[present.length - 1]!; + if (!isPlainObject(strongest[key])) continue; + const combined: Record = {}; + for (const layer of present) { + if (isPlainObject(layer[key])) Object.assign(combined, dropNullish(layer[key])); + } + out[key] = combined; + } + return out; +} diff --git a/src/internal/poll.ts b/src/internal/poll.ts index 50fc4e3..78b2444 100644 --- a/src/internal/poll.ts +++ b/src/internal/poll.ts @@ -1,20 +1,70 @@ /** - * Poll a task endpoint until it reaches a terminal status or the deadline hits. - * Backoff grows 1.5× per iteration up to a 20s cap. + * Poll a task endpoint until it reaches a terminal status or the deadline + * hits. Backoff grows 1.5× per iteration up to a 20s cap. + * + * The deadline is monotonic and binds everything: every GET is issued with a + * request timeout no larger than the remaining budget (headers *and* body), + * the cancellable sleep never overshoots it, and no request is started once it + * has passed. A response that arrives after the budget therefore cannot be + * reported as an in-time success, and the loop never spends "one more GET" + * after the caller's deadline. `--timeout 0` is the one exception by design: + * exactly one query, bounded by the transport's own read timeout, returning + * whatever status it saw. */ import type { TaskEndpoint } from "../client/endpoints/base.js"; -import { TERMINAL_STATUSES, type Task } from "../client/types.js"; +import { TransportError } from "../client/transport.js"; +import { isTerminalStatus, type Task } from "../client/types.js"; +import { UsageError } from "./errors.js"; export interface PollOptions { timeoutSeconds: number; intervalMs: number; - onTick?: (task: Task) => void; + /** Transport read timeout; each request is capped at min(remaining budget, this). */ + requestTimeoutMs?: number; + onTick?: (task: Task, raw: unknown) => void; + signal?: AbortSignal; + /** Test hook: injectable clock and sleep. */ + now?: () => number; + sleep?: (ms: number, signal?: AbortSignal) => Promise; } export interface PollResult { - task: Task; + /** + * The last task seen. `null` only when the deadline or the abort signal hit + * before the first response arrived — the caller still knows the task id. + */ + task: Task | null; + raw: unknown; timedOut: boolean; + polls: number; + /** True when the external signal fired before a terminal status. */ + aborted: boolean; +} + +/** Accept only finite, non-negative seconds. NaN/Infinity/negative are usage errors, never "wait forever". */ +export function parseTimeoutSeconds(raw: unknown, flag = "--timeout"): number { + const text = String(raw ?? "").trim(); + const n = typeof raw === "number" ? raw : Number(text); + if (text === "" || !Number.isFinite(n) || n < 0) { + throw new UsageError(`${flag} must be a finite number of seconds >= 0 (got '${String(raw)}')`); + } + return n; +} + +export function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + function onAbort(): void { + clearTimeout(timer); + resolve(); + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); } export async function pollUntilTerminal( @@ -22,24 +72,64 @@ export async function pollUntilTerminal( taskId: string, opts: PollOptions, ): Promise { - const deadline = Date.now() + Math.max(0, opts.timeoutSeconds) * 1000; + const now = opts.now ?? (() => performance.now()); + const sleep = opts.sleep ?? sleepWithSignal; + const single = opts.timeoutSeconds === 0; + const deadline = now() + Math.max(0, opts.timeoutSeconds) * 1000; const baseInterval = Math.max(250, opts.intervalMs); let interval = baseInterval; const cap = Math.max(baseInterval, 20_000); + let polls = 0; + let last: { task: Task; raw: unknown } | null = null; - while (true) { - const task = await endpoint.retrieve(taskId); - opts.onTick?.(task); - if (TERMINAL_STATUSES.has(task.status)) { - return { task, timedOut: false }; + const done = (timedOut: boolean, aborted: boolean): PollResult => ({ + task: last?.task ?? null, + raw: last?.raw ?? null, + timedOut, + polls, + aborted, + }); + + for (;;) { + const remaining = deadline - now(); + if (!single && polls > 0 && remaining <= 0) return done(true, false); + if (opts.signal?.aborted) return done(false, true); + + // Bound this request by what is left of the budget (and the transport's own + // read timeout, whichever is smaller). A deadline-bound request that times + // out *is* the deadline; a read-timeout-bound one is a network failure. + const requestCap = opts.requestTimeoutMs; + let timeoutMs: number | undefined; + let deadlineBound = false; + if (single) { + timeoutMs = requestCap; + } else if (requestCap === undefined || remaining <= requestCap) { + timeoutMs = Math.max(1, Math.ceil(remaining)); + deadlineBound = true; + } else { + timeoutMs = requestCap; + } + + let res: { task: Task; raw: unknown }; + try { + res = await endpoint.retrieveDetailed(taskId, { signal: opts.signal, timeoutMs }); + } catch (err) { + if (opts.signal?.aborted) return done(false, true); + if (deadlineBound && err instanceof TransportError && (err.phase === "timeout" || err.phase === "aborted")) { + return done(true, false); + } + throw err; } - const remaining = deadline - Date.now(); - if (remaining <= 0) return { task, timedOut: true }; - await sleep(Math.min(interval, Math.max(remaining, 100))); + polls += 1; + last = res; + opts.onTick?.(res.task, res.raw); + if (isTerminalStatus(res.task.status)) return done(false, false); + if (opts.signal?.aborted) return done(false, true); + if (single) return done(true, false); + const left = deadline - now(); + if (left <= 0) return done(true, false); + await sleep(Math.max(1, Math.min(interval, left)), opts.signal); + if (opts.signal?.aborted) return done(false, true); interval = Math.min(cap, Math.floor(interval * 1.5)); } } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts new file mode 100644 index 0000000..b712a28 --- /dev/null +++ b/src/internal/project-store.ts @@ -0,0 +1,558 @@ +/** + * Project store — the `meshy_output/` layout the Skills established. + * + * /history.json index of projects (rebuildable) + * //metadata.json the facts about one project + * //task_.json task snapshots saved by --project + * // + * + * metadata.json is the source of truth; history.json is an index derived + * from it. They are two files, so no cross-file transaction is claimed: + * `recordTask` commits metadata under the project lock, releases it, then + * takes the root lock to refresh the index. When the second step fails the + * caller gets `index: { updated: false }` and `rebuild-index` repairs it. + * Locks are never nested in the other order. + * + * Legacy files written by the Python helper (no schema_version, tasks with + * only task_id/task_type/stage/files/created_at) are read as v1 and migrated + * on the first write with a `.bak-` copy; unknown fields survive. + * The CLI's own download `meta.json` is a third format and never written here. + */ + +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, copyFileSync } from "node:fs"; +import { basename, join, relative, resolve as resolvePath, sep } from "node:path"; +import { randomBytes } from "node:crypto"; +import { writeJsonFile } from "./atomic-file.js"; +import { CliError, UsageError } from "./errors.js"; +import { withFileLock } from "./lock.js"; +import { realpathLenient, resolveWithinRoot, safeSegment, type AuthorisedRoot } from "./paths.js"; + +export const METADATA_SCHEMA_VERSION = 2; +export const HISTORY_VERSION = 1; + +export interface ProjectTaskEntry { + task_id: string; + task_type: string | null; + resource: string | null; + endpoint: string | null; + stage: string; + parent_task_id: string | null; + status: string | null; + files: string[]; + task_json: string | null; + operation_id: string | null; + created_at: string; + updated_at: string; + [extra: string]: unknown; +} + +export interface ProjectMetadata { + schema_version: number; + project_name: string; + folder: string; + root_task_id: string | null; + created_at: string; + updated_at: string; + tasks: ProjectTaskEntry[]; + [extra: string]: unknown; +} + +export interface HistoryEntry { + folder: string; + prompt: string; + task_type: string; + root_task_id: string | null; + created_at: string; + updated_at: string; + task_count: number; + [extra: string]: unknown; +} + +export interface HistoryFile { + version: number; + projects: HistoryEntry[]; + [extra: string]: unknown; +} + +export interface RecordInput { + taskId: string; + stage: string; + resource?: string | null; + taskType?: string | null; + endpoint?: string | null; + parentTaskId?: string | null; + status?: string | null; + files?: string[]; + taskJson?: string | null; + operationId?: string | null; +} + +export interface StoreOptions { + now?: () => Date; + lockTimeoutMs?: number; +} + +/** + * Where a project's history index lives (its parent directory unless the + * caller named a root) and whether this invocation may write there. With an + * explicit --workspace the index root must resolve inside it; when it does not + * (the workspace *is* the project directory), metadata is still recorded and + * the index is left alone with an explicit reason — nothing is ever written, + * locked or temp-filed outside the workspace. + */ +export function indexRootFor(projectDir: string, explicitRoot: string | undefined, workspace: string | AuthorisedRoot | undefined): { root: string; skipIndex?: string } { + const root = explicitRoot !== undefined ? resolvePath(explicitRoot) : resolvePath(projectDir, ".."); + if (!workspace) return { root }; + const workspacePath = typeof workspace === "string" ? resolvePath(workspace) : workspace.given; + try { + resolveWithinRoot(root, workspace, { label: "history root" }); + return { root }; + } catch (err) { + return { root, skipIndex: `history root ${root} resolves outside --workspace ${workspacePath}; metadata.json was recorded but history.json was not touched (${err instanceof Error ? err.message : String(err)}) — run \`meshy project rebuild-index --root ${root}\` from a workspace that contains it` }; + } +} + +const ISO = (d: Date) => d.toISOString(); + +function projectLock(projectDir: string): string { + return join(projectDir, ".meshy.lock"); +} + +function rootLock(root: string): string { + return join(root, ".meshy-history.lock"); +} + +export function metadataPath(projectDir: string): string { + return join(projectDir, "metadata.json"); +} + +export function historyPath(root: string): string { + return join(root, "history.json"); +} + +/** Folder name: `YYYYMMDD_HHmmss__` (legacy shape, collision-safe). */ +export function projectFolderName(name: string, taskId: string | null, now: Date, random: () => string = () => randomBytes(2).toString("hex")): string { + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30).replace(/-+$/g, ""); + if (!slug) slug = "project"; + const suffix = taskId ? safeSegment(taskId).slice(0, 8) : random(); + return safeSegment(`${stamp}_${slug}_${suffix}`, "project"); +} + +/** A metadata/history file path entry must be a plain relative path inside its base. */ +export function assertSafeRelativeFile(rel: string, label: string): string { + if (!rel || rel.includes("\0")) throw new UsageError(`${label}: empty or invalid path`); + const normalized = rel.split(/[\\/]/); + if (normalized.some((s) => s === ".." || s === "")) throw new UsageError(`${label}: '${rel}' must be a relative path inside the project (no '..', no absolute path)`); + if (/^[A-Za-z]:/.test(rel) || rel.startsWith("/") || rel.startsWith("\\")) throw new UsageError(`${label}: '${rel}' must be relative to the project directory`); + return normalized.join("/"); +} + +function readJsonFile(path: string): unknown { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new CliError({ code: "local_io", message: `cannot read ${path}: ${(err as Error).message}` }); + } + try { + return JSON.parse(text); + } catch (err) { + throw new CliError({ code: "local_io", message: `${path} is not valid JSON (${(err as Error).message}); refusing to overwrite a damaged file — repair or move it first` }); + } +} + +/** Normalise any metadata.json (legacy v1 or v2) into the v2 view without touching disk. */ +export function normalizeMetadata(raw: unknown, folder: string): { metadata: ProjectMetadata; legacy: boolean } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new CliError({ code: "local_io", message: `metadata.json in ${folder} is not a JSON object` }); + } + const o = raw as Record; + const legacy = typeof o["schema_version"] !== "number"; + const tasksRaw = Array.isArray(o["tasks"]) ? (o["tasks"] as unknown[]) : []; + const tasks: ProjectTaskEntry[] = tasksRaw + .filter((t): t is Record => Boolean(t) && typeof t === "object" && !Array.isArray(t)) + .map((t) => { + const created = typeof t["created_at"] === "string" ? (t["created_at"] as string) : ""; + return { + ...t, + task_id: String(t["task_id"] ?? ""), + task_type: typeof t["task_type"] === "string" ? (t["task_type"] as string) : null, + resource: typeof t["resource"] === "string" ? (t["resource"] as string) : null, + endpoint: typeof t["endpoint"] === "string" ? (t["endpoint"] as string) : null, + stage: typeof t["stage"] === "string" ? (t["stage"] as string) : "unknown", + parent_task_id: typeof t["parent_task_id"] === "string" ? (t["parent_task_id"] as string) : null, + status: typeof t["status"] === "string" ? (t["status"] as string) : null, + files: Array.isArray(t["files"]) ? (t["files"] as unknown[]).filter((f): f is string => typeof f === "string") : [], + task_json: typeof t["task_json"] === "string" ? (t["task_json"] as string) : null, + operation_id: typeof t["operation_id"] === "string" ? (t["operation_id"] as string) : null, + created_at: created, + updated_at: typeof t["updated_at"] === "string" ? (t["updated_at"] as string) : created, + }; + }); + const metadata: ProjectMetadata = { + ...o, + schema_version: legacy ? METADATA_SCHEMA_VERSION : (o["schema_version"] as number), + project_name: typeof o["project_name"] === "string" ? (o["project_name"] as string) : folder, + folder: typeof o["folder"] === "string" ? (o["folder"] as string) : folder, + root_task_id: typeof o["root_task_id"] === "string" ? (o["root_task_id"] as string) : null, + created_at: typeof o["created_at"] === "string" ? (o["created_at"] as string) : "", + updated_at: typeof o["updated_at"] === "string" ? (o["updated_at"] as string) : "", + tasks, + }; + return { metadata, legacy }; +} + +export interface ProjectRead { + path: string; + metadata: ProjectMetadata; + legacy: boolean; + exists: boolean; +} + +export function readProject(projectDir: string): ProjectRead { + const dir = resolvePath(projectDir); + const raw = readJsonFile(metadataPath(dir)); + if (raw === undefined) { + throw new CliError({ code: "not_found", message: `${dir} has no metadata.json; run \`meshy project init\` or pass an existing project directory` }); + } + const { metadata, legacy } = normalizeMetadata(raw, basename(dir)); + return { path: dir, metadata, legacy, exists: true }; +} + +function writeMetadata(dir: string, metadata: ProjectMetadata, wasLegacy: boolean, now: Date): void { + const target = metadataPath(dir); + if (wasLegacy && existsSync(target)) { + const backup = `${target}.bak-${ISO(now).replace(/[:.]/g, "-")}`; + copyFileSync(target, backup); + } + writeJsonFile(target, metadata, { overwrite: true, mode: 0o644 }); +} + +export interface InitResult { + root: string; + project_dir: string; + folder: string; + metadata: ProjectMetadata; + index: { updated: boolean; error: string | null }; +} + +export function initProject(root: string, opts: { name?: string; taskId?: string | null; taskType?: string | null } & StoreOptions = {}): InitResult { + const now = (opts.now ?? (() => new Date()))(); + const rootAbs = resolvePath(root); + mkdirSync(rootAbs, { recursive: true }); + const name = (opts.name ?? opts.taskType ?? "model").trim() || "model"; + let folder = projectFolderName(name, opts.taskId ?? null, now); + let dir = join(rootAbs, folder); + while (existsSync(dir)) { + folder = safeSegment(`${folder}-${randomBytes(2).toString("hex")}`); + dir = join(rootAbs, folder); + } + resolveWithinRoot(dir, rootAbs, { label: "project directory" }); + mkdirSync(dir, { recursive: false }); + const metadata: ProjectMetadata = { + schema_version: METADATA_SCHEMA_VERSION, + project_name: name, + folder, + root_task_id: opts.taskId ?? null, + created_at: ISO(now), + updated_at: ISO(now), + tasks: [], + }; + withFileLock(projectLock(dir), () => writeMetadata(dir, metadata, false, now), { timeoutMs: opts.lockTimeoutMs }); + const index = refreshIndexEntry(rootAbs, dir, metadata, opts); + return { root: rootAbs, project_dir: dir, folder, metadata, index }; +} + +export interface RecordResult { + project_dir: string; + metadata: ProjectMetadata; + entry: ProjectTaskEntry; + action: "added" | "merged"; + migrated_from_legacy: boolean; + index: { updated: boolean; error: string | null }; +} + +/** + * Add or merge a task record. The de-duplication key is (task_id, stage): + * a repeat merges files (union) and refreshes status/task_json instead of + * appending a duplicate entry. + */ +export function recordTask(projectDir: string, input: RecordInput, opts: StoreOptions & { root?: string; skipIndex?: string } = {}): RecordResult { + const now = (opts.now ?? (() => new Date()))(); + const dir = resolvePath(projectDir); + if (!input.taskId) throw new UsageError("--task-id is required"); + if (!input.stage) throw new UsageError("--stage is required"); + const files = (input.files ?? []).map((f) => assertSafeRelativeFile(f, "--file")); + if (input.taskJson) assertSafeRelativeFile(input.taskJson, "task_json"); + + const result = withFileLock( + projectLock(dir), + () => { + const raw = readJsonFile(metadataPath(dir)); + if (raw === undefined) { + throw new CliError({ code: "not_found", message: `${dir} has no metadata.json; run \`meshy project init\` first` }); + } + const { metadata, legacy } = normalizeMetadata(raw, basename(dir)); + const existing = metadata.tasks.find((t) => t.task_id === input.taskId && t.stage === input.stage); + let entry: ProjectTaskEntry; + let action: "added" | "merged"; + if (existing) { + action = "merged"; + existing.files = [...new Set([...existing.files, ...files])]; + if (input.status !== undefined && input.status !== null) existing.status = input.status; + if (input.taskJson) existing.task_json = input.taskJson; + if (input.resource) existing.resource = input.resource; + if (input.taskType) existing.task_type = input.taskType; + if (input.endpoint) existing.endpoint = input.endpoint; + if (input.parentTaskId) existing.parent_task_id = input.parentTaskId; + if (input.operationId) existing.operation_id = input.operationId; + existing.updated_at = ISO(now); + entry = existing; + } else { + action = "added"; + entry = { + task_id: input.taskId, + task_type: input.taskType ?? input.resource ?? null, + resource: input.resource ?? null, + endpoint: input.endpoint ?? null, + stage: input.stage, + parent_task_id: input.parentTaskId ?? null, + status: input.status ?? null, + files, + task_json: input.taskJson ?? null, + operation_id: input.operationId ?? null, + created_at: ISO(now), + updated_at: ISO(now), + }; + metadata.tasks.push(entry); + } + if (!metadata.root_task_id) metadata.root_task_id = input.taskId; + metadata.updated_at = ISO(now); + writeMetadata(dir, metadata, legacy, now); + return { metadata, entry, action, legacy }; + }, + { timeoutMs: opts.lockTimeoutMs }, + ); + + const root = opts.root ? resolvePath(opts.root) : resolvePath(dir, ".."); + const index = opts.skipIndex ? { updated: false, error: opts.skipIndex } : refreshIndexEntry(root, dir, result.metadata, opts); + return { project_dir: dir, metadata: result.metadata, entry: result.entry, action: result.action, migrated_from_legacy: result.legacy, index }; +} + +function historyEntryFor(metadata: ProjectMetadata, folder: string): HistoryEntry { + const first = metadata.tasks[0]; + return { + folder, + prompt: metadata.project_name, + task_type: first?.task_type ?? first?.resource ?? "", + root_task_id: metadata.root_task_id, + created_at: metadata.created_at, + updated_at: metadata.updated_at, + task_count: metadata.tasks.length, + }; +} + +/** Second phase of a record: refresh this project's row in history.json under the root lock. Never throws. */ +function refreshIndexEntry(root: string, projectDir: string, metadata: ProjectMetadata, opts: StoreOptions): { updated: boolean; error: string | null } { + try { + const folder = basename(projectDir); + withFileLock( + rootLock(root), + () => { + const raw = readJsonFile(historyPath(root)); + let history: HistoryFile; + if (raw === undefined) history = { version: HISTORY_VERSION, projects: [] }; + else if (raw && typeof raw === "object" && !Array.isArray(raw) && Array.isArray((raw as HistoryFile).projects)) history = raw as HistoryFile; + else throw new CliError({ code: "local_io", message: `${historyPath(root)} is not a history index ({version, projects[]}); refusing to overwrite it` }); + const entry = historyEntryFor(metadata, folder); + const idx = history.projects.findIndex((p) => p.folder === folder); + if (idx === -1) history.projects.push(entry); + else history.projects[idx] = { ...history.projects[idx], ...entry }; + writeJsonFile(historyPath(root), history, { overwrite: true, mode: 0o644 }); + }, + { timeoutMs: opts.lockTimeoutMs }, + ); + return { updated: true, error: null }; + } catch (err) { + return { updated: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export interface ListResult { + root: string; + history_path: string; + history_present: boolean; + projects: Array; + unindexed_folders: string[]; + index_dirty: boolean; +} + +/** Read the index and compare it with the folders that actually carry a metadata.json. */ +export function listProjects(root: string): ListResult { + const rootAbs = resolvePath(root); + const raw = readJsonFile(historyPath(rootAbs)); + let history: HistoryFile | null = null; + if (raw !== undefined) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray((raw as HistoryFile).projects)) { + throw new CliError({ code: "local_io", message: `${historyPath(rootAbs)} is not a history index; run \`meshy project rebuild-index\` after moving the damaged file aside` }); + } + history = raw as HistoryFile; + } + const folders = existsSync(rootAbs) + ? readdirSync(rootAbs).filter((f) => { + try { + return statSync(join(rootAbs, f)).isDirectory() && existsSync(metadataPath(join(rootAbs, f))); + } catch { + return false; + } + }) + : []; + const indexed = new Set(); + const projects = (history?.projects ?? []).map((p) => { + const safe = typeof p.folder === "string" && !/[\\/]/.test(p.folder) && p.folder !== ".." && p.folder !== "."; + const present = safe && existsSync(metadataPath(join(rootAbs, p.folder))); + if (safe) indexed.add(p.folder); + return { ...p, present }; + }); + const unindexed = folders.filter((f) => !indexed.has(f)); + return { + root: rootAbs, + history_path: historyPath(rootAbs), + history_present: history !== null, + projects, + unindexed_folders: unindexed, + index_dirty: unindexed.length > 0 || projects.some((p) => !p.present), + }; +} + +export interface RebuildResult { + root: string; + history_path: string; + indexed: number; + skipped: Array<{ folder: string; reason: string }>; + backup: string | null; +} + +/** Regenerate history.json from the project folders; a damaged index is backed up, never silently replaced. */ +export function rebuildIndex(root: string, opts: StoreOptions = {}): RebuildResult { + const now = (opts.now ?? (() => new Date()))(); + const rootAbs = resolvePath(root); + if (!existsSync(rootAbs)) throw new CliError({ code: "not_found", message: `${rootAbs} does not exist` }); + const skipped: Array<{ folder: string; reason: string }> = []; + const entries: HistoryEntry[] = []; + const rootReal = realpathLenient(rootAbs); + for (const f of readdirSync(rootAbs).sort()) { + const dir = join(rootAbs, f); + try { + if (!statSync(dir).isDirectory()) continue; + } catch { + continue; + } + if (!existsSync(metadataPath(dir))) continue; + try { + resolveWithinRoot(dir, rootReal, { label: "project folder", allowSymlinkLeaf: false }); + } catch (err) { + skipped.push({ folder: f, reason: err instanceof Error ? err.message : String(err) }); + continue; + } + try { + const raw = readJsonFile(metadataPath(dir)); + const { metadata } = normalizeMetadata(raw, f); + entries.push(historyEntryFor(metadata, f)); + } catch (err) { + skipped.push({ folder: f, reason: err instanceof Error ? err.message : String(err) }); + } + } + entries.sort((a, b) => a.created_at.localeCompare(b.created_at)); + let backup: string | null = null; + withFileLock(rootLock(rootAbs), () => { + const hp = historyPath(rootAbs); + if (existsSync(hp)) { + backup = `${hp}.bak-${ISO(now).replace(/[:.]/g, "-")}`; + copyFileSync(hp, backup); + } + writeJsonFile(hp, { version: HISTORY_VERSION, projects: entries }, { overwrite: true, mode: 0o644 }); + }, { timeoutMs: opts.lockTimeoutMs }); + return { root: rootAbs, history_path: historyPath(rootAbs), indexed: entries.length, skipped, backup }; +} + +/** Save a task snapshot as `task_.json` inside the project (overwrites an older snapshot of the same task). */ +export function saveTaskSnapshot(projectDir: string, taskId: string, raw: unknown): { path: string; relative: string } { + const dir = resolvePath(projectDir); + const name = `task_${safeSegment(taskId, "task")}.json`; + // Both sides of the relative path come from the same (real-path) frame, so a + // symlinked temp dir (macOS /var → /private/var) cannot turn "task_x.json" + // into a ../.. path that the metadata store then rightly refuses. + const resolved = resolveWithinRoot(join(dir, name), dir, { label: "task snapshot" }); + writeJsonFile(resolved.path, raw, { overwrite: true, mode: 0o600 }); + return { path: resolved.path, relative: relative(resolved.root, resolved.path).split(sep).join("/") }; +} + +/** Quote one argument for a command a human can paste into a shell; plain tokens stay bare. */ +function shellArg(value: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + +/** + * A project that was initialised when a command started must still be one when + * its record is written. metadata.json missing or replaced by something that is + * not a regular file is a local condition of the project — reported as + * `local_io`, never as an API "not found" — and the caller says how to redo the + * record once the project is restored. + */ +export function assertProjectMetadataPresent(projectDir: string, flag: string): void { + const target = metadataPath(projectDir); + let st: ReturnType; + try { + st = lstatSync(target); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new CliError({ + code: "local_io", + message: `--project ${flag} has no metadata.json any more (it was an initialised project when this command started)`, + cause: err, + }); + } + throw err; + } + if (!st.isFile()) { + throw new CliError({ + code: "local_io", + message: `--project ${flag}: metadata.json is not a regular file (${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a directory" : "special"}); refusing to write through it`, + }); + } +} + +/** + * The `meshy project record` invocation that redoes exactly one thing: the + * metadata entry a command could not write. Assets, task and journal are left + * alone — the caller runs it once the project directory is repaired. The + * original write boundary (`--workspace`, resolved to an absolute path) travels + * with the command: a recovery never reaches further than the invocation that + * failed, so a workspace equal to the project still leaves the parent's + * history index alone (`index_dirty`), exactly as the original would have. + */ +export function projectRecordCommand(projectDir: string, input: RecordInput, opts: { root?: string; workspace?: string } = {}): string { + const parts = ["meshy", "project", "record", "--project", shellArg(projectDir), "--task-id", shellArg(input.taskId), "--stage", shellArg(input.stage)]; + if (input.resource) parts.push("--resource", shellArg(input.resource)); + if (input.taskType) parts.push("--task-type", shellArg(input.taskType)); + if (input.parentTaskId) parts.push("--parent-task-id", shellArg(input.parentTaskId)); + if (input.status) parts.push("--status", shellArg(input.status)); + for (const f of input.files ?? []) parts.push("--file", shellArg(f)); + if (input.taskJson) parts.push("--task-json", shellArg(input.taskJson)); + if (input.operationId) parts.push("--operation-id", shellArg(input.operationId)); + if (opts.root) parts.push("--root", shellArg(opts.root)); + if (opts.workspace) parts.push("--workspace", shellArg(resolvePath(opts.workspace))); + return parts.join(" "); +} + +/** Derive a stage name from a task type (`text-to-3d-preview` → preview, `creative-lab-lamp-build` → build). */ +export function stageFromTaskType(type: unknown, fallback: string): string { + if (typeof type !== "string" || !type) return fallback; + const m = /-(preview|refine|prototype|build)$/.exec(type); + return m ? m[1]! : fallback; +} diff --git a/src/internal/result.ts b/src/internal/result.ts new file mode 100644 index 0000000..e11df89 --- /dev/null +++ b/src/internal/result.ts @@ -0,0 +1,78 @@ +/** + * The `meshy.cli/v1` machine contract. + * + * Every v1 command prints exactly one envelope on stdout (stream mode prints + * one per event, see StreamEventEnvelope). The six top-level keys never + * change; `result` is shaped per command. `ok` reports whether the CLI + * operation completed — a `get` that returns a FAILED task is `ok:true`, a + * `wait` that ends on a FAILED task is `ok:false` with the task preserved. + */ + +import { classifyError, type ErrorRecovery, type Warning } from "./errors.js"; + +export type { Warning } from "./errors.js"; + +export const SCHEMA_VERSION = "meshy.cli/v1"; + +export type OutputSchema = "legacy" | "v1"; + +export interface V1Error { + code: string; + message: string; + http_status: number | null; + retryable: boolean; + recovery: ErrorRecovery | null; + hint?: string; + details?: unknown; +} + +export interface V1Envelope { + schema_version: typeof SCHEMA_VERSION; + command: string; + ok: boolean; + result: R | null; + error: V1Error | null; + warnings: Warning[]; +} + +/** ndjson stream events add a local sequence and the event kind. */ +export interface StreamEventEnvelope extends V1Envelope { + event: "task" | "outcome" | "warning"; + sequence: number; +} + +export function okEnvelope(command: string, result: R, warnings: Warning[] = []): V1Envelope { + return { schema_version: SCHEMA_VERSION, command, ok: true, result, error: null, warnings }; +} + +/** + * Build a failure envelope from any thrown value. A partial `result` supplied + * by the caller wins over the error's own; both are kept when the caller passes + * nothing (a CliError carries the state that must outlive the failure). + */ +export function errorEnvelope( + command: string, + err: unknown, + opts: { result?: unknown; warnings?: Warning[] } = {}, +): { envelope: V1Envelope; exitCode: number } { + const c = classifyError(err); + const error: V1Error = { + code: c.code, + message: c.message, + http_status: c.httpStatus, + retryable: c.retryable, + recovery: c.recovery, + ...(c.hint ? { hint: c.hint } : {}), + ...(c.details !== undefined ? { details: c.details } : {}), + }; + const result = opts.result !== undefined ? opts.result : c.result; + const warnings = [...c.warnings, ...(opts.warnings ?? [])]; + return { + envelope: { schema_version: SCHEMA_VERSION, command, ok: false, result: result ?? null, error, warnings }, + exitCode: c.exitCode, + }; +} + +export function warning(code: string, message: string): Warning { + return { code, message }; +} diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 37d872b..e7822b2 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -1,28 +1,44 @@ /** - * Shared execution context bound to the root command — lazy-loads the client - * so sub-commands that don't need credentials (e.g. --help) still work. + * Execution contexts. * - * buildRuntime is async so it can silently refresh an expiring OAuth token - * before constructing the client. All callers are async command actions. + * buildRuntime — authenticated API runtime: resolves config (flags → + * env → env-file → stored profile), silently refreshes an + * expiring OAuth token, constructs the client. Built per + * call; nothing is cached across invocations so tests and + * chained commands never see a stale key/URL/format. + * buildLocalRuntime — local runtime for commands that must work without a + * credential or network: never touches the credential + * store, never refreshes anything. */ import { Command } from "commander"; +import { freezeRoot, type AuthorisedRoot } from "./paths.js"; import { MeshyClient } from "../client/index.js"; import { loadConfig, type ConfigOverrides, type MeshyConfig } from "./config.js"; import { credentialsPath, readCredentials, saveProfile } from "./credentials.js"; -import { authRequiredError } from "./errors.js"; -import { logger } from "./logger.js"; +import { authRequiredError, UsageError } from "./errors.js"; +import { logger, setLogLevel } from "./logger.js"; import { refreshTokens } from "./oauth.js"; import type { LogLevel } from "./logger.js"; import type { OutputFormat } from "./output.js"; +import type { OutputSchema } from "./result.js"; export interface GlobalFlags { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; format: OutputFormat; json?: boolean; + outputSchema?: OutputSchema; output?: string; + /** Path given to --api-key-file (only MESHY_API_KEY is read from it). */ + envFile?: string; + workspace?: string; + /** The --workspace frozen when the flags were read (real path + directory identity); every write is confined to it. */ + workspaceRoot?: AuthorisedRoot; + /** false when --no-update-check was given. */ + updateCheck: boolean; verbose: boolean; logLevel?: LogLevel; } @@ -33,7 +49,9 @@ export interface Runtime { readonly client: MeshyClient; } -let cached: Runtime | null = null; +export interface LocalRuntime { + readonly flags: GlobalFlags; +} /** 60-second skew window: refresh if token expires within this many ms. */ const REFRESH_SKEW_MS = 60_000; @@ -106,6 +124,7 @@ export async function refreshOAuthCredentialIfNeeded( refresh_token: tok.refresh_token, expires_at: Date.now() + tok.expires_in * 1000, user_id: tok.user_id ?? profile.user_id, + ...(profile.login_id ? { login_id: profile.login_id } : {}), created_at: profile.created_at, }, { makeActive: false }, @@ -141,8 +160,19 @@ export async function refreshOAuthCredentialIfNeeded( } } +export function configOverridesFrom(flags: GlobalFlags): ConfigOverrides { + return { + apiKey: flags.apiKey, + baseUrlV1: flags.baseUrlV1, + baseUrlV2: flags.baseUrlV2, + baseUrlCreativeLab: flags.baseUrlCreativeLab, + envFile: flags.envFile, + logLevel: flags.verbose ? "debug" : flags.logLevel, + }; +} + /** - * Build (or return the cached) runtime context. + * Build the authenticated runtime for this invocation. * * When the active credential is an OAuth profile with a refresh_token and the * access_token is within REFRESH_SKEW_MS of expiry (or already expired), this @@ -154,20 +184,21 @@ export async function refreshOAuthCredentialIfNeeded( * and refresh fails, throws authRequiredError pointing at `meshy auth login`. */ export async function buildRuntime(flags: GlobalFlags): Promise { - if (cached) return cached; - - const overrides: ConfigOverrides = { - apiKey: flags.apiKey, - baseUrlV1: flags.baseUrlV1, - baseUrlV2: flags.baseUrlV2, - logLevel: flags.verbose ? "debug" : flags.logLevel, - }; - + const overrides = configOverridesFrom(flags); const config = await refreshOAuthCredentialIfNeeded(loadConfig(overrides), overrides); - const client = new MeshyClient(config); - cached = { flags, config, client }; - return cached; + return { flags, config, client }; +} + +/** Runtime for commands that need neither a credential nor the API. */ +export function buildLocalRuntime(flags: GlobalFlags): LocalRuntime { + const envLevel = (process.env.MESHY_LOG_LEVEL || "").toLowerCase(); + const fallback: LogLevel = + envLevel === "debug" || envLevel === "info" || envLevel === "warn" || envLevel === "error" || envLevel === "silent" + ? (envLevel as LogLevel) + : "warn"; + setLogLevel(flags.verbose ? "debug" : flags.logLevel ?? fallback); + return { flags }; } /** Pull the resolved global flags from the top-level command. */ @@ -176,26 +207,67 @@ export function readGlobalFlags(cmd: Command): GlobalFlags { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; format?: string; json?: boolean; + outputSchema?: string; output?: string; + apiKeyFile?: string; + envFile?: string; + workspace?: string; + updateCheck?: boolean; verbose?: boolean; logLevel?: string; }>(); + if (opts.envFile !== undefined) { + throw new UsageError( + "--env-file is intercepted by Node.js itself (it loads the whole file into the environment before meshy-cli starts and exits 9 when the file is missing). Use --api-key-file ; only MESHY_API_KEY is read from it.", + ); + } // --json is an alias for --format json; --json wins if both are set. const format = opts.json ? "json" : normalizeFormat(opts.format); return { apiKey: opts.apiKey, baseUrlV1: opts.baseUrlV1, baseUrlV2: opts.baseUrlV2, + baseUrlCreativeLab: opts.baseUrlCreativeLab, format, json: opts.json, + outputSchema: normalizeSchema(opts.outputSchema), output: opts.output, + envFile: opts.apiKeyFile, + workspace: opts.workspace, + workspaceRoot: opts.workspace ? freezeRoot(opts.workspace, { label: "--workspace" }) : undefined, + updateCheck: opts.updateCheck !== false, verbose: Boolean(opts.verbose), logLevel: normalizeLogLevel(opts.logLevel), }; } +/** + * Decide which stdout data model a command uses. + * - `legacy` commands (everything 0.2.0 shipped) default to legacy and opt + * into v1 with `--output-schema v1`. + * - `v1` commands (everything new in S1) always emit v1; asking them for + * legacy is a usage error rather than a silent no-op. + */ +export function resolveSchema(flags: GlobalFlags, commandDefault: OutputSchema): OutputSchema { + if (commandDefault === "v1") { + if (flags.outputSchema === "legacy") { + throw new UsageError("--output-schema legacy is not available for this command; it only emits the v1 envelope"); + } + return "v1"; + } + return flags.outputSchema ?? "legacy"; +} + +function normalizeSchema(raw: string | undefined): OutputSchema | undefined { + if (raw === undefined) return undefined; + const v = raw.toLowerCase(); + if (v === "v1" || v === "legacy") return v; + throw new UsageError(`invalid --output-schema '${raw}'. Expected: legacy | v1`); +} + function normalizeFormat(raw: string | undefined): OutputFormat { const v = (raw ?? "json").toLowerCase(); if (v === "json" || v === "pretty" || v === "ndjson") return v; diff --git a/src/internal/slicers.ts b/src/internal/slicers.ts new file mode 100644 index 0000000..95d4927 --- /dev/null +++ b/src/internal/slicers.ts @@ -0,0 +1,408 @@ +/** + * Slicer registry, detection and launch — the legacy `slicers.py` helper. + * + * Seven slicers are registered with the install locations the skill used; the + * `multicolor` flag is capability metadata only (the slicer can drive + * multi-material prints — it says nothing about a particular printer). + * + * Detection is a pure function of an injectable DetectionEnv so every + * platform's rules are testable on any host: + * macOS — `/Applications/.app` and `~/Applications/.app` + * Windows — `\\`, where a `*` + * suffix on the registered directory matches versioned installs + * ("Creality Print 5.1", "UltiMaker Cura 5.7") + * Linux — the three executables the skill knew about, on PATH; the other + * four are reported as unsupported instead of guessed + * + * Launching only ever runs the detected path (or `open -a ` on macOS) + * with the model file as a single argv element: no shell, no string commands, + * no default-application fallback. The legacy Windows bug — detecting an exe + * under Program Files, then running a same-named exe from PATH — is exactly + * what the detected absolute path avoids. A spawn that fails is an error; a + * spawn that succeeds is `launch_requested`, never proof that the slicer + * imported the file. On macOS the short-lived `open` helper is awaited (bounded) + * for its exit status; GUI processes are never awaited. + */ + +import { spawn as nodeSpawn } from "node:child_process"; +import type { EventEmitter } from "node:events"; +import { accessSync, constants as fsConstants, existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, posix, resolve as resolvePath, win32 } from "node:path"; +import { CliError, UsageError } from "./errors.js"; + +export interface SlicerDescriptor { + /** Stable slug for scripts (`orca-slicer`). */ + id: string; + /** Display name, as the legacy skill spelled it (`OrcaSlicer`). */ + name: string; + /** macOS bundle name without `.app`. */ + macApp: string; + /** Windows executable file name. */ + winExe: string; + /** Windows directory under Program Files; a trailing `*` matches a version suffix. */ + winDir: string; + /** Linux executable expected on PATH, or null when the skill registered none. */ + linuxExe: string | null; + /** Capability metadata: the slicer supports multi-material / multi-colour prints. */ + multicolor: boolean; +} + +export const SLICERS: readonly SlicerDescriptor[] = [ + { id: "orca-slicer", name: "OrcaSlicer", macApp: "OrcaSlicer", winExe: "orca-slicer.exe", winDir: "OrcaSlicer", linuxExe: "orca-slicer", multicolor: true }, + { id: "bambu-studio", name: "Bambu Studio", macApp: "BambuStudio", winExe: "bambu-studio.exe", winDir: "BambuStudio", linuxExe: "bambu-studio", multicolor: true }, + { id: "creality-print", name: "Creality Print", macApp: "Creality Print", winExe: "CrealityPrint.exe", winDir: "Creality Print*", linuxExe: null, multicolor: true }, + { id: "elegoo-slicer", name: "Elegoo Slicer", macApp: "ElegooSlicer", winExe: "elegoo-slicer.exe", winDir: "ElegooSlicer", linuxExe: null, multicolor: true }, + { id: "anycubic-slicer-next", name: "Anycubic Slicer Next", macApp: "AnycubicSlicerNext", winExe: "AnycubicSlicerNext.exe", winDir: "AnycubicSlicerNext", linuxExe: null, multicolor: true }, + { id: "prusa-slicer", name: "PrusaSlicer", macApp: "PrusaSlicer", winExe: "prusa-slicer.exe", winDir: "PrusaSlicer", linuxExe: "prusa-slicer", multicolor: false }, + { id: "ultimaker-cura", name: "UltiMaker Cura", macApp: "UltiMaker Cura", winExe: "UltiMaker-Cura.exe", winDir: "UltiMaker Cura*", linuxExe: null, multicolor: false }, +]; + +/** File types a slicer can be asked to open. Anything else is a usage error. */ +export const LAUNCHABLE_EXTENSIONS: ReadonlySet = new Set(["obj", "stl", "3mf", "glb", "gltf", "step", "stp", "ply", "amf"]); + +export const NO_LINUX_EXECUTABLE = "no registered Linux executable"; + +export interface DetectionEnv { + platform: "darwin" | "win32" | "linux" | string; + env: Record; + home: string; + exists(path: string): boolean; + readdir(dir: string): string[]; + which(name: string): string | null; +} + +export interface DetectedSlicer { + id: string; + name: string; + path: string; + multicolor: boolean; + platform: string; +} + +export interface UnsupportedSlicer { + id: string; + name: string; + reason: string; +} + +export interface SlicerDetection { + platform: string; + slicers: DetectedSlicer[]; + unsupported: UnsupportedSlicer[]; +} + +/** Environment variables on Windows are case-insensitive; plain records in tests are not. */ +function envValue(env: Record, name: string): string | undefined { + if (env[name] !== undefined) return env[name]; + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(env)) { + if (key.toLowerCase() === lower) return value; + } + return undefined; +} + +function isExecutableFile(path: string, platform: string): boolean { + try { + if (!statSync(path).isFile()) return false; + if (platform !== "win32") accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** PATH lookup using the simulated platform's delimiter and PATHEXT rules. */ +function whichFromPath(env: Record, platform: string): (name: string) => string | null { + return (name) => { + const isWin = platform === "win32"; + const p = isWin ? win32 : posix; + const dirs = (envValue(env, "PATH") ?? "").split(isWin ? ";" : ":").filter(Boolean); + const exts = isWin ? (envValue(env, "PATHEXT") ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""]; + for (const dir of dirs) { + for (const ext of exts) { + const candidate = p.join(dir, ext && !name.toLowerCase().endsWith(ext.toLowerCase()) ? `${name}${ext}` : name); + if (isExecutableFile(candidate, platform)) return candidate; + } + } + return null; + }; +} + +/** The real environment, with any field replaced by the caller (tests simulate other platforms). */ +export function defaultDetectionEnv(overrides: Partial = {}): DetectionEnv { + const platform = overrides.platform ?? process.platform; + const env = overrides.env ?? (process.env as Record); + return { + platform, + env, + home: overrides.home ?? homedir(), + exists: overrides.exists ?? ((path) => existsSync(path)), + readdir: + overrides.readdir ?? + ((dir) => { + try { + return readdirSync(dir); + } catch { + return []; + } + }), + which: overrides.which ?? whichFromPath(env, platform), + }; +} + +interface Probe { + path: string | null; + checked: string[]; + unsupported: string | null; +} + +function probeDarwin(s: SlicerDescriptor, env: DetectionEnv): Probe { + const checked = [posix.join("/Applications", `${s.macApp}.app`), posix.join(env.home, "Applications", `${s.macApp}.app`)]; + for (const candidate of checked) { + if (env.exists(candidate)) return { path: candidate, checked, unsupported: null }; + } + return { path: null, checked, unsupported: null }; +} + +function probeWindows(s: SlicerDescriptor, env: DetectionEnv): Probe { + const bases = [ + envValue(env.env, "ProgramFiles") ?? "C:\\Program Files", + envValue(env.env, "ProgramFiles(x86)") ?? "C:\\Program Files (x86)", + ]; + const checked: string[] = []; + for (const base of bases) { + let dirs: string[]; + if (s.winDir.endsWith("*")) { + const prefix = s.winDir.slice(0, -1).toLowerCase(); + let entries: string[]; + try { + entries = env.readdir(base); + } catch { + entries = []; + } + // Highest version last; the newest install that actually holds the exe wins. + dirs = entries + .filter((entry) => entry.toLowerCase().startsWith(prefix)) + .sort((a, b) => a.localeCompare(b, "en", { numeric: true })) + .reverse(); + if (dirs.length === 0) checked.push(win32.join(base, s.winDir, s.winExe)); + } else { + dirs = [s.winDir]; + } + for (const dir of dirs) { + const candidate = win32.join(base, dir, s.winExe); + checked.push(candidate); + if (env.exists(candidate)) return { path: candidate, checked, unsupported: null }; + } + } + return { path: null, checked, unsupported: null }; +} + +function probeLinux(s: SlicerDescriptor, env: DetectionEnv): Probe { + if (s.linuxExe === null) return { path: null, checked: [], unsupported: NO_LINUX_EXECUTABLE }; + const found = env.which(s.linuxExe); + return { path: found, checked: [`${s.linuxExe} on PATH`], unsupported: null }; +} + +function probe(s: SlicerDescriptor, env: DetectionEnv): Probe { + switch (env.platform) { + case "darwin": + return probeDarwin(s, env); + case "win32": + return probeWindows(s, env); + case "linux": + return probeLinux(s, env); + default: + return { path: null, checked: [], unsupported: `no detection rule for platform '${env.platform}'` }; + } +} + +/** Detect installed slicers. An empty `slicers` list is a successful answer. */ +export function detectSlicers(env: Partial = {}): SlicerDetection { + const resolved = defaultDetectionEnv(env); + const detection: SlicerDetection = { platform: resolved.platform, slicers: [], unsupported: [] }; + for (const s of SLICERS) { + const result = probe(s, resolved); + if (result.path !== null) { + detection.slicers.push({ id: s.id, name: s.name, path: result.path, multicolor: s.multicolor, platform: resolved.platform }); + } else if (result.unsupported !== null) { + detection.unsupported.push({ id: s.id, name: s.name, reason: result.unsupported }); + } + } + return detection; +} + +/** Registered descriptor by display name or id, case-insensitive. */ +export function findSlicer(nameOrId: string): SlicerDescriptor | undefined { + const q = nameOrId.trim().toLowerCase(); + return SLICERS.find((s) => s.id === q || s.name.toLowerCase() === q); +} + +/** The subset of a child process the launcher relies on; node's ChildProcess satisfies it. */ +export type SpawnedChild = EventEmitter & { pid?: number | undefined; unref(): void }; + +export interface LaunchSpawnOptions { + detached: true; + stdio: "ignore"; + shell: false; +} + +export type SpawnFn = (command: string, args: readonly string[], options: LaunchSpawnOptions) => SpawnedChild; + +export interface SlicerLaunch { + launch_requested: true; + slicer: DetectedSlicer; + /** Absolute path handed to the slicer. */ + file: string; + pid: number | null; + argv: string[]; + platform: string; + /** macOS only: the `open` helper's exit status (null when it was still running at the deadline). */ + launcher: { command: string; exit_code: number | null } | null; +} + +export interface OpenInSlicerOptions { + detection?: SlicerDetection; + spawn?: SpawnFn; + env?: Partial; + cwd?: string; + /** How long to wait for macOS `open` to exit before reporting `exit_code: null` (default 5 s). */ + launcherTimeoutMs?: number; +} + +const DEFAULT_LAUNCHER_TIMEOUT_MS = 5_000; + +function awaitSpawned(child: SpawnedChild): Promise { + return new Promise((resolve, reject) => { + const onSpawn = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const cleanup = (): void => { + child.removeListener("spawn", onSpawn); + child.removeListener("error", onError); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +} + +function awaitExitBounded(child: SpawnedChild, timeoutMs: number): Promise { + return new Promise((resolve) => { + const onExit = (code: number | null): void => { + clearTimeout(timer); + resolve(code); + }; + const timer = setTimeout(() => { + child.removeListener("exit", onExit); + resolve(null); + }, timeoutMs); + child.once("exit", onExit); + }); +} + +/** + * Open `file` in a registered, detected slicer. Resolves as soon as the OS + * confirms the process started (plus, on macOS, when `open` reports back); + * it never waits for a GUI to close. + */ +export async function openInSlicer(file: string, slicerNameOrId: string, opts: OpenInSlicerOptions = {}): Promise { + const descriptor = findSlicer(slicerNameOrId); + if (!descriptor) { + const registered = SLICERS.map((s) => `${s.name} (${s.id})`).join(", "); + throw new UsageError(`unknown slicer '${slicerNameOrId}'. Registered slicers: ${registered}`); + } + + const detection = opts.detection ?? detectSlicers(opts.env); + const platform = detection.platform; + const detected = detection.slicers.find((s) => s.id === descriptor.id); + if (!detected) { + const unsupported = detection.unsupported.find((s) => s.id === descriptor.id); + const checked = probe(descriptor, defaultDetectionEnv({ ...opts.env, platform })).checked; + throw new CliError({ + code: "not_found", + message: unsupported + ? `${descriptor.name} cannot be detected on ${platform}: ${unsupported.reason}` + : `${descriptor.name} is not installed in a known location on ${platform}`, + hint: "meshy slicer detect --output-schema v1", + recovery: { action: "run_hint", automatic: false, command: "meshy slicer detect --output-schema v1" }, + details: { checked }, + }); + } + + const absFile = resolvePath(opts.cwd ?? process.cwd(), file); + let fileStat; + try { + fileStat = statSync(absFile); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `model file not found: ${absFile}` }); + } + throw new CliError({ code: "local_io", message: `cannot access ${absFile}: ${(err as Error).message}`, cause: err }); + } + if (!fileStat.isFile()) { + throw new CliError({ code: "not_found", message: `model path is not a regular file: ${absFile}` }); + } + const ext = extname(absFile).slice(1).toLowerCase(); + if (!LAUNCHABLE_EXTENSIONS.has(ext)) { + throw new UsageError( + `unsupported model file extension '${ext ? `.${ext}` : "(none)"}' for ${absFile}; expected one of ${[...LAUNCHABLE_EXTENSIONS].join(", ")}`, + ); + } + + let command: string; + let args: string[]; + if (platform === "darwin") { + command = "open"; + args = ["-a", detected.path, absFile]; + } else { + command = detected.path; + args = [absFile]; + } + const spawnOptions: LaunchSpawnOptions = { detached: true, stdio: "ignore", shell: false }; + const spawnImpl: SpawnFn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn); + + let child: SpawnedChild; + try { + child = spawnImpl(command, args, spawnOptions); + await awaitSpawned(child); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `failed to launch ${descriptor.name} (${command}): ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + // Late errors on an already-running child must not crash the CLI. + child.on("error", () => {}); + child.unref(); + + let launcher: SlicerLaunch["launcher"] = null; + if (platform === "darwin") { + const exitCode = await awaitExitBounded(child, opts.launcherTimeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS); + if (exitCode !== null && exitCode !== 0) { + throw new CliError({ + code: "local_io", + message: `open exited with code ${exitCode}: ${descriptor.name} at ${detected.path} did not accept ${absFile}`, + details: { argv: [command, ...args], exit_code: exitCode }, + }); + } + launcher = { command, exit_code: exitCode }; + } + + return { + launch_requested: true, + slicer: detected, + file: absFile, + pid: child.pid ?? null, + argv: [command, ...args], + platform, + launcher, + }; +} diff --git a/src/internal/stream.ts b/src/internal/stream.ts new file mode 100644 index 0000000..fbb35c6 --- /dev/null +++ b/src/internal/stream.ts @@ -0,0 +1,298 @@ +/** + * Server-Sent Events for task status. + * + * The parser follows the WHATWG EventSource algorithm: UTF-8 is decoded + * across chunk boundaries, lines end at CR, LF or CRLF, an empty line + * dispatches the event, multi-line `data:` fields are joined with "\n", + * comment lines (leading ':') are ignored, `id:` and `retry:` are recorded + * but never used to fabricate a Last-Event-ID we did not receive. + * + * Meshy's stream emits `event: message` with the full task JSON (also as the + * ~10 s keep-alive) and `event: error` with `{message, status_code}` — which + * can arrive after an HTTP 200, so it is mapped exactly like an HTTP failure. + */ + +import { codeForStatus, MeshyApiError } from "../client/errors.js"; +import type { TaskEndpoint } from "../client/endpoints/base.js"; +import { TaskSchema, isTerminalStatus, type Task } from "../client/types.js"; +import { CliError } from "./errors.js"; + +export const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024; + +export interface SseEvent { + event: string; + data: string; + id: string | null; + retry: number | null; +} + +export interface SseParser { + /** Feed raw bytes; returns every event completed by this chunk. */ + feed(chunk: Uint8Array): SseEvent[]; + /** Flush at end of stream (a final event without a trailing blank line is dispatched). */ + end(): SseEvent[]; +} + +export function createSseParser(opts: { maxEventBytes?: number } = {}): SseParser { + const maxEventBytes = opts.maxEventBytes ?? DEFAULT_MAX_SSE_EVENT_BYTES; + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + let eventType = ""; + let dataLines: string[] = []; + let lastId: string | null = null; + let retry: number | null = null; + let pendingBytes = 0; + let first = true; + + function dispatch(out: SseEvent[]): void { + if (dataLines.length === 0) { + eventType = ""; + pendingBytes = 0; + return; + } + out.push({ event: eventType || "message", data: dataLines.join("\n"), id: lastId, retry }); + eventType = ""; + dataLines = []; + pendingBytes = 0; + } + + function processLine(line: string, out: SseEvent[]): void { + if (line === "") { + dispatch(out); + return; + } + if (line.startsWith(":")) return; // comment / heartbeat + let field: string; + let value: string; + const colon = line.indexOf(":"); + if (colon === -1) { + field = line; + value = ""; + } else { + field = line.slice(0, colon); + value = line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + } + switch (field) { + case "event": + eventType = value; + break; + case "data": + pendingBytes += Buffer.byteLength(value, "utf8") + 1; + if (pendingBytes > maxEventBytes) { + throw new CliError({ code: "protocol", message: `SSE event exceeds ${maxEventBytes} bytes` }); + } + dataLines.push(value); + break; + case "id": + if (!value.includes(" ")) lastId = value; + break; + case "retry": { + const n = Number(value); + if (Number.isInteger(n) && n >= 0) retry = n; + break; + } + default: + break; // unknown fields are ignored per spec + } + } + + function consume(text: string, out: SseEvent[], final: boolean): void { + buffer += text; + if (first && buffer.startsWith("\uFEFF")) buffer = buffer.slice(1); + first = false; + let start = 0; + for (;;) { + const cr = buffer.indexOf("\r", start); + const lf = buffer.indexOf("\n", start); + let end: number; + let next: number; + if (cr === -1 && lf === -1) break; + if (cr !== -1 && (lf === -1 || cr < lf)) { + // CR, possibly followed by LF. A trailing CR at the very end of the + // buffer may be half of a CRLF — wait for more bytes unless final. + if (cr === buffer.length - 1 && !final) break; + end = cr; + next = buffer[cr + 1] === "\n" ? cr + 2 : cr + 1; + } else { + end = lf; + next = lf + 1; + } + processLine(buffer.slice(start, end), out); + start = next; + } + buffer = buffer.slice(start); + if (buffer.length > maxEventBytes) { + throw new CliError({ code: "protocol", message: `SSE line exceeds ${maxEventBytes} bytes without a line ending` }); + } + } + + return { + feed(chunk) { + const out: SseEvent[] = []; + consume(decoder.decode(chunk, { stream: true }), out, false); + return out; + }, + end() { + const out: SseEvent[] = []; + consume(decoder.decode(), out, true); + if (buffer.length > 0) { + processLine(buffer, out); + buffer = ""; + } + dispatch(out); + return out; + }, + }; +} + +export type StreamEndReason = "terminal" | "timeout" | "idle_timeout" | "disconnected" | "error" | "interrupted" | "protocol"; + +export interface StreamOutcome { + reason: StreamEndReason; + task: Task | null; + raw: unknown; + events: number; + /** Set when the stream carried an API error event or a protocol failure. */ + error: MeshyApiError | CliError | null; + elapsedMs: number; +} + +export interface StreamTaskOptions { + timeoutMs: number; + idleTimeoutMs: number; + signal?: AbortSignal; + onTask?: (task: Task, raw: unknown) => void | Promise; + onUnknownEvent?: (event: SseEvent) => void; + maxEventBytes?: number; +} + +/** + * Open the task stream and consume it until a terminal task, an API error + * event, a deadline, a disconnect or an abort. The connection is always closed + * before this resolves. + */ +export async function streamTask(endpoint: TaskEndpoint, taskId: string, opts: StreamTaskOptions): Promise { + const started = performance.now(); + const total = new AbortController(); + const idle = new AbortController(); + const totalTimer = setTimeout(() => total.abort(new Error("total timeout")), opts.timeoutMs); + let idleTimer: NodeJS.Timeout | undefined; + const resetIdle = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => idle.abort(new Error("idle timeout")), opts.idleTimeoutMs); + }; + const signals: AbortSignal[] = [total.signal, idle.signal]; + if (opts.signal) signals.push(opts.signal); + const signal = AbortSignal.any(signals); + + const state: { task: Task | null; raw: unknown; events: number } = { task: null, raw: null, events: 0 }; + const finish = (reason: StreamEndReason, error: MeshyApiError | CliError | null = null): StreamOutcome => ({ + reason, + task: state.task, + raw: state.raw, + events: state.events, + error, + elapsedMs: performance.now() - started, + }); + const classifyAbort = (): StreamEndReason => { + if (opts.signal?.aborted) return "interrupted"; + if (total.signal.aborted) return "timeout"; + if (idle.signal.aborted) return "idle_timeout"; + return "disconnected"; + }; + + let handle: Awaited> | null = null; + try { + resetIdle(); + try { + handle = await endpoint.openStream(taskId, { signal, connectTimeoutMs: Math.min(opts.timeoutMs, opts.idleTimeoutMs) }); + } catch (err) { + if (signal.aborted) return finish(classifyAbort()); + throw err; + } + const contentType = handle.response.headers.get("content-type") ?? ""; + if (!/text\/event-stream/i.test(contentType)) { + return finish("protocol", new CliError({ code: "protocol", message: `stream endpoint answered with content-type '${contentType || "(none)"}' instead of text/event-stream` })); + } + const body = handle.response.body; + if (!body) return finish("protocol", new CliError({ code: "protocol", message: "stream response has no body" })); + const reader = body.getReader(); + const parser = createSseParser({ maxEventBytes: opts.maxEventBytes }); + + const handleEvent = async (ev: SseEvent): Promise => { + if (ev.event === "error") { + let payload: { message?: unknown; status_code?: unknown } = {}; + try { + payload = JSON.parse(ev.data) as typeof payload; + } catch { + return finish("protocol", new CliError({ code: "protocol", message: `stream error event is not JSON: ${ev.data.slice(0, 200)}` })); + } + const status = typeof payload.status_code === "number" ? payload.status_code : 0; + const message = typeof payload.message === "string" ? payload.message : "stream error"; + const apiErr = new MeshyApiError({ + message: `meshy stream ${status || "error"} on ${endpoint.streamPath(taskId)}: ${message}`, + status, + code: status ? codeForStatus(status) : "server", + path: endpoint.streamPath(taskId), + body: payload, + }); + return finish("error", apiErr); + } + if (ev.event !== "message") { + opts.onUnknownEvent?.(ev); + return null; + } + let json: unknown; + try { + json = JSON.parse(ev.data); + } catch { + return finish("protocol", new CliError({ code: "protocol", message: `stream message is not JSON: ${ev.data.slice(0, 200)}` })); + } + const parsed = TaskSchema.safeParse(json); + if (!parsed.success) { + return finish("protocol", new CliError({ code: "protocol", message: `stream message is not a task: ${parsed.error.message}` })); + } + state.task = parsed.data; + state.raw = json; + state.events += 1; + await opts.onTask?.(parsed.data, json); + if (isTerminalStatus(parsed.data.status)) return finish("terminal"); + return null; + }; + + for (;;) { + let chunk: Awaited>; + try { + chunk = await reader.read(); + } catch (err) { + if (signal.aborted) return finish(classifyAbort()); + return finish("disconnected", new CliError({ code: "network", message: `stream disconnected: ${err instanceof Error ? err.message : String(err)}` })); + } + if (signal.aborted) return finish(classifyAbort()); + if (chunk.done) { + for (const ev of parser.end()) { + const out = await handleEvent(ev); + if (out) return out; + } + if (state.task === null) return finish("protocol", new CliError({ code: "protocol", message: "stream ended without any task event" })); + return finish("disconnected", new CliError({ code: "network", message: `stream ended before the task reached a terminal status (last status: ${state.task.status})` })); + } + resetIdle(); // any bytes — keep-alives, comments, partial lines — count as liveness + let parsedEvents: SseEvent[]; + try { + parsedEvents = parser.feed(chunk.value); + } catch (err) { + return finish("protocol", err instanceof CliError ? err : new CliError({ code: "protocol", message: String(err) })); + } + for (const ev of parsedEvents) { + const out = await handleEvent(ev); + if (out) return out; + } + } + } finally { + clearTimeout(totalTimer); + if (idleTimer) clearTimeout(idleTimer); + handle?.close(); + } +} diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 2268d5c..b4eecec 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -1,23 +1,66 @@ /** - * Factory that wires the uniform get/list/delete/wait/create subcommands for - * any async-task resource. Keeps the per-resource modules focused on their - * unique create-flag shape. + * Factory that wires the uniform create/get/list/wait/stream/delete + * subcommands for any registered task resource. Per-resource modules only + * contribute their unique `create` flag shape and payload validation. + * + * Two output schemas share one execution path: + * legacy — the 0.2.0 payloads and `-o` status report, with two documented + * fixes: `get` of a non-terminal task exits 0, and async create + * never polls. + * v1 — one `meshy.cli/v1` envelope whose result carries the TaskView, + * the submission record and the download manifest. + * + * Money rules enforced here, independent of schema: + * - every create is exactly one POST, journaled before it is sent; + * - a lost or malformed response is `submission_unknown` (exit 10), never a + * retry and never a "please run it again" hint; + * - local targets that would fail after the POST (an existing --save-json + * file, an -o path outside the workspace, a missing --project) are checked + * before it, so a detectable conflict costs zero requests; + * - once the server has accepted a task, every later failure — saving JSON, + * polling, downloading, recording, a signal — still reports that task id, + * the submission record and the `get`/`wait` commands that resume it; + * - SIGINT stops waiting/streaming (exit 130) and never deletes anything. */ import { Command, Option } from "commander"; import type { TaskEndpoint } from "../client/endpoints/base.js"; -import type { MeshyClient, ResourceName } from "../client/index.js"; -import { downloadArtifacts } from "./download.js"; -import { resolveImageFields, resolveModelFields } from "./file-input.js"; -import { pollUntilTerminal } from "./poll.js"; -import { emit } from "./output.js"; -import { mergePayload, parseJsonFlag } from "./payload.js"; +import { MeshyApiError } from "../client/errors.js"; +import type { MeshyClient } from "../client/index.js"; +import { requireTaskResource, type TaskResourceDescriptor } from "../client/resource-registry.js"; +import { TransportError } from "../client/transport.js"; +import { isTerminalStatus, summarizeTask, type Task } from "../client/types.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type OpenedCommand, type SavedJson } from "./command-helpers.js"; +import { abortSignal, wasInterrupted } from "./context.js"; +import { downloadArtifacts, looksLikeFile } from "./download.js"; +import { classifyError, CliError, UsageError, type Warning } from "./errors.js"; +import { normalizeMediaPayload } from "./file-input.js"; +import { logger } from "./logger.js"; +import type { MaterialLinkReport } from "./material-links.js"; +import { mergeNestedObjects, mergePayload, parseJsonFlag } from "./payload.js"; +import { emitEnvelope, emitStreamEvent, emit } from "./output.js"; +import { parseTimeoutSeconds, pollUntilTerminal, type PollResult } from "./poll.js"; import { printReport } from "./report.js"; +import { errorEnvelope, okEnvelope, warning, type StreamEventEnvelope } from "./result.js"; +import { buildRuntime, type Runtime } from "./runtime.js"; import { getUpdateNotice } from "./update-notifier.js"; -import { buildRuntime, type Runtime, readGlobalFlags } from "./runtime.js"; -import { summarizeTask, TERMINAL_STATUSES, type Task } from "../client/types.js"; -import { UsageError } from "./errors.js"; -import { logger } from "./logger.js"; +import { streamTask } from "./stream.js"; +import { toTaskView, type TaskView } from "./task-view.js"; +import { + beginOperation, + credentialBinding, + credentialFingerprint, + newOperationId, + operationsRoot, + payloadFingerprint, + updateOperation, + type OperationRecord, +} from "./operation-store.js"; +import { originOf } from "./config.js"; +import { resolveWithinRoot, freezeRoot, type AuthorisedRoot } from "./paths.js"; +import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, recordTask, saveTaskSnapshot, stageFromTaskType, type RecordInput } from "./project-store.js"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve as resolvePath } from "node:path"; export interface CreateSpec { description: string; @@ -31,128 +74,609 @@ export interface CreateSpec { * not throw (validation belongs in toPayload). */ toDefaults?(opts: Record): Record; + /** + * Payload keys holding nested option objects that must merge field by field + * across defaults < --data < flags (e.g. Creative Lab `options`, `output`). + * Everything else merges shallowly: arrays and scalars replace wholesale. + */ + nestedObjectKeys?: readonly string[]; + /** + * Validate the merged payload (defaults < --data < flags) before media is + * normalised and before anything is sent. Throw UsageError / CliError. + */ + validatePayload?(payload: Record, opts: Record): void; } export interface ResourceCommandSpec { - name: ResourceName; + /** Registry id of the task resource this command drives. */ + name: string; + /** Commander name when it differs from the id (Creative Lab stages). */ + commandName?: string; + /** Dotted prefix for v1 `command` (defaults to the id). */ + commandPrefix?: string; description: string; supportsList?: boolean; + /** Default output schema for this resource's verbs (`legacy` for 0.2.0 commands). */ + defaultSchema?: "legacy" | "v1"; create: CreateSpec; - endpointOf(client: MeshyClient): TaskEndpoint; + /** Optional override; defaults to the registry endpoint for `name`. */ + endpointOf?(client: MeshyClient): TaskEndpoint; +} + +const TASK_JSON_OPTIONS = (cmd: Command): Command => + cmd + .option("--save-json ", "save the full task JSON as received (never overwrites)") + .option("--include-raw", "v1: include the untouched task response under result.task.raw") + .option("--project ", "initialised meshy_output project: save task_.json there and record the task in metadata.json") + .option("--stage ", "stage label for the project record (default: derived from the task type, e.g. preview | refine | build)"); + +export interface DownloadOutcome { + state: "not_requested" | "not_ready" | "completed" | "partial" | "failed"; + files: Array<{ key?: string; path: string; status: "written" | "failed"; bytes?: number; sha256?: string; error?: string | null }>; + metadata_path: string | null; + /** OBJ/MTL/texture reference report when the download contained a text OBJ. */ + material_links?: MaterialLinkReport | null; + /** Set when the transfers landed but a later step (relink | digest | sidecar) failed or was interrupted. */ + failed_step?: string; +} + +export interface SubmissionInfo { + state: string; + operation_id: string | null; + task_id?: string | null; + request_id?: string | null; +} + +interface TaskResultOptions { + task: Task | null; + raw: unknown; + descriptor: TaskResourceDescriptor; + includeRaw: boolean; + submission: SubmissionInfo; + downloads?: DownloadOutcome; + savedJson?: SavedJson | null; + extra?: Record; +} + +const NOT_REQUESTED = (): DownloadOutcome => ({ state: "not_requested", files: [], metadata_path: null }); + +function taskResult(o: TaskResultOptions): Record { + return { + task: o.task ? toTaskView(o.raw ?? o.task, { descriptor: o.descriptor, includeRaw: o.includeRaw }) : null, + submission: o.submission, + downloads: o.downloads ?? NOT_REQUESTED(), + saved_json: o.savedJson ?? null, + ...(o.extra ?? {}), + }; +} + +function nextCommands(descriptor: TaskResourceDescriptor, taskId: string): Record { + const base = `meshy ${descriptor.commandPath.join(" ")}`; + return { + get: `${base} get ${taskId} --output-schema v1`, + wait: `${base} wait ${taskId} --output-schema v1`, + stream: `${base} stream ${taskId} --format ndjson --output-schema v1`, + }; +} + +/** + * What every failure after the server accepted a task must still say: which + * task, what the submission record is, and how to pick it up again. + */ +interface TaskContext { + descriptor: TaskResourceDescriptor; + taskId: string; + task?: Task | null; + raw?: unknown; + submission: SubmissionInfo; + includeRaw?: boolean; + savedJson?: SavedJson | null; + extra?: Record; +} + +/** + * Re-throw any error as a CliError whose `result` carries `result` — keeping + * the original classification (code, HTTP status, hint, recovery, exit code). + * A CliError's own partial result (files written so far, a failed download + * manifest) is merged on top, so nothing already known is lost. + */ +export function wrapWithResult(err: unknown, result: Record): CliError { + if (err instanceof CliError) { + return new CliError({ + code: err.code, + message: err.message, + exitCode: err.exitCode, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...result, ...(err.result ?? {}) }, + cause: err, + }); + } + const c = classifyError(err); + return new CliError({ + code: c.code, + message: c.message, + exitCode: c.exitCode, + httpStatus: c.httpStatus, + retryable: c.retryable, + recovery: c.recovery, + hint: c.hint, + details: c.details, + warnings: c.warnings, + result: { ...result, ...(c.result ?? {}) }, + cause: err, + }); +} + +/** `wrapWithResult` with the task result shape: the id, submission and next commands always survive. */ +function withTaskContext(err: unknown, ctx: TaskContext): CliError { + const next = nextCommands(ctx.descriptor, ctx.taskId); + const base = taskResult({ + task: ctx.task ?? null, + raw: ctx.raw ?? null, + descriptor: ctx.descriptor, + includeRaw: Boolean(ctx.includeRaw), + submission: ctx.submission, + savedJson: ctx.savedJson ?? null, + extra: { task_id: ctx.taskId, next, ...(ctx.extra ?? {}) }, + }); + let own: Record = {}; + if (err instanceof CliError && err.result) { + own = { ...err.result }; + // A bookkeeping error's `task: null` must not erase a task we do know. + if (own["task"] === null && ctx.task) delete own["task"]; + } + const wrapped = wrapWithResult(err, base); + return new CliError({ + code: wrapped.code, + message: wrapped.message, + exitCode: wrapped.exitCode, + httpStatus: wrapped.httpStatus, + retryable: wrapped.retryable, + recovery: wrapped.recovery, + hint: wrapped.hint ?? wrapped.recovery?.command ?? next.wait, + details: wrapped.details, + warnings: wrapped.warnings, + result: { ...base, ...own, task_id: ctx.taskId, next }, + cause: err, + }); +} + +interface ProjectAttachment { + project_dir: string; + snapshot: string | null; + stage: string; + action: "added" | "merged"; + index: { updated: boolean; error: string | null }; +} + +function parentTaskIdFromPayload(payload: Record | null): string | null { + if (!payload) return null; + for (const k of ["preview_task_id", "input_task_id", "rig_task_id"]) { + if (typeof payload[k] === "string" && payload[k]) return payload[k] as string; + } + return null; +} + +/** Resolve --project: an initialised project directory, inside the workspace when one is set. */ +function resolveProjectDir(projectFlag: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): string { + const projectDir = resolvePath(cwd, projectFlag); + if (!existsSync(join(projectDir, "metadata.json"))) { + throw new CliError({ + code: "local_io", + message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first`, + }); + } + if (workspace) resolveWithinRoot(projectDir, workspace, { cwd, label: "--project" }); + return projectDir; +} + +/** + * --project: snapshot the task (when a full task is known) and record it. + * Failures keep the task id in the error result — a bookkeeping problem must + * never read as "no task was created". The recovery context (task, journal + * operation, stage, workspace) exists before the project is even looked at, so + * every failure of this phase — the directory resolving outside the workspace, + * a metadata.json that vanished or was damaged after the preflight, a lock, a + * full disk — carries it. Two kinds of failure, two answers: a project that no + * longer lies inside the write boundary gets no command that would cross it; + * anything else gets the one `meshy project record …` invocation (with the + * original `--workspace`) that redoes just the record once the project is + * restored. Nothing is re-submitted. + */ +function attachToProject( + opts: Record, + opened: OpenedCommand, + descriptor: TaskResourceDescriptor, + taskId: string, + task: Task | null, + raw: unknown, + extra: { operationId?: string | null; payload?: Record | null; files?: string[] }, + warnings: Warning[], +): ProjectAttachment | null { + const projectFlag = opts.project as string | undefined; + if (!projectFlag) return null; + const projectDir = resolvePath(projectFlag); + // The boundary was frozen before the first request (the workspace, or the + // project directory itself when no workspace is given); it is never resolved + // again from a path that may have been replaced since. + const root: AuthorisedRoot = opened.flags.workspaceRoot ?? (opts.__projectRoot as AuthorisedRoot | undefined) ?? freezeRoot(projectDir, { label: "--project" }); + const workspace = opened.flags.workspaceRoot?.given; + const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); + const input: RecordInput = { + taskId, + stage, + resource: descriptor.id, + taskType: task?.type ?? null, + endpoint: descriptor.legacyEndpoint, + parentTaskId: parentTaskIdFromPayload(extra.payload ?? null), + status: task?.status ?? null, + taskJson: null, + operationId: extra.operationId ?? null, + files: extra.files ?? [], + }; + // 1. The location. The frozen boundary must still be the directory it was, + // and the project must resolve inside it now — a workspace or project + // replaced by a symlink while the request was in flight, or a project that + // now resolves elsewhere, is a boundary problem: the task exists and is + // journaled, but no recovery command may be handed out that writes across + // that line. What passes is written through its real path. + let located: string; + try { + located = resolveWithinRoot(projectDir, root, { label: "--project" }).path; + } catch (err) { + throw projectBoundaryFailure(err, taskId, projectFlag, input); + } + // 2. The record itself, including "is this (still) an initialised project". + try { + if (opts.__projectInitialised === false && !existsSync(join(located, "metadata.json"))) { + throw new CliError({ code: "local_io", message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first` }); + } + assertProjectMetadataPresent(located, projectFlag); + const snapshot = task && raw ? saveTaskSnapshot(located, taskId, raw) : null; + input.taskJson = snapshot?.relative ?? null; + const indexRoot = indexRootFor(located, undefined, opened.flags.workspaceRoot); + const rec = recordTask(located, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); + if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); + return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; + } catch (err) { + // The task exists and the journal is written; only the project entry is + // missing. The recovery redoes that one step under the original boundary. + const command = projectRecordCommand(projectDir, input, { workspace }); + throw new CliError({ + code: err instanceof CliError ? err.code : "local_io", + message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}; restore the project, then run: ${command}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + retryable: err instanceof CliError ? err.retryable : false, + recovery: { action: "record_project", automatic: false, command }, + hint: command, + details: err instanceof CliError ? err.details : undefined, + cause: err, + }); + } +} + +/** + * The project directory no longer resolves inside the workspace (or is a + * symlink). The task is known and journaled; the caller learns that, and that + * nothing was recorded — but no `meshy project record` command is offered, + * because the only one that would succeed is one without the boundary. + */ +function projectBoundaryFailure(err: unknown, taskId: string, projectFlag: string, input: RecordInput): CliError { + const reason = err instanceof Error ? err.message : String(err); + return new CliError({ + code: "local_io", + message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but --project ${projectFlag} is no longer a target inside the authorised boundary: ${reason}; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with \`meshy project record\` from that workspace`, + details: { project: projectFlag, task_id: taskId, operation_id: input.operationId ?? null, stage: input.stage, recorded: false }, + cause: err, + }); +} + +/** --save-json inside the task's context: a full disk or a vanished directory never hides the task id. */ +function saveJsonInContext(opts: Record, opened: OpenedCommand, raw: unknown, ctx: TaskContext): SavedJson | null { + if (!opts.saveJson) return null; + try { + return saveRawJson(String(opts.saveJson), raw, { workspace: opened.flags.workspaceRoot }); + } catch (err) { + throw withTaskContext(err, ctx); + } +} + +function attachInContext( + opts: Record, + opened: OpenedCommand, + descriptor: TaskResourceDescriptor, + taskId: string, + task: Task | null, + raw: unknown, + extra: { operationId?: string | null; payload?: Record | null; files?: string[] }, + warnings: Warning[], + ctx: TaskContext, +): ProjectAttachment | null { + try { + return attachToProject(opts, opened, descriptor, taskId, task, raw, extra, warnings); + } catch (err) { + throw withTaskContext(err, ctx); + } +} + +// --------------------------------------------------------------------------- +// Pre-submission checks: anything local that would fail *after* a billable POST +// and can be detected now is refused now, with "nothing was submitted". +// --------------------------------------------------------------------------- + +/** `-o` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ +export function preflightOutputPath(output: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): void { + const abs = resolvePath(cwd, output); + resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--output" }); + if (looksLikeFile(abs) && existsSync(abs)) { + throw new CliError({ + code: "local_io", + message: `--output ${output} already exists; choose another path (nothing was submitted)`, + recovery: { action: "choose_path", automatic: false }, + }); + } +} + +/** `--save-json` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ +export function preflightSaveJsonPath(target: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): void { + const abs = resolvePath(cwd, target); + const resolved = resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--save-json target" }); + if (existsSync(resolved.path)) { + throw new CliError({ + code: "local_io", + message: `--save-json target ${target} already exists; choose another path (nothing was submitted)`, + recovery: { action: "choose_path", automatic: false }, + }); + } +} + +/** + * Freeze the project's write boundary before the first request: with a + * workspace it is the workspace (already frozen with the flags); without one it + * is the project directory as it is right now. Whether the project was + * initialised at this moment is remembered too, so a later "no metadata.json" + * can say which it was. Nothing is refused here for get/wait/stream — a + * bookkeeping problem stays a bookkeeping problem after the request (one + * outcome); create's preflight refuses an uninitialised project before its POST. + */ +function beginProjectContext(opts: Record, opened: OpenedCommand): void { + const projectFlag = opts.project as string | undefined; + if (!projectFlag || opts.__projectRoot) return; + const projectDir = resolvePath(projectFlag); + opts.__projectInitialised = existsSync(join(projectDir, "metadata.json")); + opts.__projectRoot = opened.flags.workspaceRoot ?? freezeRoot(projectDir, { label: "--project" }); +} + +function preflightLocalTargets(opts: Record, opened: OpenedCommand): void { + if (opts.saveJson) preflightSaveJsonPath(String(opts.saveJson), opened.flags.workspaceRoot); + if (opened.flags.output) preflightOutputPath(opened.flags.output, opened.flags.workspaceRoot); + if (opts.project) { + try { + resolveProjectDir(String(opts.project), opened.flags.workspaceRoot); + beginProjectContext(opts, opened); + } catch (err) { + if (err instanceof CliError) { + throw new CliError({ code: err.code, message: `${err.message} (nothing was submitted)`, recovery: err.recovery, cause: err }); + } + throw err; + } + } } export function buildResourceCommand(spec: ResourceCommandSpec): Command { - const cmd = new Command(spec.name).description(spec.description); + const descriptor = requireTaskResource(spec.name); + const endpointOf = spec.endpointOf ?? ((client: MeshyClient) => client.endpointFor(descriptor)); + const prefix = spec.commandPrefix ?? spec.name; + const defaultSchema = spec.defaultSchema ?? "legacy"; + const cmd = new Command(spec.commandName ?? spec.name).description(spec.description); // create const createCmd = new Command("create").description(spec.create.description); spec.create.configure(createCmd); - createCmd - .addOption(new Option("--data ", "raw JSON payload (or @file.json); merges with flags")) + TASK_JSON_OPTIONS(createCmd) + .addOption(new Option("--data ", "raw JSON payload (or @file.json); merges with flags (flags win)")) .addOption( new Option( "--async", - "return the task_id immediately without polling; query later with ` wait ` or `get `", + "submit once and return the task id immediately (no polling); query later with `get`, `wait` or `stream`", ).default(false), ) .addOption(new Option("--timeout ", "max seconds to poll in sync mode").default("600")) + .addOption( + new Option( + "--operation-id ", + "local journal id for this submission; repeating it returns the recorded outcome instead of submitting again (local record only, not a server idempotency key)", + ), + ) .action(async (opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const endpoint = spec.endpointOf(runtime.client); + const opened = openCommand(thisCmd, `${prefix}.create`, defaultSchema); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const runAsync = Boolean(opts.async); + const includeRaw = Boolean(opts.includeRaw); + const runtime = await buildRuntime(opened.flags); + const endpoint = endpointOf(runtime.client); + + // 1. Build and validate the payload — every local failure happens here, before the journal and the POST. const data = parseJsonFlag(opts.data as string | undefined, "--data"); - // Resolve local file paths + preflight URLs for image and 3D-model - // inputs before building the payload — fail fast on missing inputs. - await resolveImageFields(opts); - await resolveModelFields(opts); const flagPayload = spec.create.toPayload(opts); const defaults = spec.create.toDefaults?.(opts) ?? {}; - const payload = mergePayload(defaults, data, flagPayload); - logger.debug("create payload", payload); - const taskId = await endpoint.create(payload); - const runAsync = Boolean(opts.async); - const timeoutSeconds = Number(opts.timeout ?? 600); + const layers = [defaults, data, flagPayload]; + let merged = mergePayload(...layers); + if (spec.create.nestedObjectKeys && spec.create.nestedObjectKeys.length > 0) { + merged = mergeNestedObjects(merged, layers, spec.create.nestedObjectKeys); + } + spec.create.validatePayload?.(merged, opts); + const { payload } = await normalizeMediaPayload(merged, descriptor.mediaFields, { signal: abortSignal() }); + logger.debug("create payload", redactForLog(payload)); + + // 2. Local targets that would fail after the POST are refused before it. + preflightLocalTargets(opts, opened); + + // 3. Journal, then exactly one POST. + const submitted = await submitCreate(runtime, descriptor, endpoint, payload, (opts.operationId as string | undefined) ?? null); + const { taskId, raw: createRaw, operationId, warnings } = submitted; + const submission: SubmissionInfo = { state: "accepted", operation_id: operationId, task_id: taskId, request_id: submitted.requestId }; + if (runAsync) { + const ctx: TaskContext = { descriptor, taskId, task: null, raw: null, submission, includeRaw }; + const savedJson = saveJsonInContext(opts, opened, createRaw, ctx); + const project = attachInContext(opts, opened, descriptor, taskId, null, null, { operationId, payload }, warnings, { ...ctx, savedJson }); + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope( + opened.command, + taskResult({ + task: null, + raw: null, + descriptor, + includeRaw: false, + submission, + savedJson, + extra: { task_id: taskId, next: nextCommands(descriptor, taskId), project }, + }), + warnings, + ), + opened.format, + ); + return; + } emit( { resource: spec.name, task_id: taskId, status: "PENDING", - hint: `meshy-cli ${spec.name} wait ${taskId}`, + hint: `meshy-cli ${descriptor.commandPath.join(" ")} wait ${taskId}`, + operation_id: operationId, + ...(project ? { project_dir: project.project_dir } : {}), }, - { format: runtime.flags.format, file: undefined }, + { format: opened.format, file: undefined }, ); return; } - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - }); - const elapsed = (Date.now() - started) / 1000; - await emitTerminalOutcome(task, timedOut, elapsed, spec.name, runtime); + + // 4. Sync: poll the id we just recorded. + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, { ...opts, __payload: payload, __operationId: operationId }, submission, warnings); }); cmd.addCommand(createCmd); // get - cmd - .command("get ") - .description("Retrieve a single task by id") - .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const task = await spec.endpointOf(runtime.client).retrieve(taskId); - await emitTerminalOutcome(task, false, undefined, spec.name, runtime); - }); + TASK_JSON_OPTIONS(cmd.command("get ").description("Retrieve a single task by id (any status is a successful query)")).action( + async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.get`, defaultSchema); + beginProjectContext(opts, opened); + const runtime = await buildRuntime(opened.flags); + const { task, raw } = await endpointOf(runtime.client).retrieveDetailed(taskId, { signal: abortSignal() }); + const submission: SubmissionInfo = { state: "accepted", operation_id: null }; + const includeRaw = Boolean(opts.includeRaw); + const warnings: Warning[] = []; + const ctx: TaskContext = { descriptor, taskId, task, raw, submission, includeRaw }; + const savedJson = saveJsonInContext(opts, opened, raw, ctx); + const project = attachInContext(opts, opened, descriptor, taskId, task, raw, {}, warnings, { ...ctx, savedJson }); + if (opened.schema === "v1") { + const downloads = await maybeDownloadV1(opened, descriptor, task, raw, submission, warnings, { savedJson, includeRaw, project }); + await emitEnvelope( + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: project ? { project } : {} }), warnings), + opened.format, + ); + return; + } + // Legacy shape, same rule: a download failure still names the task. + try { + await emitLegacyOutcome(task, false, undefined, spec.name, runtime, { query: true }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: project ? { project } : {} }); + } + }, + ); // wait - cmd - .command("wait ") - .description("Poll until the task reaches a terminal status") - .option("--timeout ", "max seconds to wait", "600") - .action(async (taskId: string, opts: { timeout?: string }, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const endpoint = spec.endpointOf(runtime.client); - const timeoutSeconds = Number(opts.timeout ?? 600); - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - }); - const elapsed = (Date.now() - started) / 1000; - await emitTerminalOutcome(task, timedOut, elapsed, spec.name, runtime); - }); + TASK_JSON_OPTIONS( + cmd + .command("wait ") + .description("Poll until the task reaches a terminal status") + .option("--timeout ", "max seconds to wait (0 = a single query)", "600"), + ).action(async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.wait`, defaultSchema); + beginProjectContext(opts, opened); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const runtime = await buildRuntime(opened.flags); + const endpoint = endpointOf(runtime.client); + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, opts, { state: "accepted", operation_id: null }, []); + }); + + // stream + TASK_JSON_OPTIONS( + cmd + .command("stream ") + .description("Follow the task over Server-Sent Events until it is terminal") + .option("--timeout ", "total deadline for the stream", "600") + .option("--idle-timeout ", "abort when no bytes arrive for this long (keep-alives count)", "60"), + ).action(async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.stream`, defaultSchema); + beginProjectContext(opts, opened); + if (!descriptor.supports.stream) throw new UsageError(`${spec.name} does not support stream`); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const idleSeconds = parseTimeoutSeconds(opts.idleTimeout ?? "60", "--idle-timeout"); + if (timeoutSeconds === 0) throw new UsageError("--timeout 0 is not meaningful for stream; use `get` for a single query"); + const runtime = await buildRuntime(opened.flags); + const endpoint = endpointOf(runtime.client); + await streamAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, idleSeconds, opts); + }); // delete cmd .command("delete ") .description("Delete a task") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - await spec.endpointOf(runtime.client).delete(taskId); - emit({ resource: spec.name, task_id: taskId, deleted: true }, { - format: runtime.flags.format, - file: runtime.flags.output, - }); + const opened = openCommand(thisCmd, `${prefix}.delete`, defaultSchema); + rejectOutputFlagForV1(opened, undefined); + const runtime = await buildRuntime(opened.flags); + const raw = await endpointOf(runtime.client).delete(taskId, { signal: abortSignal() }); + await emitResult( + opened, + { resource: spec.name, task_id: taskId, deleted: true }, + { resource: spec.name, endpoint: descriptor.legacyEndpoint, task_id: taskId, deleted: true, response: raw ?? null }, + { legacyFile: opened.flags.output }, + ); }); - // list (optional — some endpoints don't support it) - if (spec.supportsList !== false) { + // list + if (spec.supportsList !== false && descriptor.supports.list) { cmd .command("list") .description("List recent tasks") .option("--page ", "page number (1-based)", "1") .option("--page-size ", "page size", "10") .option("--sort-by ", "sort order (e.g. -created_at)", "-created_at") - .action(async (opts: { page?: string; pageSize?: string; sortBy?: string }, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const tasks = await spec.endpointOf(runtime.client).list({ - page_num: Number(opts.page ?? 1), - page_size: Number(opts.pageSize ?? 10), - sort_by: opts.sortBy ?? "-created_at", - }); - emit(tasks.map((t) => summarizeTask(t)), { - format: runtime.flags.format, - file: runtime.flags.output, - }); + .option("--save-json ", "save the raw list response (never overwrites)") + .option("--include-raw", "v1: include each raw task under items[].raw") + .action(async (opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.list`, defaultSchema); + rejectOutputFlagForV1(opened, opts.saveJson as string | undefined); + const runtime = await buildRuntime(opened.flags); + const page = { page_num: Number(opts.page ?? 1), page_size: Number(opts.pageSize ?? 10), sort_by: String(opts.sortBy ?? "-created_at") }; + if (!Number.isInteger(page.page_num) || page.page_num < 1) throw new UsageError("--page must be a positive integer"); + if (!Number.isInteger(page.page_size) || page.page_size < 1) throw new UsageError("--page-size must be a positive integer"); + const { tasks, raw } = await endpointOf(runtime.client).listDetailed(page, { signal: abortSignal() }); + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspaceRoot }) : null; + const rawItems = Array.isArray(raw) ? raw : []; + await emitResult( + opened, + tasks.map((t) => summarizeTask(t)), + { + items: tasks.map((t, i) => toTaskView(rawItems[i] ?? t, { descriptor, includeRaw: Boolean(opts.includeRaw) })), + count: tasks.length, + page, + saved_json: savedJson, + }, + { legacyFile: opened.flags.output }, + ); }); } else { cmd @@ -166,25 +690,581 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { return cmd; } +/** Strip media payloads before logging. */ +function redactForLog(payload: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(payload)) { + if (typeof v === "string" && v.startsWith("data:")) out[k] = `data:<${v.length} chars>`; + else if (Array.isArray(v)) out[k] = v.map((x) => (typeof x === "string" && x.startsWith("data:") ? `data:<${x.length} chars>` : x)); + else out[k] = v; + } + return out; +} + +export interface Submitted { + taskId: string; + raw: unknown; + requestId: string | null; + operationId: string; + warnings: Warning[]; +} + +export interface SubmitContext { + /** Names the request in messages, e.g. "make: the text-to-3d preview (geometry) request". */ + label?: string; + /** Extra keys carried in every failure result (e.g. `{ step: 1 }`). */ + extraResult?: Record; +} + /** - * Decide how to report a task whose status is (expected to be) terminal: - * - with `-o`: download artifacts to the filesystem and print the status - * report. Failed/timed-out tasks print the FAIL form without downloads. - * - without `-o`: emit the familiar JSON/pretty summary to stdout. + * The credential identity a submission is journaled under: bound to the key + * digest, the OAuth subject or the OAuth login id. `verified` is false when + * none of those exists (a pre-login-id OAuth profile): such a credential may + * start operations but is refused a replay of an existing record. */ -export async function emitTerminalOutcome( +export function credentialIdentityFor(runtime: Runtime, apiOrigin: string): { fingerprint: string; verified: boolean } { + const parts = { + source: runtime.config.credentialSource, + profile: runtime.config.credentialProfile ?? null, + origin: apiOrigin, + kind: runtime.config.credentialKind, + secret: runtime.config.credentialKind === "api_key" ? runtime.config.apiKey : null, + subject: runtime.config.credentialSubject ?? null, + loginId: runtime.config.credentialLoginId ?? null, + }; + return { fingerprint: credentialFingerprint(parts), verified: credentialBinding(parts).verified }; +} + +/** + * Journal → single POST → journal update. Every failure path leaves a record + * that says what is known; the unknown state is reported as exit 10 with the + * operation id and never as a suggestion to submit again. Shared by the + * resource commands and `make`, so there is exactly one submission state + * machine. + */ +export async function submitCreate( + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + payload: Record, + requestedOperationId: string | null, + ctx: SubmitContext = {}, +): Promise { + const root = operationsRoot(); + const operationId = requestedOperationId ?? newOperationId(); + const apiOrigin = originOf(endpoint.transportBaseUrl) ?? endpoint.transportBaseUrl; + const credential = credentialIdentityFor(runtime, apiOrigin); + const identity = { + resource: descriptor.id, + endpoint: descriptor.legacyEndpoint, + apiOrigin, + credentialFingerprint: credential.fingerprint, + credentialVerified: credential.verified, + payloadFingerprint: payloadFingerprint(payload), + }; + const label = ctx.label ?? "the create request"; + const extra = ctx.extraResult ?? {}; + const warnings: Warning[] = []; + + let begin: ReturnType; + try { + begin = beginOperation(root, operationId, identity); + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError({ code: "local_io", message: `cannot write the operation journal under ${root}: ${err instanceof Error ? err.message : String(err)}; nothing was submitted`, cause: err }); + } + + if (begin.outcome === "existing") { + return replayExisting(begin.record, descriptor, operationId, extra); + } + + if (abortSignal().aborted) { + updateOperation(root, operationId, { state: "not_submitted", error: "interrupted before the request was sent" }); + throw new CliError({ code: "interrupted", message: "interrupted before the request was sent; nothing was submitted", result: { submission: { state: "not_submitted", operation_id: operationId }, task: null, ...extra } }); + } + + let created: { taskId: string; raw: unknown; requestId: string | null }; + try { + created = await endpoint.createDetailed(payload, { signal: abortSignal() }); + } catch (err) { + const submission = classifySubmissionFailure(err); + try { + updateOperation(root, operationId, { + state: submission.state, + http_status: err instanceof MeshyApiError && err.status ? err.status : null, + error: err instanceof Error ? err.message : String(err), + }); + } catch (journalErr) { + warnings.push(warning("journal_write_failed", `operation journal update failed: ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`)); + } + if (submission.state === "rejected" || submission.state === "not_submitted") { + // Definite outcomes keep their own classification (validation/auth/network …). + if (err instanceof CliError) throw err; + const wrapped = err instanceof MeshyApiError ? err : err instanceof Error ? err : new Error(String(err)); + throw attachSubmission(wrapped, { state: submission.state, operation_id: operationId }, warnings); + } + const interrupted = wasInterrupted(); + throw new CliError({ + code: interrupted ? "interrupted" : "submission_unknown", + message: interrupted + ? `interrupted while ${label} was in flight; the server may or may not have created a task (operation ${operationId})` + : `${label} was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may or may not have created a task`, + httpStatus: err instanceof MeshyApiError && err.status ? err.status : null, + recovery: { + action: "reconcile", + automatic: false, + command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1 # then match operation ${operationId} by time/prompt before creating again`, + }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, downloads: NOT_REQUESTED(), ...extra }, + warnings, + cause: err, + }); + } + + try { + updateOperation(root, operationId, { state: "accepted", task_id: created.taskId, request_id: created.requestId, http_status: 200 }); + } catch (journalErr) { + // The server has the task; the id must survive this failure — a local write + // problem is local_io, never an unknown submission. + throw new CliError({ + code: "local_io", + message: `task ${created.taskId} was created but the operation journal could not be updated: ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`, + result: { + submission: { state: "accepted", operation_id: operationId, task_id: created.taskId, request_id: created.requestId }, + task: null, + task_id: created.taskId, + next: nextCommands(descriptor, created.taskId), + ...extra, + }, + cause: journalErr, + }); + } + return { taskId: created.taskId, raw: created.raw, requestId: created.requestId, operationId, warnings }; +} + +function replayExisting(record: OperationRecord, descriptor: TaskResourceDescriptor, operationId: string, extra: Record): Submitted { + if (record.state === "accepted" && record.task_id) { + return { + taskId: record.task_id, + raw: { result: record.task_id, replayed_from_journal: true }, + requestId: record.request_id, + operationId, + warnings: [warning("operation_replayed", `operation ${operationId} was already accepted as task ${record.task_id} on ${record.updated_at}; no new request was sent`)], + }; + } + if (record.state === "unknown" || record.state === "started") { + throw new CliError({ + code: "submission_unknown", + message: `operation ${operationId} is recorded as '${record.state}' since ${record.updated_at}; reconcile it before submitting again (nothing was sent now)`, + recovery: { action: "reconcile", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1` }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: record.task_id }, task: null, ...extra }, + }); + } + throw new CliError({ + code: "operation_conflict", + message: `operation ${operationId} was already ${record.state} on ${record.updated_at} (${record.error ?? "no detail"}); use a new --operation-id to submit again`, + result: { submission: { state: record.state, operation_id: operationId, task_id: record.task_id }, task: null, ...extra }, + }); +} + +function classifySubmissionFailure(err: unknown): { state: "rejected" | "not_submitted" | "unknown" } { + if (err instanceof TransportError) { + return { state: err.neverSent ? "not_submitted" : "unknown" }; + } + if (err instanceof MeshyApiError) { + if (err.status >= 400 && err.status < 500) return { state: "rejected" }; + return { state: "unknown" }; // 5xx, malformed 2xx, unknown status + } + return { state: "unknown" }; +} + +function attachSubmission(err: Error, submission: { state: string; operation_id: string }, warnings: Warning[]): Error { + (err as Error & { submission?: unknown; warnings?: Warning[] }).submission = submission; + (err as Error & { submission?: unknown; warnings?: Warning[] }).warnings = warnings; + return err; +} + +/** + * v1 `-o`: download a SUCCEEDED task's assets through the legacy layout, + * confined to the workspace when one is set. A failure keeps the task in the + * result — the assets are still on the server, the task still exists. + */ +async function maybeDownloadV1( + opened: OpenedCommand, + descriptor: TaskResourceDescriptor, + task: Task, + raw: unknown, + submission: SubmissionInfo, + warnings: Warning[], + ctx: { savedJson?: SavedJson | null; includeRaw?: boolean; project?: ProjectAttachment | null }, +): Promise { + const output = opened.flags.output; + if (!output) return NOT_REQUESTED(); + if (task.status !== "SUCCEEDED") return { state: "not_ready", files: [], metadata_path: null }; + try { + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspaceRoot, signal: abortSignal() }); + if (materialLinks) warnings.push(...materialLinks.warnings); + return { + state: "completed", + files: files.map((f) => ({ key: f.key, path: f.path, status: f.status, bytes: f.bytes, sha256: f.sha256, error: f.error })), + metadata_path: metadataPath, + material_links: materialLinks, + }; + } catch (err) { + // Whatever the downloader already committed stays in the manifest, and the + // failure keeps its own class: an HTTP 503 on the second asset is a network + // failure with its status, a Ctrl-C is `interrupted` (130) — never a bare local_io. + const partial: DownloadOutcome = + err instanceof CliError && err.result && err.result["downloads"] && typeof err.result["downloads"] === "object" + ? (err.result["downloads"] as DownloadOutcome) + : { state: "failed", files: [], metadata_path: null }; + const interrupted = (err instanceof CliError && err.code === "interrupted") || wasInterrupted(); + const failure = new CliError({ + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `task ${task.id} is SUCCEEDED but downloading its assets ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + retryable: err instanceof CliError ? err.retryable : false, + recovery: err instanceof CliError && err.recovery ? err.recovery : { action: "download", automatic: false, command: `meshy download --resource ${descriptor.id} --task-id ${task.id} --all --output-dir ` }, + hint: err instanceof CliError ? err.hint : undefined, + details: err instanceof CliError ? err.details : undefined, + result: { downloads: partial }, + warnings: [...warnings, ...(err instanceof CliError ? err.warnings : [])], + cause: err, + }); + throw withTaskContext(failure, { + descriptor, + taskId: task.id, + task, + raw, + submission, + includeRaw: ctx.includeRaw, + savedJson: ctx.savedJson, + extra: ctx.project ? { project: ctx.project } : {}, + }); + } +} + +async function waitAndReport( + opened: OpenedCommand, + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + taskId: string, + timeoutSeconds: number, + opts: Record, + submission: SubmissionInfo, + warnings: Warning[], +): Promise { + const started = performance.now(); + const includeRaw = Boolean(opts.includeRaw); + // A holder (not a bare `let`) so the callback's assignment is visible to the + // catch block without TypeScript narrowing it away. + const seen: { last: { task: Task; raw: unknown } | null; polls: number } = { last: null, polls: 0 }; + let poll: PollResult; + try { + poll = await pollUntilTerminal(endpoint, taskId, { + timeoutSeconds, + intervalMs: runtime.config.pollIntervalMs, + requestTimeoutMs: runtime.config.readTimeoutMs, + signal: abortSignal(), + onTick: (task, raw) => { + seen.last = { task, raw }; + seen.polls += 1; + if (opened.schema === "v1" && opened.format !== "ndjson") { + process.stderr.write(`[${descriptor.id}] ${task.status}${typeof task.progress === "number" ? ` ${task.progress}%` : ""}\n`); + } + }, + }); + } catch (err) { + if (wasInterrupted() || abortSignal().aborted) { + throw interruptedError(descriptor, taskId, seen.last, submission, opts, opened); + } + // A polling failure (5xx, network, malformed task) is not "no task": the + // id, the submission and the last status seen travel with the error. + const elapsed = (performance.now() - started) / 1000; + throw withTaskContext(err, { + descriptor, + taskId, + task: seen.last?.task ?? null, + raw: seen.last?.raw ?? null, + submission, + includeRaw, + extra: { wait: { timed_out: false, elapsed_seconds: Number(elapsed.toFixed(2)), polls: seen.polls } }, + }); + } + const elapsed = (performance.now() - started) / 1000; + const { task, raw, timedOut, aborted } = poll; + const waitInfo = { timed_out: timedOut, elapsed_seconds: Number(elapsed.toFixed(2)), polls: poll.polls }; + + if (aborted) throw interruptedError(descriptor, taskId, task ? { task, raw } : seen.last, submission, opts, opened); + + if (task === null) { + // The deadline passed before the first response arrived; the task id is all we know — and it is enough. + if (opened.schema !== "v1") { + emitLegacyTimeoutWithoutTask(taskId, descriptor.id, runtime); + return; + } + throw new CliError({ + code: "timed_out", + message: `task ${taskId} did not answer within ${timeoutSeconds}s (no status was received in time); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task: null, raw: null, descriptor, includeRaw, submission, extra: { task_id: taskId, wait: waitInfo, next: nextCommands(descriptor, taskId) } }), + warnings, + }); + } + + const ctx: TaskContext = { descriptor, taskId, task, raw, submission, includeRaw, extra: { wait: waitInfo } }; + const savedJson = saveJsonInContext(opts, opened, raw, ctx); + const project = attachInContext( + opts, + opened, + descriptor, + taskId, + task, + raw, + { operationId: (opts.__operationId as string | undefined) ?? submission.operation_id ?? null, payload: (opts.__payload as Record | undefined) ?? null }, + warnings, + { ...ctx, savedJson }, + ); + const projectExtra = project ? { project } : {}; + + if (opened.schema !== "v1") { + // The legacy reporter downloads too; whatever fails there — an asset host + // 503, a sidecar that cannot be published, Ctrl-C — the error still + // carries the accepted task id, the real submission and the resume command. + try { + await emitLegacyOutcome(task, timedOut, elapsed, descriptor.id, runtime, { query: false }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: { wait: waitInfo, ...projectExtra } }); + } + return; + } + + if (timedOut) { + throw new CliError({ + code: "timed_out", + message: `task ${taskId} did not reach a terminal status within ${timeoutSeconds}s (last status: ${task.status}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { task_id: taskId, wait: waitInfo, next: nextCommands(descriptor, taskId), ...projectExtra } }), + warnings, + }); + } + if (task.status !== "SUCCEEDED") { + throw new CliError({ + code: "task_failed", + message: task.task_error?.message ? `task ${taskId} ${task.status}: ${task.task_error.message}` : `task ${taskId} ended as ${task.status}`, + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { task_id: taskId, wait: waitInfo, ...projectExtra } }), + warnings, + }); + } + const downloads = await maybeDownloadV1(opened, descriptor, task, raw, submission, warnings, { savedJson, includeRaw, project }); + await emitEnvelope( + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { wait: waitInfo, ...projectExtra } }), warnings), + opened.format, + ); +} + +/** Legacy shape for a wait that timed out before any status arrived: still the task id, still exit 8. */ +function emitLegacyTimeoutWithoutTask(taskId: string, resourceName: string, runtime: Runtime): void { + if (runtime.flags.output) { + const report: Parameters[0] = { status: "FAIL", taskId, type: resourceName, timedOut: true }; + const notice = getUpdateNotice(); + if (notice) report._notice = notice; + printReport(report); + } else { + emit({ resource: resourceName, id: taskId, status: null, timed_out: true }, { format: runtime.flags.format }); + } + process.exitCode = 8; +} + +function interruptedError( + descriptor: TaskResourceDescriptor, + taskId: string, + last: { task: Task; raw: unknown } | null, + submission: SubmissionInfo, + opts: Record, + opened: OpenedCommand, + savedJson: SavedJson | null = null, +): CliError { + let saved = savedJson; + if (!saved && opts.saveJson && last) { + try { + saved = saveRawJson(opts.saveJson as string, last.raw, { workspace: opened.flags.workspaceRoot }); + } catch { + saved = null; + } + } + return new CliError({ + code: "interrupted", + message: `interrupted while waiting for task ${taskId}; the server keeps running it — resume with \`${nextCommands(descriptor, taskId).wait}\``, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task: last?.task ?? null, raw: last?.raw ?? null, descriptor, includeRaw: Boolean(opts.includeRaw), submission, savedJson: saved, extra: { task_id: taskId, next: nextCommands(descriptor, taskId) } }), + }); +} + +async function streamAndReport( + opened: OpenedCommand, + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + taskId: string, + timeoutSeconds: number, + idleSeconds: number, + opts: Record, +): Promise { + const includeRaw = Boolean(opts.includeRaw); + const ndjson = opened.format === "ndjson"; + let sequence = 0; + const submission: SubmissionInfo = { state: "accepted", operation_id: null }; + const warnings: Warning[] = []; + + const outcome = await streamTask(endpoint, taskId, { + timeoutMs: timeoutSeconds * 1000, + idleTimeoutMs: idleSeconds * 1000, + signal: abortSignal(), + onUnknownEvent: (ev) => { + warnings.push(warning("unknown_sse_event", `ignored SSE event '${ev.event}'`)); + }, + onTask: async (task, raw) => { + if (opened.schema === "v1" && ndjson) { + sequence += 1; + const event: StreamEventEnvelope = { + ...okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission })), + event: "task", + sequence, + }; + await emitStreamEvent(event); + } else if (opened.schema === "v1") { + process.stderr.write(`[${descriptor.id}] ${task.status}${typeof task.progress === "number" ? ` ${task.progress}%` : ""}\n`); + } + }, + }); + + const streamInfo = { events: outcome.events, ended: outcome.reason, elapsed_seconds: Number((outcome.elapsedMs / 1000).toFixed(2)) }; + const ctx: TaskContext = { descriptor, taskId, task: outcome.task, raw: outcome.raw, submission, includeRaw, extra: { stream: streamInfo, task_id: taskId } }; + + // Once the stream has started, every later step — saving JSON, recording the + // project, downloading — is part of the same terminal outcome: one `outcome` + // event (ndjson) or one envelope (json/pretty), never a bare error after it. + let savedJson: SavedJson | null = null; + let project: ProjectAttachment | null = null; + let bookkeepingError: CliError | null = null; + try { + savedJson = opts.saveJson && outcome.raw ? saveJsonInContext(opts, opened, outcome.raw, ctx) : null; + project = outcome.task ? attachInContext(opts, opened, descriptor, taskId, outcome.task, outcome.raw, {}, warnings, { ...ctx, savedJson }) : null; + } catch (err) { + bookkeepingError = err instanceof CliError ? err : withTaskContext(err, ctx); + } + const projectExtra = project ? { project } : {}; + + let finalError: CliError | null = null; + const resultWith = (downloads?: DownloadOutcome): Record => + taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { stream: streamInfo, task_id: taskId, next: nextCommands(descriptor, taskId), ...projectExtra } }); + let result = resultWith(); + switch (outcome.reason) { + case "terminal": + if (outcome.task && outcome.task.status !== "SUCCEEDED") { + finalError = new CliError({ + code: "task_failed", + message: outcome.task.task_error?.message ? `task ${taskId} ${outcome.task.status}: ${outcome.task.task_error.message}` : `task ${taskId} ended as ${outcome.task.status}`, + result, + warnings, + }); + } + break; + case "timeout": + finalError = new CliError({ code: "timed_out", message: `stream deadline of ${timeoutSeconds}s reached (last status: ${outcome.task?.status ?? "none"}); the server keeps running the task`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "idle_timeout": + finalError = new CliError({ code: "network", message: `no bytes received for ${idleSeconds}s on the stream (last status: ${outcome.task?.status ?? "none"})`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "disconnected": + finalError = new CliError({ code: "network", message: outcome.error?.message ?? "stream disconnected before a terminal status", recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "interrupted": + finalError = new CliError({ code: "interrupted", message: `interrupted while streaming task ${taskId}; the server keeps running it`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "error": + case "protocol": + finalError = wrapStreamError(outcome.error, result, warnings); + break; + } + if (bookkeepingError) { + if (finalError) { + // The stream's own failure is the outcome; the bookkeeping failure rides along as a warning. + finalError.warnings.push(warning("bookkeeping_failed", bookkeepingError.message)); + } else { + finalError = bookkeepingError; + } + } + + if (opened.schema !== "v1") { + // Legacy has no stream shape to preserve: reuse the terminal summary path (which honours -o itself). + if (finalError) throw finalError; + if (outcome.task) { + try { + await emitLegacyOutcome(outcome.task, false, outcome.elapsedMs / 1000, descriptor.id, runtime, { query: false }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: { stream: streamInfo, ...projectExtra } }); + } + } + return; + } + + // The terminal outcome includes the requested download whatever the output + // format: `-o` means "put the assets on disk", not "only when printing JSON". + if (!finalError && outcome.task && outcome.task.status === "SUCCEEDED") { + try { + const downloads = await maybeDownloadV1(opened, descriptor, outcome.task, outcome.raw, submission, warnings, { savedJson, includeRaw, project }); + result = resultWith(downloads); + } catch (err) { + finalError = err instanceof CliError ? err : withTaskContext(err, ctx); + } + } + + if (ndjson) { + sequence += 1; + const body = finalError ? errorEnvelope(opened.command, finalError).envelope : okEnvelope(opened.command, result, warnings); + const event: StreamEventEnvelope = { ...body, event: "outcome", sequence }; + await emitStreamEvent(event); + // The outcome line already carries the error; exit with its code without a second envelope. + if (finalError) process.exitCode = finalError.exitCode; + return; + } + if (finalError) throw finalError; + await emitEnvelope(okEnvelope(opened.command, result, warnings), opened.format); +} + +function wrapStreamError(err: MeshyApiError | CliError | null, result: Record, warnings: Warning[]): CliError { + if (err instanceof CliError) { + return new CliError({ code: err.code, message: err.message, httpStatus: err.httpStatus, recovery: err.recovery, result, warnings: [...err.warnings, ...warnings], cause: err }); + } + if (err instanceof MeshyApiError) { + const code = err.code === "server" ? "server" : err.code; + return new CliError({ code, message: err.message, httpStatus: err.status || null, result, warnings, cause: err }); + } + return new CliError({ code: "protocol", message: "stream ended abnormally", result, warnings }); +} + +/** + * Legacy reporter. `query: true` is `get`: a valid task is a successful query + * whatever its status (0.2.0 exited 1 for PENDING/IN_PROGRESS — fixed); a + * terminal FAILED/CANCELED still exits 1 for compatibility. + */ +async function emitLegacyOutcome( task: Task, timedOut: boolean, elapsedSeconds: number | undefined, resourceName: string, runtime: Runtime, + mode: { query: boolean }, ): Promise { const output = runtime.flags.output; const succeeded = !timedOut && task.status === "SUCCEEDED"; if (output) { if (succeeded) { - const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName); + const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspaceRoot, signal: abortSignal() }); const successReport: Parameters[0] = { status: "SUCCESS", taskId: task.id, @@ -195,35 +1275,49 @@ export async function emitTerminalOutcome( const successNotice = getUpdateNotice(); if (successNotice) successReport._notice = successNotice; printReport(successReport); - } else { - const report: Parameters[0] = { - status: "FAIL", - taskId: task.id, - type: task.type || resourceName, - }; - if (timedOut) report.timedOut = true; - else if (task.task_error?.message) report.error = task.task_error.message; - else if (task.status) report.error = `task status: ${task.status}`; - const failNotice = getUpdateNotice(); - if (failNotice) report._notice = failNotice; - printReport(report); - process.exitCode = timedOut ? 8 : 1; + return; } - if (succeeded) return; - } else { - // No -o: keep the JSON/pretty summary for machine consumption. - emit( - { - resource: resourceName, - ...summarizeTask(task, elapsedSeconds), - ...(timedOut ? { timed_out: true } : {}), - }, - { format: runtime.flags.format }, - ); - } - - if (!output) { + const report: Parameters[0] = { + status: "FAIL", + taskId: task.id, + type: task.type || resourceName, + }; + if (timedOut) report.timedOut = true; + else if (task.task_error?.message) report.error = task.task_error.message; + else if (task.status) report.error = `task status: ${task.status}`; + const failNotice = getUpdateNotice(); + if (failNotice) report._notice = failNotice; + printReport(report); if (timedOut) process.exitCode = 8; - else if (!TERMINAL_STATUSES.has(task.status) || task.status !== "SUCCEEDED") process.exitCode = 1; + else if (mode.query && !isTerminalStatus(task.status)) process.exitCode = 0; + else process.exitCode = 1; + return; } + + emit( + { + resource: resourceName, + ...summarizeTask(task, elapsedSeconds), + ...(timedOut ? { timed_out: true } : {}), + }, + { format: runtime.flags.format }, + ); + if (timedOut) process.exitCode = 8; + else if (task.status === "SUCCEEDED") process.exitCode = 0; + else if (!isTerminalStatus(task.status)) process.exitCode = mode.query ? 0 : 1; + else process.exitCode = 1; } + +/** Kept for make.ts (legacy path). */ +export async function emitTerminalOutcome( + task: Task, + timedOut: boolean, + elapsedSeconds: number | undefined, + resourceName: string, + runtime: Runtime, +): Promise { + await emitLegacyOutcome(task, timedOut, elapsedSeconds, resourceName, runtime, { query: false }); +} + +export { taskResult as buildTaskResult, nextCommands as taskNextCommands }; +export type { TaskView }; diff --git a/src/internal/task-view.ts b/src/internal/task-view.ts new file mode 100644 index 0000000..50f18ff --- /dev/null +++ b/src/internal/task-view.ts @@ -0,0 +1,131 @@ +/** + * TaskView — the canonical v1 task summary. + * + * Built from the raw JSON the server returned, not from the Zod-defaulted + * object: a field the server omitted is `null` here (never 0 or ""), so a + * missing face_count cannot masquerade as "zero faces". `raw` is attached only + * when the caller asked for it (--include-raw). + */ + +import type { TaskResourceDescriptor } from "../client/resource-registry.js"; + +export interface TaskView { + task_id: string; + resource: string | null; + endpoint: string | null; + type: string | null; + name: string | null; + status: string | null; + progress: number | null; + preceding_tasks: number | null; + created_at: number | null; + started_at: number | null; + finished_at: number | null; + expires_at: number | null; + face_count: number | null; + consumed_credits: number | null; + model_urls: Record; + image_urls: string[]; + texture_urls: Array>; + thumbnail_url: string | null; + thumbnail_urls: Record | null; + alpha_thumbnail_url: string | null; + result: Record | null; + printability: Record | null; + task_error: { message: string | null; [k: string]: unknown } | null; + /** Full response as received, only when --include-raw was given. */ + raw?: unknown; +} + +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} + +function strRecord(v: unknown): Record | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + out[k] = typeof val === "string" ? val : null; + } + return out; +} + +export interface TaskViewOptions { + descriptor?: TaskResourceDescriptor | null; + includeRaw?: boolean; + /** Explicit endpoint when there is no descriptor (e.g. a saved task JSON without resource). */ + endpoint?: string | null; +} + +/** + * Normalise a raw task object. Throws only when there is no usable `id`; every + * other field degrades to null so a partial server payload still yields a view. + */ +export function toTaskView(raw: unknown, opts: TaskViewOptions = {}): TaskView { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("task payload is not an object"); + } + const r = raw as Record; + const id = typeof r["id"] === "string" ? r["id"] : typeof r["task_id"] === "string" ? (r["task_id"] as string) : ""; + if (!id) throw new Error("task payload has no id"); + const taskError = r["task_error"]; + const view: TaskView = { + task_id: id, + resource: opts.descriptor?.id ?? null, + endpoint: opts.descriptor?.legacyEndpoint ?? opts.endpoint ?? null, + type: str(r["type"]), + name: str(r["name"]), + status: str(r["status"]), + progress: num(r["progress"]), + preceding_tasks: num(r["preceding_tasks"]), + created_at: num(r["created_at"]), + started_at: num(r["started_at"]), + finished_at: num(r["finished_at"]), + expires_at: num(r["expires_at"]), + face_count: num(r["face_count"]), + consumed_credits: num(r["consumed_credits"]), + model_urls: strRecord(r["model_urls"]) ?? {}, + image_urls: Array.isArray(r["image_urls"]) ? (r["image_urls"] as unknown[]).filter((u): u is string => typeof u === "string") : [], + texture_urls: Array.isArray(r["texture_urls"]) + ? (r["texture_urls"] as unknown[]).map((set) => strRecord(set) ?? {}) + : [], + thumbnail_url: str(r["thumbnail_url"]), + thumbnail_urls: strRecord(r["thumbnail_urls"]), + alpha_thumbnail_url: str(r["alpha_thumbnail_url"]), + result: r["result"] && typeof r["result"] === "object" && !Array.isArray(r["result"]) ? (r["result"] as Record) : null, + printability: r["printability"] && typeof r["printability"] === "object" ? (r["printability"] as Record) : null, + task_error: + taskError && typeof taskError === "object" + ? { ...(taskError as Record), message: str((taskError as Record)["message"]) } + : null, + }; + if (opts.includeRaw) view.raw = raw; + return view; +} + +/** Accept a task JSON in any of the shapes the CLI has written or the API returns. */ +export function extractTaskObject(input: unknown): { task: Record; source: "api" | "meta.json" | "v1-envelope" | "v1-result" } | null { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + const o = input as Record; + if (typeof o["id"] === "string" && typeof o["status"] === "string") return { task: o, source: "api" }; + const meta = o["task"]; + if (meta && typeof meta === "object" && typeof (meta as Record)["id"] === "string") { + return { task: meta as Record, source: "meta.json" }; + } + const result = o["result"]; + if (result && typeof result === "object") { + const rt = (result as Record)["task"]; + if (rt && typeof rt === "object" && typeof (rt as Record)["task_id"] === "string") { + const view = rt as Record; + // A v1 TaskView carries the same fields under task_id; fold it back. + const raw = view["raw"]; + if (raw && typeof raw === "object") return { task: raw as Record, source: "v1-envelope" }; + return { task: { ...view, id: view["task_id"] }, source: "v1-result" }; + } + } + return null; +} diff --git a/src/internal/update-notifier.ts b/src/internal/update-notifier.ts index 1adef7a..c3e1183 100644 --- a/src/internal/update-notifier.ts +++ b/src/internal/update-notifier.ts @@ -15,9 +15,9 @@ */ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { spawn } from "node:child_process"; +import { configDir } from "./credentials.js"; import { VERSION } from "./version.js"; import type { OutputFormat } from "./output.js"; @@ -64,9 +64,13 @@ interface UpdateState { // Path helpers // --------------------------------------------------------------------------- -/** Returns the path to the update-state cache file. */ +/** + * Returns the path to the update-state cache file. Lives in the config + * directory (MESHY_CONFIG_DIR when set, else ~/.config/meshy) so an isolated + * config dir also isolates the update cache. + */ export function stateFilePath(): string { - return join(homedir(), ".config", "meshy", "update-state.json"); + return join(configDir(), "update-state.json"); } // --------------------------------------------------------------------------- diff --git a/src/root.ts b/src/root.ts index 5a6935f..7895c74 100644 --- a/src/root.ts +++ b/src/root.ts @@ -15,28 +15,72 @@ import { REFRESH_COMMAND, runRefreshCommand } from "./internal/update-notifier.j import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, + walkCommands, } from "./internal/global-options.js"; import { analyzePrintabilityCommand } from "./cmd/analyze-printability.js"; import { animateCommand } from "./cmd/animate.js"; +import { animationCatalogCommand } from "./cmd/animation-catalog.js"; import { apiCommand } from "./cmd/api.js"; import { authCommand } from "./cmd/auth.js"; import { balanceCommand } from "./cmd/balance.js"; import { convertCommand } from "./cmd/convert.js"; +import { creativeLabCommand } from "./cmd/creative-lab.js"; import { deleteCommand } from "./cmd/delete.js"; +import { doctorCommand } from "./cmd/doctor.js"; +import { downloadCommand } from "./cmd/download.js"; import { imageTo3dCommand } from "./cmd/image-to-3d.js"; import { imageToImageCommand } from "./cmd/image-to-image.js"; +import { inspectCommand } from "./cmd/inspect.js"; import { makeCommand } from "./cmd/make.js"; +import { meshCommand } from "./cmd/mesh.js"; import { multiColorPrintCommand } from "./cmd/multi-color-print.js"; import { multiImageTo3dCommand } from "./cmd/multi-image-to-3d.js"; +import { projectCommand } from "./cmd/project.js"; import { remeshCommand } from "./cmd/remesh.js"; import { repairPrintabilityCommand } from "./cmd/repair-printability.js"; import { resizeCommand } from "./cmd/resize.js"; import { resourcesCommand } from "./cmd/resources.js"; import { retextureCommand } from "./cmd/retexture.js"; import { riggingCommand } from "./cmd/rigging.js"; +import { showcasesCommand } from "./cmd/showcases.js"; +import { slicerCommand } from "./cmd/slicer.js"; import { textTo3dCommand } from "./cmd/text-to-3d.js"; import { textToImageCommand } from "./cmd/text-to-image.js"; import { textToMotionCommand } from "./cmd/text-to-motion.js"; +import { uvUnwrapCommand } from "./cmd/uv-unwrap.js"; + +/** + * Commands added in S1. They only speak the v1 envelope; the error exit uses + * this set to pick the schema when a parse error happens before any action. + */ +export const V1_ONLY_COMMANDS: ReadonlySet = new Set([ + "uv-unwrap", + "creative-lab", + "animation-catalog", + "showcases", + "download", + "project", + "inspect", + "mesh", + "slicer", + "doctor", +]); + +/** + * Commands that never need the authenticated API. The background update check + * is skipped for them so local work spawns no network child. + */ +export const LOCAL_COMMANDS: ReadonlySet = new Set([ + "resources", + "project", + "inspect", + "mesh", + "slicer", + "doctor", + "download", + "animation-catalog", + REFRESH_COMMAND, +]); const ROOT_LONG = `meshy-cli — command-line interface for the Meshy AI API. @@ -53,7 +97,7 @@ EXAMPLES: meshy make "a red sports car" --max-credits 25 # refuse if the estimate is over # every endpoint, one at a time - meshy resources # index of the 17 commands + meshy resources # index of every command meshy text-to-3d create --mode preview --prompt "a red sports car" meshy image-to-3d get meshy delete # unified: works for any task @@ -70,26 +114,32 @@ AUTHENTICATION: auth use switch the active profile auth logout [--all] forget the stored credential - Resolution order: --api-key > MESHY_API_KEY > stored profile. The env var - stays ahead of the stored credential so CI is never overridden by whatever - a developer logged into on that machine. Non-production --base-url-v1 uses a - separate credentials.dev.json, so staging cannot clobber a production login. + Resolution order: --api-key > MESHY_API_KEY > --api-key-file > stored profile. The + env var stays ahead of the stored credential so CI is never overridden by + whatever a developer logged into on that machine. Non-production --base-url-v1 + uses a separate credentials.dev.json, so staging cannot clobber a production login. RESOURCE COMMANDS: - All 17 endpoint commands stay available and stay supported — they are - indexed by \`meshy resources\` instead of listed here, so this help does not - grow with the API. \`meshy resources --help\` also carries the shared - create/get/list/wait/delete verb contract. + All endpoint commands stay available and stay supported — they are indexed + by \`meshy resources\` instead of listed here, so this help does not grow + with the API. \`meshy resources --help\` also carries the shared + create/get/list/wait/stream/delete verb contract. GLOBAL FLAGS (accepted at any position in the command line): --api-key override MESHY_API_KEY --base-url-v1 override MESHY_BASE_URL_V1 --base-url-v2 override MESHY_BASE_URL_V2 + --base-url-creative-lab override the Creative Lab base (default: /openapi/creative-lab) --format json (default) | pretty | ndjson + --output-schema legacy (default for existing commands) | v1 (stable envelope; new commands) --output, -o download task artifacts to a file or directory, write meta.json alongside, and replace stdout with a status report (without -o: stdout keeps the JSON summary). + --api-key-file read MESHY_API_KEY from an explicit dotenv-style file + (only that key; never auto-discovered; do not use Node's --env-file) + --workspace confine every written file to this directory + --no-update-check skip the background npm version check --verbose, -v enable debug logging to stderr --log-level debug | info | warn | error | silent @@ -99,6 +149,7 @@ ENVIRONMENT: MESHY_CREDENTIALS_PATH exact credentials file (wins over the above) MESHY_BASE_URL_V1 default: https://api.meshy.ai/openapi/v1 MESHY_BASE_URL_V2 default: https://api.meshy.ai/openapi/v2 + MESHY_BASE_URL_CREATIVE_LAB default: derived from the v1 origin MESHY_OAUTH_AUTHORIZE_URL override the OAuth authorize page (staging/testing) MESHY_CLI_NO_BROWSER set to 1 to skip browser open (headless/agent use) MESHY_POLL_INTERVAL_MS default: 3000 @@ -145,6 +196,16 @@ export function buildRootCommand(): Command { analyzePrintabilityCommand, repairPrintabilityCommand, deleteCommand, + uvUnwrapCommand, + creativeLabCommand, + animationCatalogCommand, + showcasesCommand, + downloadCommand, + projectCommand, + inspectCommand, + meshCommand, + slicerCommand, + doctorCommand, ]) { program.addCommand(cmd, { hidden: true }); } @@ -157,5 +218,13 @@ export function buildRootCommand(): Command { mirrorGlobalOptionsToDescendants(program); + // Parse errors (unknown option/command, missing argument) must reach the + // unified exit in index.ts instead of Commander's own process.exit(1), so + // every command in the tree throws CommanderError. addCommand() does not + // inherit this setting, hence the walk. + walkCommands(program, (cmd) => { + cmd.exitOverride(); + }); + return program; } diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts new file mode 100644 index 0000000..ec4691c --- /dev/null +++ b/tests/artifacts.test.ts @@ -0,0 +1,118 @@ +/** + * Asset enumeration and selection (T-060, T-061, T-062, T-063, T-071). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { enumerateAssets, resolveAssetKey, selectAssets, SelectionError } from "../src/internal/artifacts.js"; +import { requireTaskResource } from "../src/client/resource-registry.js"; +import { extractTaskObject, toTaskView } from "../src/internal/task-view.js"; + +const rig = JSON.parse(readFileSync(new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url), "utf8")) as Record; + +test("T-060 rigging: rig + nested walking/running animations are enumerated with role keys", () => { + const e = enumerateAssets(rig, requireTaskResource("rigging")); + assert.deepEqual( + e.assets.map((a) => [a.key, a.kind, a.format]), + [ + ["result.rigged_character_glb_url", "rig", "glb"], + ["result.basic_animations.walking_glb_url", "animation", "glb"], + ["result.basic_animations.running_glb_url", "animation", "glb"], + ], + ); + assert.deepEqual(e.unknown_urls, []); +}); + +test("T-060 textured model: model formats, MTL dependency, textures, primary/multiview/alpha thumbnails", () => { + const task = { + id: "t", + type: "text-to-3d-refine", + status: "SUCCEEDED", + model_urls: { glb: "https://a.example/m.glb", obj: "https://a.example/m.obj", mtl: "https://a.example/m.mtl", fbx: "https://a.example/m.fbx", usdz: null }, + thumbnail_url: "https://a.example/t.png", + thumbnail_urls: { front: "https://a.example/f.png", back: "https://a.example/b.png" }, + alpha_thumbnail_url: "https://a.example/alpha.png", + texture_urls: [{ base_color: "https://a.example/bc.png", normal: "https://a.example/n.png", metallic: null }], + }; + const e = enumerateAssets(task, requireTaskResource("text-to-3d")); + const keys = e.assets.map((a) => a.key); + assert.deepEqual(keys, [ + "model.glb", "model.obj", "model.mtl", "model.fbx", + "thumbnail.primary", "thumbnail.front", "thumbnail.back", "thumbnail.alpha", + "texture.0.base_color", "texture.0.normal", + ]); + const obj = e.assets.find((a) => a.key === "model.obj")!; + assert.deepEqual(obj.dependencies, ["model.mtl", "texture.0.base_color", "texture.0.normal"]); + assert.equal(e.assets.find((a) => a.key === "thumbnail.front")?.notes?.["view"], "front"); +}); + +test("T-060 lamp build parts are STL/ZIP, never `.lamp_stl`; keychain obj is a ZIP container", () => { + const lamp = enumerateAssets({ id: "l", type: "creative-lab-lamp-build", status: "SUCCEEDED", model_urls: { lamp_stl: "https://a.example/lamp", base_stl: "https://a.example/base", bundle_zip: "https://a.example/bundle" } }); + assert.deepEqual(lamp.assets.map((a) => [a.key, a.format, a.filename]), [ + ["model.lamp_stl", "stl", "lamp.stl"], + ["model.base_stl", "stl", "base.stl"], + ["model.bundle_zip", "zip", "bundle.zip"], + ]); + const kc = enumerateAssets({ id: "k", type: "creative-lab-keychain-build", status: "SUCCEEDED", model_urls: { obj: "https://a.example/kc" } }); + const obj = kc.assets[0]!; + assert.equal(obj.format, "zip"); + assert.equal(obj.containerFormat, "zip"); + assert.equal(obj.modelFormat, "obj"); + assert.equal(obj.filename, "model.obj.zip"); + assert.equal(obj.notes?.["extracted"], false); + assert.deepEqual(obj.dependencies, []); + // With the descriptor instead of the type. + const fm = enumerateAssets({ id: "f", status: "SUCCEEDED", model_urls: { obj: "https://a.example/fm" } }, requireTaskResource("creative-lab.fridge-magnet.build")); + assert.equal(fm.assets[0]!.containerFormat, "zip"); + // A plain text-to-3d OBJ is a real OBJ. + const plain = enumerateAssets({ id: "p", type: "text-to-3d-refine", status: "SUCCEEDED", model_urls: { obj: "https://a.example/p.obj" } }); + assert.equal(plain.assets[0]!.containerFormat, null); + assert.equal(plain.assets[0]!.filename, "model.obj"); +}); + +test("T-060 motion clips use motion_format; report-only tasks yield a JSON report asset; unknown URLs are listed, not fetched", () => { + const motion = enumerateAssets({ id: "m", type: "text-to-motion", status: "SUCCEEDED", result: { motion_url: "https://a.example/clip", motion_format: "bvh", duration_ms: 3000 } }); + assert.deepEqual(motion.assets.map((a) => [a.key, a.kind, a.format, a.filename]), [["result.motion_url", "motion", "bvh", "motion.bvh"]]); + const report = enumerateAssets({ id: "r", type: "print-analyze", status: "SUCCEEDED", printability: { status: "healthy" } }); + assert.equal(report.assets[0]!.kind, "report"); + assert.equal(report.assets[0]!.url, null); + assert.deepEqual(report.assets[0]!.report, { status: "healthy" }); + const odd = enumerateAssets({ id: "o", status: "SUCCEEDED", result: { mystery_url: "https://a.example/x", nested: { deeper: "https://a.example/y" } } }); + assert.deepEqual(odd.assets, []); + assert.deepEqual(odd.unknown_urls.map((u) => u.path), ["result.mystery_url", "result.nested.deeper"]); +}); + +test("T-061 selection: keys (with legacy aliases), model format, kind, all; contradictions and misses throw with candidates", () => { + const task = { id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: "https://a/g", obj: "https://a/o", mtl: "https://a/m" }, thumbnail_url: "https://a/t.png", texture_urls: [{ base_color: "https://a/bc.png" }] }; + const e = enumerateAssets(task); + assert.deepEqual(selectAssets(e, { keys: ["model.glb", "thumbnail"] }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb", "thumbnail.primary"]); + assert.deepEqual(selectAssets(e, { keys: ["model_glb", "texture_0_base_color"] }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb", "texture.0.base_color"]); + assert.deepEqual(selectAssets(e, { modelFormat: "GLB" }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb"]); + assert.deepEqual(selectAssets(e, { kind: "texture" }, { withDependencies: true }).selected.map((a) => a.key), ["texture.0.base_color"]); + assert.equal(selectAssets(e, { all: true }, { withDependencies: false }).selected.length, 5); + const objSel = selectAssets(e, { keys: ["model.obj"] }, { withDependencies: true }); + assert.deepEqual(objSel.dependencies.map((a) => a.key), ["model.mtl", "texture.0.base_color"]); + assert.deepEqual(selectAssets(e, { keys: ["model.obj"] }, { withDependencies: false }).dependencies, []); + assert.throws(() => selectAssets(e, { keys: ["model.usdz"] }, { withDependencies: true }), (err: unknown) => err instanceof SelectionError && err.candidates.length === 5); + assert.throws(() => selectAssets(e, { modelFormat: "3mf" }, { withDependencies: true }), SelectionError); + assert.throws(() => selectAssets(e, {}, { withDependencies: true }), SelectionError); + assert.equal(resolveAssetKey("animation_glb_url", enumerateAssets({ id: "a", status: "SUCCEEDED", result: { animation_glb_url: "https://a/x.glb" } }).assets)?.key, "result.animation_glb_url"); + assert.equal(resolveAssetKey("walking_glb_url", enumerateAssets(rig).assets)?.key, "result.basic_animations.walking_glb_url"); +}); + +test("T-062 OBJ without MTL in the task reports the missing dependency instead of pretending", () => { + const e = enumerateAssets({ id: "t", type: "remesh", status: "SUCCEEDED", model_urls: { obj: "https://a/o" } }); + const sel = selectAssets(e, { keys: ["model.obj"] }, { withDependencies: true }); + assert.deepEqual(sel.dependencies, []); + assert.deepEqual(sel.missingDependencies, []); + assert.deepEqual(e.assets[0]!.dependencies, [], "no MTL in the task → nothing to depend on"); +}); + +test("T-063 the same task yields the same assets from an API task, a meta.json and a v1 envelope", () => { + const fromApi = enumerateAssets(rig).assets.map((a) => a.key); + const meta = extractTaskObject({ resource: "rigging", task: rig, saved_files: [], downloaded_at: "x" })!; + const env = extractTaskObject({ schema_version: "meshy.cli/v1", command: "rigging.get", ok: true, result: { task: toTaskView(rig, { includeRaw: true }) }, error: null, warnings: [] })!; + assert.deepEqual(enumerateAssets(meta.task).assets.map((a) => a.key), fromApi); + assert.deepEqual(enumerateAssets(env.task).assets.map((a) => a.key), fromApi); +}); diff --git a/tests/auth-headless.test.ts b/tests/auth-headless.test.ts index 151b1ff..31cfbff 100644 --- a/tests/auth-headless.test.ts +++ b/tests/auth-headless.test.ts @@ -172,10 +172,11 @@ test( // Credentials file should exist with oauth kind. assert.ok(existsSync(credFile), "credentials file should exist"); const creds = JSON.parse(readFileSync(credFile, "utf8")) as { - profiles: Record; + profiles: Record; }; assert.equal(creds.profiles["default"]?.kind, "oauth"); assert.equal(creds.profiles["default"]?.access_token, "e2e-device-access-token"); + assert.match(String(creds.profiles["default"]?.login_id), /^[0-9a-f-]{36}$/, "every OAuth login mints a login id (R2-F06)"); } finally { await stub.close(); } diff --git a/tests/catalog-showcases.test.ts b/tests/catalog-showcases.test.ts new file mode 100644 index 0000000..78ec042 --- /dev/null +++ b/tests/catalog-showcases.test.ts @@ -0,0 +1,113 @@ +/** + * animation-catalog (T-026) and showcases (T-027) as black-box subprocesses. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const CATALOG = { + result: { + total: 3, + list: [ + { id: 290, key: "wave_one_hand", name: "Wave One Hand", category: "DailyActions", subCategory: "Interacting", previewUrl: "https://cdn.example.invalid/wave.gif", rigType: "biped", isDefault: false, isFree: true }, + { id: -1, key: "idle", name: "Idle", category: "DailyActions", subCategory: "Idle", previewUrl: "https://cdn.example.invalid/idle.gif", rigType: "biped", isDefault: true, isFree: true }, + { id: 12, key: "run", name: "Running", category: "WalkAndRun", subCategory: "Run", previewUrl: "https://cdn.example.invalid/run.gif", rigType: "biped", isDefault: false, isFree: false }, + ], + }, +}; + +test("T-026 catalog: public path, no Authorization/Cookie, no credential required, local search", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/web/public/animations/resources") { + const q = new URL(req.url, "http://x").searchParams; + const list = q.get("category") ? CATALOG.result.list.filter((e) => e.category === q.get("category")) : CATALOG.result.list; + return jsonReply(res, 200, { result: { total: list.length, list } }); + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + // No MESHY_API_KEY, no profile: the command must still work. + const env = api.env({ MESHY_API_KEY: undefined }); + const r = await runCli(["animation-catalog", "list", "--category", "DailyActions", "--search", "WAVE"], { env }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { command: string; result: { items: Array<{ action_id: number; name: string }>; search_scope: string; fetched: number; total: number; authenticated: boolean } }; + assert.equal(out.command, "animation-catalog.list"); + assert.deepEqual(out.result.items.map((i) => i.action_id), [290]); + assert.equal(out.result.search_scope, "local"); + assert.equal(out.result.fetched, 2); + assert.equal(out.result.authenticated, false); + assert.equal(api.requests.length, 1); + const req = api.requests[0]!; + assert.equal(req.method, "GET"); + assert.equal(req.url, "/web/public/animations/resources?category=DailyActions"); + assert.equal(req.headers["authorization"], undefined); + assert.equal(req.headers["cookie"], undefined); + + // Negative ids and an empty result are fine; nothing is fabricated. + const empty = await runCli(["animation-catalog", "list", "--search", "does-not-exist"], { env }); + assert.equal(empty.code, 0); + assert.deepEqual((parseSingleJson(empty.stdout) as { result: { items: unknown[] } }).result.items, []); + const all = parseSingleJson((await runCli(["animation-catalog", "list"], { env })).stdout) as { result: { items: Array<{ action_id: number }> } }; + assert.ok(all.result.items.some((i) => i.action_id === -1)); + + // Legacy schema is not available for a v1-only command. + const legacy = await runCli(["animation-catalog", "list", "--output-schema", "legacy"], { env }); + assert.equal(legacy.code, 2); + } finally { + await api.close(); + } +}); + +test("T-027 showcases: exactly one billable GET with the given params; items pass through; alias warns", async () => { + const items = [{ id: "s1", result_id: "t1", name: "Car", author: "a", community_url: "https://www.meshy.ai/x", model_url: "https://assets.example.invalid/m.glb", conversion_status: "", mode: "refine", extra: { kept: true } }]; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/showcases") return jsonReply(res, 200, { result: items }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const r = await runCli(["showcases", "list", "--search", "car", "--page-size", "3", "--model-format", "glb", "--showcase-type", "animated", "--sort-by", "-downloads"], { env: api.env() }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { result: { items: unknown[]; requests_made: number; billing: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(out.result.items, items); + assert.equal(out.result.requests_made, 1); + assert.equal(out.result.billing, "may-charge"); + assert.equal(out.warnings[0]?.code, "showcase_type_alias"); + assert.equal(api.requests.length, 1); + const q = new URL(api.requests[0]!.url, "http://x").searchParams; + assert.equal(q.get("search"), "car"); + assert.equal(q.get("page_size"), "3"); + assert.equal(q.get("format"), "glb"); + assert.equal(q.get("showcase_type"), "animate"); + assert.equal(q.get("sort_by"), "-downloads"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + } finally { + await api.close(); + } +}); + +test("T-027 showcases: non-Enterprise 403 and a network error are single attempts, never retried", async () => { + let hits = 0; + const api = await startMockApi((_req, res) => { + hits += 1; + jsonReply(res, 403, { message: "This endpoint is only available for enterprise users" }); + }); + try { + const r = await runCli(["showcases", "list"], { env: api.env() }); + assert.equal(r.code, 1, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { http_status: number; message: string } }; + assert.equal(out.ok, false); + assert.equal(out.error.http_status, 403); + assert.match(out.error.message, /enterprise/i); + assert.equal(hits, 1); + const bad = await runCli(["showcases", "list", "--page-size", "11"], { env: api.env() }); + assert.equal(bad.code, 2); + assert.equal(hits, 1, "usage errors make no request"); + } finally { + await api.close(); + } + const dead = await startMockApi(() => undefined); + await dead.close(); + const r = await runCli(["showcases", "list"], { env: dead.env() }); + assert.equal(r.code, 7, r.stderr); +}); diff --git a/tests/cli-contract.test.ts b/tests/cli-contract.test.ts new file mode 100644 index 0000000..526aab9 --- /dev/null +++ b/tests/cli-contract.test.ts @@ -0,0 +1,284 @@ +/** + * Black-box contract tests for the output schema layer (T-001..T-005, T-010, + * T-012, T-100, T-102). Every run is a real subprocess against dist/ with an + * isolated config dir; API calls go to a loopback mock. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + isolatedEnv, + jsonReply, + parseSingleJson, + runCli, + startMockApi, + tmpDir, +} from "./helpers/cli.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +test("--version and --help stay plain text and exit 0", async () => { + const v = await runCli(["--version"]); + assert.equal(v.code, 0); + assert.match(v.stdout.trim(), /^\d+\.\d+\.\d+/); + const h = await runCli(["--help"]); + assert.equal(h.code, 0); + assert.match(h.stdout, /USAGE:/); + const sub = await runCli(["balance", "--help"]); + assert.equal(sub.code, 0); + assert.match(sub.stdout, /--save-json/); +}); + +test("T-005 legacy: unknown option exits 2 with a parseable payload and no API request", async () => { + const r = await runCli(["balance", "--bogus"]); + assert.equal(r.code, 2, r.stderr); + const payload = parseSingleJson(r.stdout) as Record; + assert.equal(payload["name"], "UsageError"); + assert.match(String(payload["message"]), /unknown option/); +}); + +test("T-005 v1: unknown command, missing argument and bad --format are usage errors in the envelope", async () => { + const unknown = await runCli(["--output-schema", "v1", "no-such-command"]); + assert.equal(unknown.code, 2, unknown.stderr); + const env1 = parseSingleJson(unknown.stdout) as Record; + assert.deepEqual(Object.keys(env1), SIX); + assert.equal(env1["ok"], false); + assert.equal((env1["error"] as Record)["code"], "usage"); + + const missing = await runCli(["text-to-3d", "get", "--output-schema", "v1"]); + assert.equal(missing.code, 2, missing.stderr); + const env2 = parseSingleJson(missing.stdout) as Record; + assert.equal(env2["command"], "text-to-3d.get"); + assert.equal((env2["error"] as Record)["code"], "usage"); + + const badFormat = await runCli(["--output-schema", "v1", "--format", "yaml", "balance"]); + assert.equal(badFormat.code, 2, badFormat.stderr); + const env3 = parseSingleJson(badFormat.stdout) as Record; + assert.equal(env3["ok"], false); +}); + +test("T-002 v1 success: balance emits one six-key envelope; pretty and ndjson share semantics (T-003)", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/balance") return jsonReply(res, 200, { balance: 42, currency: "credits" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const json = await runCli(["balance", "--output-schema", "v1"], { env: api.env() }); + assert.equal(json.code, 0, json.stderr); + const env = parseSingleJson(json.stdout) as Record; + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env["schema_version"], "meshy.cli/v1"); + assert.equal(env["command"], "balance"); + assert.equal(env["ok"], true); + assert.deepEqual(env["result"], { balance: 42, saved_json: null }); + assert.equal(env["error"], null); + assert.deepEqual(env["warnings"], []); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + + const nd = await runCli(["--output-schema", "v1", "--format", "ndjson", "balance"], { env: api.env() }); + assert.equal(nd.code, 0); + assert.equal(nd.stdout.trim().split("\n").length, 1); + assert.deepEqual(JSON.parse(nd.stdout.trim())["result"], { balance: 42, saved_json: null }); + + const pretty = await runCli(["balance", "--format", "pretty", "--output-schema", "v1"], { env: api.env() }); + assert.equal(pretty.code, 0); + assert.match(pretty.stdout, /^schema_version: meshy\.cli\/v1$/m); + assert.match(pretty.stdout, /balance: 42/); + } finally { + await api.close(); + } +}); + +test("T-001 legacy: balance output shape is unchanged without --output-schema", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 7 })); + try { + const r = await runCli(["balance"], { env: api.env() }); + assert.equal(r.code, 0, r.stderr); + assert.equal(r.stdout, '{\n "balance": 7\n}\n'); + const file = join(tmpDir(), "out.json"); + const r2 = await runCli(["balance", "-o", file], { env: api.env() }); + assert.equal(r2.code, 0); + assert.equal(r2.stdout, ""); + assert.equal(readFileSync(file, "utf8"), '{\n "balance": 7\n}\n'); + } finally { + await api.close(); + } +}); + +test("T-004 global flags resolve identically at root, middle and end", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 1 })); + try { + const a = await runCli(["--output-schema", "v1", "--format", "ndjson", "balance"], { env: api.env() }); + const b = await runCli(["balance", "--output-schema", "v1", "--format", "ndjson"], { env: api.env() }); + const c = await runCli(["--format", "pretty", "balance", "--json", "--output-schema", "v1"], { env: api.env() }); + assert.equal(a.stdout, b.stdout); + // --json wins over --format pretty. + assert.equal((parseSingleJson(c.stdout) as Record)["ok"], true); + } finally { + await api.close(); + } +}); + +test("T-010 v1: --save-json stores the raw body, -o is refused, existing files are never overwritten", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 3, extra: { nested: true } })); + try { + const dir = tmpDir(); + const target = join(dir, "raw.json"); + const ok = await runCli(["balance", "--output-schema", "v1", "--save-json", target], { env: api.env(), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { balance: 3, extra: { nested: true } }); + const env = parseSingleJson(ok.stdout) as { result: { saved_json: { path: string; bytes: number } } }; + assert.equal(env.result.saved_json.path, realpathSync(target)); + + const again = await runCli(["balance", "--output-schema", "v1", "--save-json", target], { env: api.env(), cwd: dir }); + assert.equal(again.code, 11, again.stderr); + const err = parseSingleJson(again.stdout) as { error: { code: string } }; + assert.equal(err.error.code, "local_io"); + assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { balance: 3, extra: { nested: true } }, "original file untouched"); + + const withOutput = await runCli(["balance", "--output-schema", "v1", "-o", join(dir, "x.json")], { env: api.env(), cwd: dir }); + assert.equal(withOutput.code, 2); + assert.ok(!existsSync(join(dir, "x.json"))); + // -o was rejected before any request. + assert.equal(api.requests.length, 2); + } finally { + await api.close(); + } +}); + +test("T-011 v1: API failures map to codes/exits and never include the key", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 402, { message: "Insufficient credits" })); + try { + const r = await runCli(["balance", "--output-schema", "v1"], { env: api.env() }); + assert.equal(r.code, 9, r.stderr); + const env = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; http_status: number; recovery: unknown } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, "credit"); + assert.equal(env.error.http_status, 402); + assert.ok(!r.stdout.includes("msy_fixture_key_loopback_only")); + assert.ok(!r.stderr.includes("msy_fixture_key_loopback_only")); + } finally { + await api.close(); + } +}); + +test("--env-file is refused with an explanation (Node.js intercepts it)", async () => { + const dir = tmpDir(); + const file = join(dir, "k.env"); + writeFileSync(file, "MESHY_API_KEY=msy_x\n"); + const r = await runCli(["balance", "--env-file", file, "--output-schema", "v1"], { cwd: dir }); + assert.equal(r.code, 2, r.stderr); + const env = parseSingleJson(r.stdout) as { error: { message: string } }; + assert.match(env.error.message, /--api-key-file/); +}); + +test("T-100 credential priority: flag > env > api-key-file > profile; a broken key file never falls through", async () => { + const seen: string[] = []; + const api = await startMockApi((req, res) => { + seen.push(String(req.headers["authorization"])); + jsonReply(res, 200, { balance: 1 }); + }); + try { + const dir = tmpDir(); + const envFile = join(dir, "keys.env"); + writeFileSync(envFile, "MESHY_API_KEY=msy_from_file\n"); + + // env-file wins when neither flag nor env var is set. + const noEnv = await runCli(["balance", "--api-key-file", envFile], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(noEnv.code, 0, noEnv.stderr); + assert.equal(seen.at(-1), "Bearer msy_from_file"); + + // env var beats env-file. + const withEnv = await runCli(["balance", "--api-key-file", envFile], { env: api.env(), cwd: dir }); + assert.equal(withEnv.code, 0, withEnv.stderr); + assert.equal(seen.at(-1), "Bearer msy_fixture_key_loopback_only"); + + // flag beats both. + const withFlag = await runCli(["balance", "--api-key-file", envFile, "--api-key", "msy_flag"], { env: api.env(), cwd: dir }); + assert.equal(withFlag.code, 0, withFlag.stderr); + assert.equal(seen.at(-1), "Bearer msy_flag"); + + // A malformed explicit file is an error even though the env var could have been used. + writeFileSync(envFile, "MESHY_API_KEY=$(curl evil)\n"); + const broken = await runCli(["balance", "--api-key-file", envFile, "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(broken.code, 3, broken.stderr); + const before = seen.length; + // A missing file is a usage error and makes no request. + const missing = await runCli(["balance", "--api-key-file", join(dir, "absent.env"), "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(missing.code, 2, missing.stderr); + assert.equal(seen.length, before); + // A file without the key does not fall back to the stored profile. + writeFileSync(envFile, "OTHER=1\n"); + const keyless = await runCli(["balance", "--api-key-file", envFile, "--output-schema", "v1"], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(keyless.code, 3, keyless.stderr); + assert.equal(seen.length, before); + } finally { + await api.close(); + } +}); + +test("T-102 local commands run without a credential, without network and without an update child", async () => { + // A loopback "npm registry": the detached refresh child would GET it. The + // isolated config dir has no update cache, so any API command that is + // allowed to check for updates spawns the child (positive control) while a + // local command must not. + let registryHits = 0; + const registry = await startMockApi((_req, res) => { + registryHits += 1; + jsonReply(res, 200, { version: "0.0.1" }); + }); + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 1 })); + try { + const env = api.env({ + MESHY_CLI_NO_UPDATE_NOTIFIER: undefined, + MESHY_CLI_UPDATE_REGISTRY_URL: `${registry.url}/meshy-cli/latest`, + MESHY_API_KEY: undefined, + }); + const r = await runCli(["resources"], { env }); + assert.equal(r.code, 0, r.stderr); + const list = parseSingleJson(r.stdout) as unknown[]; + assert.ok(Array.isArray(list) && list.length > 0); + await new Promise((resolve) => setTimeout(resolve, 1500)); + assert.equal(registryHits, 0, "a local command must not spawn the update child"); + assert.ok(!existsSync(join(String(env["MESHY_CONFIG_DIR"]), "update-state.json"))); + + // --no-update-check also silences an API command. + const quiet = await runCli(["balance", "--no-update-check"], { env: { ...env, MESHY_API_KEY: "msy_x" } }); + assert.equal(quiet.code, 0, quiet.stderr); + await new Promise((resolve) => setTimeout(resolve, 1500)); + assert.equal(registryHits, 0, "--no-update-check must not spawn the update child"); + + // Positive control: the same API command without the opt-out does check. + const loud = await runCli(["balance"], { env: { ...env, MESHY_API_KEY: "msy_x" } }); + assert.equal(loud.code, 0, loud.stderr); + const deadline = Date.now() + 8000; + while (registryHits === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(registryHits >= 1, "an API command with a stale cache must refresh it"); + } finally { + await registry.close(); + await api.close(); + } +}); + +test("T-012 consecutive runs never reuse a previous key or URL (no runtime cache across processes)", async () => { + const seenA: string[] = []; + const seenB: string[] = []; + const a = await startMockApi((req, res) => { seenA.push(String(req.headers["authorization"])); jsonReply(res, 200, { balance: 1 }); }); + const b = await startMockApi((req, res) => { seenB.push(String(req.headers["authorization"])); jsonReply(res, 200, { balance: 2 }); }); + try { + const ra = await runCli(["balance"], { env: a.env({ MESHY_API_KEY: "msy_a" }) }); + const rb = await runCli(["balance"], { env: b.env({ MESHY_API_KEY: "msy_b" }) }); + assert.equal(ra.code, 0); + assert.equal(rb.code, 0); + assert.deepEqual(seenA, ["Bearer msy_a"]); + assert.deepEqual(seenB, ["Bearer msy_b"]); + } finally { + await a.close(); + await b.close(); + } +}); diff --git a/tests/client.test.ts b/tests/client.test.ts index 5f3d667..f06d936 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -40,6 +40,8 @@ function buildConfig(): MeshyConfig { apiKey: "msy_test_key", baseUrlV1: "https://api.example.com/v1", baseUrlV2: "https://api.example.com/v2", + baseUrlCreativeLab: "https://api.example.com/openapi/creative-lab", + publicWebBase: "https://api.example.com/web/public", connectTimeoutMs: 1000, readTimeoutMs: 5000, pollIntervalMs: 10, diff --git a/tests/codex-review-round1.test.ts b/tests/codex-review-round1.test.ts new file mode 100644 index 0000000..15ec49c --- /dev/null +++ b/tests/codex-review-round1.test.ts @@ -0,0 +1,768 @@ +/** + * Codex review round 1 (reviews/cli-s1-6273d9a, F01–F10) turned into positive + * regression tests. Every scenario mirrors the reviewer's independent probe + * (R01–R12): real subprocesses, a loopback API/asset host that records every + * request, synthetic credentials, isolated temp directories. Where the probe + * demonstrated a defect, the test now asserts the required behaviour. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import sharp from "sharp"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const KEY_A = "msy_review_fixture_account_a"; +const KEY_B = "msy_review_fixture_account_b"; +const CREATE = ["text-to-3d", "create", "--mode", "preview", "--prompt", "review fixture", "--async", "--output-schema", "v1"]; + +function taskBody(fields: Record = {}): Record { + return { id: "review-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function laterReply(res: Parameters[0], ms: number, status: number, body: unknown): void { + setTimeout(() => { + try { + jsonReply(res, status, body); + } catch { + /* the client is gone — that is the point of the test */ + } + }, ms); +} + +type Env = Record; + +function withoutKey(env: Env, extra: Env = {}): Env { + const next: Env = { ...env, ...extra }; + delete next["MESHY_API_KEY"]; + return next; +} + +// --------------------------------------------------------------------------- +// F05 / R01 — credential identity +// --------------------------------------------------------------------------- + +test("R01/F05 a different API key under the same --operation-id conflicts before any request; the same key replays", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "account-a-task" }) : jsonReply(res, 404, {}))); + try { + const env = api.env({ MESHY_API_KEY: KEY_A }); + const args = [...CREATE, "--operation-id", "same-credential-op"]; + const first = await runCli(args, { env }); + assert.equal(first.code, 0, first.stderr); + const other = await runCli(args, { env: { ...env, MESHY_API_KEY: KEY_B } }); + assert.equal(other.code, 2, other.stderr); + const out = parseSingleJson(other.stdout) as { ok: boolean; error: { code: string; message: string }; result: { conflict: string[]; submission: { task_id: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "operation_conflict"); + assert.deepEqual(out.result.conflict, ["credential"]); + assert.equal(out.result.submission.task_id, "account-a-task", "the record is reported, never re-used for the other account"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "account B sent nothing"); + const again = await runCli(args, { env }); + assert.equal(again.code, 0, again.stderr); + assert.equal((parseSingleJson(again.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +test("R01/F05 OAuth: a rotated token is the same account (replay); a different user under the same profile name conflicts", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "oauth-task" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + const credFile = join(dir, "credentials.json"); + const profile = (accessToken: string, userId: string) => + JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "oauth", access_token: accessToken, refresh_token: "r", expires_at: Date.now() + 3_600_000, user_id: userId, created_at: 1 } } }); + writeFileSync(credFile, profile("tok-1", "user-a")); + const env = withoutKey(api.env(), { MESHY_CREDENTIALS_PATH: credFile }); + const args = [...CREATE, "--operation-id", "oauth-op"]; + const first = await runCli(args, { env, cwd: dir }); + assert.equal(first.code, 0, first.stderr); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer tok-1"); + writeFileSync(credFile, profile("tok-2", "user-a")); + const rotated = await runCli(args, { env, cwd: dir }); + assert.equal(rotated.code, 0, rotated.stderr); + assert.equal((parseSingleJson(rotated.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + writeFileSync(credFile, profile("tok-3", "user-b")); + const otherUser = await runCli(args, { env, cwd: dir }); + assert.equal(otherUser.code, 2, otherUser.stderr); + assert.equal((parseSingleJson(otherUser.stdout) as { error: { code: string } }).error.code, "operation_conflict"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F06 / R02 — media content in the fingerprint +// --------------------------------------------------------------------------- + +test("R02/F06 two equal-length images differ: the second conflicts; the same image (even re-wrapped) replays; no base64 in the journal", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "red-image-task" }) : jsonReply(res, 404, {}))); + try { + const red = await sharp({ create: { width: 1, height: 1, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const green = await sharp({ create: { width: 1, height: 1, channels: 3, background: "#00ff00" } }).png().toBuffer(); + assert.ok(!red.equals(green)); + assert.equal(red.toString("base64").length, green.toString("base64").length, "the probe's premise: equal encoded length"); + const env = api.env({ MESHY_API_KEY: KEY_A }); + const args = (b64: string) => ["image-to-3d", "create", "--image-url", `data:image/png;base64,${b64}`, "--operation-id", "same-media-op", "--async", "--output-schema", "v1"]; + const first = await runCli(args(red.toString("base64")), { env }); + assert.equal(first.code, 0, first.stderr); + const changed = await runCli(args(green.toString("base64")), { env }); + assert.equal(changed.code, 2, changed.stderr); + const out = parseSingleJson(changed.stdout) as { error: { code: string }; result: { conflict: string[] } }; + assert.equal(out.error.code, "operation_conflict"); + assert.deepEqual(out.result.conflict, ["payload"]); + const wrapped = red.toString("base64").replace(/(.{40})/g, "$1\n"); + const same = await runCli(args(wrapped), { env }); + assert.equal(same.code, 0, same.stderr); + assert.equal((parseSingleJson(same.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "exactly one billable request across three invocations"); + const ops = join(String(env["MESHY_CONFIG_DIR"]), "operations"); + for (const f of readdirSync(ops).filter((n) => n.endsWith(".json"))) { + const text = readFileSync(join(ops, f), "utf8"); + assert.ok(!text.includes(red.toString("base64").slice(0, 16)), "journal holds no image bytes"); + assert.ok(!text.includes(KEY_A), "journal holds no key"); + } + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F07 / R03 — the wait deadline binds in-flight requests +// --------------------------------------------------------------------------- + +test("R03/F07 wait: a GET that answers after the deadline is a timeout (exit 8) with the task id, not a late success", async () => { + const api = await startMockApi((_req, res) => laterReply(res, 400, 200, taskBody())); + try { + const started = Date.now(); + const r = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05", "--output-schema", "v1"], { env: api.env() }); + const elapsed = Date.now() - started; + assert.equal(r.code, 8, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; recovery: { command: string } }; result: { task: unknown; task_id: string; wait: { timed_out: boolean; polls: number }; next: { wait: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "timed_out"); + assert.equal(out.result.task, null, "no status was received in time, none is invented"); + assert.equal(out.result.task_id, "review-task"); + assert.equal(out.result.wait.timed_out, true); + assert.equal(out.result.wait.polls, 0); + assert.match(out.error.recovery.command, /wait review-task/); + assert.ok(elapsed < 3000, `the command did not wait for the late body (${elapsed} ms)`); + // Legacy schema: same decision, legacy shape. + const legacy = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05"], { env: api.env() }); + assert.equal(legacy.code, 8, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as Record; + assert.equal(lp["id"], "review-task"); + assert.equal(lp["timed_out"], true); + } finally { + await api.close(); + } +}); + +test("R03/F07 wait: slow body, expiry during sleep and --timeout 0 behave as specified; no GET is started after the deadline", async () => { + let mode: "slow-body" | "instant" | "slow-200" = "slow-body"; + const stamps: number[] = []; + let origin = Date.now(); + const api = await startMockApi((_req, res) => { + stamps.push(Date.now() - origin); + if (mode === "slow-body") { + res.writeHead(200, { "content-type": "application/json" }); + setTimeout(() => { + try { + res.end(JSON.stringify(taskBody())); + } catch { + /* client gone */ + } + }, 400); + return; + } + if (mode === "slow-200") return laterReply(res, 200, 200, taskBody({ status: "IN_PROGRESS", progress: 10 })); + return jsonReply(res, 200, taskBody({ status: "IN_PROGRESS", progress: 10 })); + }); + try { + const slowBody = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05", "--output-schema", "v1"], { env: api.env() }); + assert.equal(slowBody.code, 8, `${slowBody.stderr}\n${slowBody.stdout}`); + assert.equal((parseSingleJson(slowBody.stdout) as { error: { code: string } }).error.code, "timed_out"); + + // Expiry while sleeping: budget 300 ms, interval 250 ms → a GET at 0 and at + // ~250 ms, then the sleep is cut to the ~50 ms left. The loop may wake a + // fraction before the deadline and issue one more deadline-bound GET (D-044: + // an early wake may poll again, never after the deadline), so the count is + // 1–3 and a third GET can only sit at the deadline itself — never inside the + // interval — and every GET the server saw started within the budget. + mode = "instant"; + stamps.length = 0; + origin = Date.now(); + const expiry = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.3", "--output-schema", "v1"], { env: api.env({ MESHY_POLL_INTERVAL_MS: "250" }) }); + assert.equal(expiry.code, 8, expiry.stderr); + const eo = parseSingleJson(expiry.stdout) as { result: { task: { status: string }; wait: { polls: number } } }; + assert.equal(eo.result.task.status, "IN_PROGRESS", "the last status seen is reported"); + assert.ok(eo.result.wait.polls >= 1 && eo.result.wait.polls <= 3, `polls=${eo.result.wait.polls}`); + const firstGet = stamps[0]!; + const offsets = stamps.map((t) => t - firstGet); + assert.ok(offsets.length >= eo.result.wait.polls && offsets.length - eo.result.wait.polls <= 1, `every counted poll is a GET the server saw (at most one deadline-bound GET was cut off): polls=${eo.result.wait.polls} gets=${JSON.stringify(offsets)}`); + assert.ok(offsets.every((t) => t <= 300 + 60), `every GET started within the budget: ${JSON.stringify(offsets)}`); + if (offsets.length >= 2) assert.ok(offsets[1]! >= 250 - 20, `the second GET waited the full interval: ${JSON.stringify(offsets)}`); + if (offsets.length === 3) assert.ok(offsets[2]! >= 300 - 20, `a third GET can only be the deadline wake-up: ${JSON.stringify(offsets)}`); + + // --timeout 0: exactly one query bounded by the read timeout, not by a zero budget. + mode = "slow-200"; + stamps.length = 0; + const single = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0", "--output-schema", "v1"], { env: api.env() }); + assert.equal(single.code, 8, single.stderr); + const so = parseSingleJson(single.stdout) as { result: { task: { status: string }; wait: { polls: number } } }; + assert.equal(so.result.task.status, "IN_PROGRESS", "the single query completed although it took 200 ms"); + assert.equal(so.result.wait.polls, 1); + assert.equal(stamps.length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F08 / R04 — Creative Lab options compose across --data, --options and typed flags +// --------------------------------------------------------------------------- + +test("R04/F08 --data.options, --options and typed flags merge field by field (typed wins, false survives)", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "lamp-build-task" }) : jsonReply(res, 404, {}))); + try { + const r = await runCli( + [ + "creative-lab", "lamp", "build", "create", "--input-task-id", "parent-prototype", + "--data", '{"options":{"diameter_mm":180,"rotate_x_deg":90,"include_result_json":false},"output":{"format":"stl"}}', + "--options", '{"thickness_mm":1.5,"include_result_json":false}', + "--include-result-json", "--model-format", "zip", "--async", + ], + { env: api.env() }, + ); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { + input_task_id: "parent-prototype", + options: { diameter_mm: 180, rotate_x_deg: 90, include_result_json: true, thickness_mm: 1.5 }, + output: { format: "zip" }, + }); + // The reviewer's exact probe: --data options + --options. + const probe = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "parent-prototype", "--data", '{"options":{"diameter_mm":180,"rotate_x_deg":90}}', "--options", '{"thickness_mm":1.5}', "--async"], { env: api.env() }); + assert.equal(probe.code, 0, probe.stderr); + assert.deepEqual((api.requests.at(-1)!.json as { options: unknown }).options, { diameter_mm: 180, rotate_x_deg: 90, thickness_mm: 1.5 }); + // Keychain: explicit false from --data survives an --options layer. + const kc = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "p", "--data", '{"options":{"has_closed_back":false}}', "--options", '{"badge_shape":"star"}', "--async"], { env: api.env() }); + assert.equal(kc.code, 0, kc.stderr); + assert.deepEqual((api.requests.at(-1)!.json as { options: unknown }).options, { has_closed_back: false, badge_shape: "star" }); + // Validation still sees the merged object: an out-of-range value from --data is refused before the POST. + const before = api.requests.length; + const bad = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "p", "--data", '{"options":{"diameter_mm":10}}', "--options", '{"thickness_mm":1.5}', "--async"], { env: api.env() }); + assert.equal(bad.code, 2, bad.stderr); + assert.equal(api.requests.length, before); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F01 / R05, R06 — the accepted task id survives every later failure +// --------------------------------------------------------------------------- + +test("R05/F01 --save-json: an existing target is refused before the POST; a failure after the POST still reports the accepted id", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "accepted-before-save-error" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + writeFileSync(join(dir, "occupied.json"), "existing-user-data"); + const preflight = await runCli([...CREATE, "--save-json", "occupied.json"], { env: api.env(), cwd: dir }); + assert.equal(preflight.code, 11, preflight.stderr); + const pf = parseSingleJson(preflight.stdout) as { error: { code: string; message: string } }; + assert.equal(pf.error.code, "local_io"); + assert.match(pf.error.message, /nothing was submitted/); + assert.equal(api.requests.length, 0, "a detectable conflict costs zero requests"); + assert.equal(readFileSync(join(dir, "occupied.json"), "utf8"), "existing-user-data"); + + // A save that can only fail after the POST (the parent "directory" is a file). + writeFileSync(join(dir, "blocked"), "i am a file"); + const late = await runCli([...CREATE, "--save-json", join("blocked", "task.json")], { env: api.env(), cwd: dir }); + assert.equal(late.code, 11, `${late.stderr}\n${late.stdout}`); + const out = parseSingleJson(late.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string; submission: { state: string; task_id: string; operation_id: string }; next: { get: string; wait: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.task_id, "accepted-before-save-error"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "accepted-before-save-error"); + assert.match(out.result.next.get, /get accepted-before-save-error/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +test("R06/F01 sync create: a 503 while polling keeps the created id, submission and resume commands; exactly one POST", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "accepted-before-get-error" }) : jsonReply(res, 503, { message: "synthetic temporary outage" }))); + try { + const r = await runCli(CREATE.filter((a) => a !== "--async"), { env: api.env() }); + assert.equal(r.code, 1, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; http_status: number }; result: { task: unknown; task_id: string; submission: { state: string; task_id: string }; next: { wait: string }; wait: { polls: number } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "server"); + assert.equal(out.error.http_status, 503); + assert.equal(out.result.task_id, "accepted-before-get-error"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "accepted-before-get-error"); + assert.match(out.result.next.wait, /wait accepted-before-get-error/); + assert.equal(out.result.wait.polls, 0); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "GET"]); + // The same for a bare `wait`, and for the legacy schema. + const w = await runCli(["text-to-3d", "wait", "some-task", "--output-schema", "v1"], { env: api.env() }); + assert.equal(w.code, 1, w.stderr); + assert.equal((parseSingleJson(w.stdout) as { result: { task_id: string } }).result.task_id, "some-task"); + const legacy = await runCli(CREATE.filter((a) => a !== "--async" && a !== "--output-schema" && a !== "v1"), { env: api.env() }); + assert.equal(legacy.code, 1, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as { code: string; result: { task_id: string } }; + assert.equal(lp.code, "server"); + assert.equal(lp.result.task_id, "accepted-before-get-error"); + } finally { + await api.close(); + } +}); + +test("F01 make: a polling failure after step 1 was accepted keeps executed steps, the task id and the resume command", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "prev-accepted" }) : jsonReply(res, 503, { message: "outage" }))); + try { + const r = await runCli(["make", "a fixture cactus", "--output-schema", "v1"], { env: api.env() }); + assert.equal(r.code, 1, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; executed: Array<{ task_id: string }>; submission: { state: string }; next: { wait: string } } }; + assert.equal(out.error.code, "server"); + assert.equal(out.error.http_status, 503); + assert.equal(out.result.task_id, "prev-accepted"); + assert.equal(out.result.executed[0]!.task_id, "prev-accepted"); + assert.equal(out.result.submission.state, "accepted"); + assert.match(out.result.next.wait, /wait prev-accepted/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F09 / R07 — downloaded OBJ/MTL reference the files actually saved +// --------------------------------------------------------------------------- + +test("R07/F09 download: mtllib and map_* references are rewritten to the saved names; manifest digests describe the rewritten files", async () => { + const png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#336699" } }).png().toBuffer(); + const obj = "# meshy\nmtllib box.mtl\nv 0 0 0\nv 1 1 0\nv 0 1 1\nusemtl sample\nf 1 2 3\n"; + const mtl = "newmtl sample\nKd 0.8 0.8 0.8\nmap_Kd texture.png\nmap_Bump -bm 0.5 normal_map.png\n"; + const host = await startMockApi((req, res) => { + const bodies: Record = { + "/box.obj": { body: obj, type: "model/obj" }, + "/box.mtl": { body: mtl, type: "text/plain" }, + "/bc.png": { body: png, type: "image/png" }, + "/n.png": { body: png, type: "image/png" }, + }; + const entry = bodies[req.path]; + if (!entry) return jsonReply(res, 404, {}); + res.writeHead(200, { "content-type": entry.type }); + res.end(entry.body); + }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "obj-task.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/box.obj`, mtl: `${host.url}/box.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png`, normal: `${host.url}/n.png` }] }))); + const out = join(dir, "obj-download"); + const r = await runCli(["download", "--task-json", taskJson, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + assert.deepEqual(readdirSync(out).sort(), ["model.mtl", "model.obj", "texture_0_base_color.png", "texture_0_normal.png"]); + const savedObj = readFileSync(join(out, "model.obj"), "utf8"); + assert.match(savedObj, /^mtllib model\.mtl$/m); + assert.ok(!savedObj.includes("box.mtl")); + assert.ok(savedObj.includes("usemtl sample") && savedObj.includes("f 1 2 3"), "everything else is verbatim"); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd texture_0_base_color\.png$/m); + assert.match(savedMtl, /^map_Bump -bm 0\.5 texture_0_normal\.png$/m); + for (const ref of savedMtl.match(/^map_\w+ (?:-\S+ \S+ )?(\S+)$/gm)!.map((l) => l.split(" ").at(-1)!)) { + assert.ok(existsSync(join(out, ref)), `${ref} exists next to the MTL`); + } + const env = parseSingleJson(r.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; sha256: string; relinked: boolean }>; material_links: { rewritten: string[]; mtllib: Array<{ resolved_to: string }>; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string }> } } }; warnings: Array<{ code: string }> }; + const files = Object.fromEntries(env.result.downloads.files.map((f) => [f.key, f])); + assert.equal(files["model.obj"]!.relinked, true); + assert.equal(files["model.mtl"]!.relinked, true); + assert.equal(files["texture.0.base_color"]!.relinked, false); + for (const f of env.result.downloads.files) assert.equal(f.sha256, sha(f.path), `${f.key}: manifest digest matches the file on disk`); + assert.equal(env.result.downloads.material_links.rewritten.length, 2); + assert.deepEqual(env.result.downloads.material_links.mtllib.map((l) => l.resolved_to), ["model.mtl"]); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["texture.png", "texture_0_base_color.png", "channel_of_key"], + ["normal_map.png", "texture_0_normal.png", "channel_in_name"], + ]); + assert.ok(!env.warnings.some((w) => w.code === "material_reference_unresolved")); + + // Legacy `-o` (all artifacts) relinks too. + const api = await startMockApi((req, res) => { + if (req.path.startsWith("/openapi/")) return jsonReply(res, 200, taskBody({ model_urls: { obj: `${host.url}/box.obj`, mtl: `${host.url}/box.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png`, normal: `${host.url}/n.png` }] })); + return jsonReply(res, 404, {}); + }); + try { + const legacyOut = join(dir, "legacy"); + const legacy = await runCli(["text-to-3d", "get", "review-task", "-o", legacyOut], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 0, legacy.stderr); + assert.match(readFileSync(join(legacyOut, "model.obj"), "utf8"), /^mtllib model\.mtl$/m); + assert.match(readFileSync(join(legacyOut, "model.mtl"), "utf8"), /^map_Kd texture_0_base_color\.png$/m); + } finally { + await api.close(); + } + + // Geometry only: the OBJ keeps its reference and the result says so. + const geo = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--geometry-only", "--output-dir", join(dir, "geo")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(geo.code, 0, geo.stderr); + assert.match(readFileSync(join(dir, "geo", "model.obj"), "utf8"), /^mtllib box\.mtl$/m); + const gw = parseSingleJson(geo.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(gw.warnings.some((w) => w.code === "geometry_only")); + assert.ok(gw.warnings.some((w) => w.code === "material_reference_unresolved")); + } finally { + await host.close(); + } +}); + +test("F09 an MTL map that matches no downloaded texture stays as written and is reported, never silently dropped", async () => { + const png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#336699" } }).png().toBuffer(); + const host = await startMockApi((req, res) => { + if (req.path === "/m.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + { + res.end("mtllib m.mtl\nv 0 0 0\n"); + return; + } + } + if (req.path === "/m.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + { + res.end("newmtl a\nmap_Kd albedo.png\nmap_Ks specular_only.png\n"); + return; + } + } + if (req.path === "/bc.png") { + res.writeHead(200, { "content-type": "image/png" }); + { + res.end(png); + return; + } + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/m.obj`, mtl: `${host.url}/m.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png` }] }))); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--output-dir", join(dir, "out")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const savedMtl = readFileSync(join(dir, "out", "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd texture_0_base_color\.png$/m, "albedo → base color by channel word"); + assert.match(savedMtl, /^map_Ks specular_only\.png$/m, "unresolved reference untouched"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { material_links: { texture_maps: Array<{ reference: string; resolved_to: string | null }> } } } }; + assert.ok(env.warnings.some((w) => w.code === "material_reference_unresolved" && w.message.includes("specular_only.png"))); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to]), [["albedo.png", "texture_0_base_color.png"], ["specular_only.png", null]]); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// F03 / R08, R09 — --workspace confines every write path +// --------------------------------------------------------------------------- + +test("R08/F03 project init/record/rebuild-index refuse roots and projects outside --workspace before writing", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + const init = await runCli(["project", "init", "--workspace", workspace, "--root", join(outside, "projects"), "--name", "review"], { cwd: dir, env }); + assert.equal(init.code, 11, `${init.stderr}\n${init.stdout}`); + assert.equal((parseSingleJson(init.stdout) as { error: { code: string } }).error.code, "local_io"); + assert.deepEqual(readdirSync(outside), [], "nothing was created outside the workspace"); + + const inside = await runCli(["project", "init", "--workspace", workspace, "--root", join(workspace, "projects"), "--name", "review"], { cwd: dir, env }); + assert.equal(inside.code, 0, inside.stderr); + const projectDir = (parseSingleJson(inside.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(existsSync(join(projectDir, "metadata.json"))); + + // A project that lives outside the workspace cannot be written to. + const foreign = await runCli(["project", "init", "--root", join(outside, "projects"), "--name", "foreign"], { cwd: dir, env }); + assert.equal(foreign.code, 0, foreign.stderr); + const foreignDir = (parseSingleJson(foreign.stdout) as { result: { project_dir: string } }).result.project_dir; + const rec = await runCli(["project", "record", "--workspace", workspace, "--project", foreignDir, "--task-id", "t", "--stage", "preview"], { cwd: dir, env }); + assert.equal(rec.code, 11, rec.stderr); + assert.equal((JSON.parse(readFileSync(join(foreignDir, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0); + const rebuild = await runCli(["project", "rebuild-index", "--workspace", workspace, "--root", join(outside, "projects")], { cwd: dir, env }); + assert.equal(rebuild.code, 11, rebuild.stderr); + // Task verbs: --project outside the workspace is refused before the POST. + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "never" }) : jsonReply(res, 404, {}))); + try { + const create = await runCli([...CREATE, "--workspace", workspace, "--project", foreignDir], { cwd: dir, env: api.env() }); + assert.equal(create.code, 11, create.stderr); + assert.equal(api.requests.length, 0); + } finally { + await api.close(); + } +}); + +test("R09/F03 task -o (get/wait/create/make) honours --workspace like standalone download; nothing outside is written or fetched", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + { + res.end(glb()); + return; + } + } + if (req.method === "POST") return jsonReply(res, 200, { result: "review-task" }); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const env = api.env(); + const outsideModel = join(outside, "model.glb"); + const get = await runCli(["text-to-3d", "get", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", outsideModel], { env, cwd: dir }); + assert.equal(get.code, 11, `${get.stderr}\n${get.stdout}`); + const go = parseSingleJson(get.stdout) as { error: { code: string }; result: { task_id: string; downloads: { state: string } } }; + assert.equal(go.error.code, "local_io"); + assert.equal(go.result.task_id, "review-task", "the task is still reported"); + assert.equal(go.result.downloads.state, "failed"); + assert.ok(!existsSync(outsideModel)); + assert.equal(api.requests.filter((q) => q.path === "/asset.glb").length, 0, "nothing was fetched for a refused target"); + + // Symlinked directory inside the workspace pointing outside. + symlinkSync(outside, join(workspace, "link")); + const viaLink = await runCli(["text-to-3d", "wait", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", join(workspace, "link", "m.glb")], { env, cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(readdirSync(outside), []); + + // create / make: refused before the POST. + const before = api.requests.length; + const create = await runCli([...CREATE, "--workspace", workspace, "-o", outsideModel], { env, cwd: dir }); + assert.equal(create.code, 11, create.stderr); + const make = await runCli(["make", "a fixture cactus", "--workspace", workspace, "-o", outsideModel, "--output-schema", "v1"], { env, cwd: dir }); + assert.equal(make.code, 11, make.stderr); + assert.equal(api.requests.length, before, "no request for a target outside the workspace"); + assert.deepEqual(readdirSync(outside), []); + + // Inside the workspace the same commands work. + const ok = await runCli(["text-to-3d", "get", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", join(workspace, "sub", "model.glb")], { env, cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + assert.ok(existsSync(join(workspace, "sub", "model.glb"))); + // Legacy schema, same boundary. + const legacy = await runCli(["text-to-3d", "get", "review-task", "--workspace", workspace, "-o", join(outside, "legacy.glb")], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + assert.ok(!existsSync(join(outside, "legacy.glb"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F10 / R10 — stream honours -o in every output format +// --------------------------------------------------------------------------- + +test("R10/F10 stream -o downloads the assets in ndjson, json and pretty; a download failure is one outcome with the task kept", async () => { + let assetOk = true; + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/asset.glb` } }))}\n\n`); + return; + } + if (req.path === "/asset.glb") { + if (!assetOk) return jsonReply(res, 404, { message: "gone" }); + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + return; + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + for (const format of ["ndjson", "json", "pretty"] as const) { + api.requests.length = 0; + const target = join(dir, `${format}.glb`); + const r = await runCli(["text-to-3d", "stream", "review-task", "--format", format, "--output-schema", "v1", "-o", target], { env: api.env(), cwd: dir }); + assert.equal(r.code, 0, `${format}: ${r.stderr}\n${r.stdout}`); + assert.ok(existsSync(target), `${format}: the asset landed`); + assert.deepEqual(api.requests.map((q) => q.path), ["/openapi/v2/text-to-3d/review-task/stream", "/asset.glb"], `${format}: one SSE GET, one asset GET`); + if (format === "ndjson") { + const lines = parseNdjson(r.stdout) as Array<{ event: string; ok: boolean; result: { downloads: { state: string; files: Array<{ path: string }> } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"]); + assert.equal(lines[1]!.ok, true); + assert.equal(lines[1]!.result.downloads.state, "completed"); + assert.equal(lines[1]!.result.downloads.files[0]!.path, target); + } else if (format === "json") { + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string } } }; + assert.equal(env.result.downloads.state, "completed"); + } else { + assert.match(r.stdout, /state: completed/); + } + } + // Download failure: exactly one outcome line, ok:false, task kept, exit code of the failure. + assetOk = false; + api.requests.length = 0; + const target = join(dir, "failing.glb"); + const bad = await runCli(["text-to-3d", "stream", "review-task", "--format", "ndjson", "--output-schema", "v1", "-o", target], { env: api.env(), cwd: dir }); + // The asset host's 404 keeps its own class (round 2, R2-F04): not_found / exit 5, never a bare local_io. + assert.equal(bad.code, 5, `${bad.stderr}\n${bad.stdout}`); + const lines = parseNdjson(bad.stdout) as Array<{ event: string; ok: boolean; error: { code: string; http_status: number | null } | null; result: { task_id: string; task: { status: string }; downloads: { state: string; files: Array<{ key: string; status: string }> } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"], "one outcome, no second envelope"); + assert.equal(lines[1]!.ok, false); + assert.equal(lines[1]!.error!.code, "not_found"); + assert.equal(lines[1]!.error!.http_status, 404); + assert.equal(lines[1]!.result.task_id, "review-task"); + assert.equal(lines[1]!.result.task.status, "SUCCEEDED"); + assert.equal(lines[1]!.result.downloads.state, "failed"); + assert.deepEqual(lines[1]!.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); + assert.ok(!existsSync(target)); + const badJson = await runCli(["text-to-3d", "stream", "review-task", "--output-schema", "v1", "-o", join(dir, "failing2.glb")], { env: api.env(), cwd: dir }); + assert.equal(badJson.code, 5, badJson.stderr); + assert.equal((parseSingleJson(badJson.stdout) as { result: { task_id: string } }).result.task_id, "review-task"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F02 / R11 — make: journal failure after acceptance is local_io with the id +// --------------------------------------------------------------------------- + +test("R11/F02 make: a journal write failure after the server accepted is local_io (11) with the known id, never submission_unknown", async () => { + let operations = ""; + const api = await startMockApi((_req, res) => { + // Deterministic local journal failure after the POST reached the server: + // the `started` record vanishes before the CLI can mark it accepted. + for (const name of readdirSync(operations).filter((n) => n.endsWith(".json"))) { + const full = join(operations, name); + if ((JSON.parse(readFileSync(full, "utf8")) as { state: string }).state === "started") unlinkSync(full); + } + jsonReply(res, 200, { result: "make-known-accepted-id" }); + }); + try { + const shared = api.env(); + operations = join(String(shared["MESHY_CONFIG_DIR"]), "operations"); + const text = await runCli(["make", "a fixture cactus", "--async", "--output-schema", "v1"], { env: shared }); + assert.equal(text.code, 11, `${text.stderr}\n${text.stdout}`); + const out = parseSingleJson(text.stdout) as { error: { code: string }; result: { submission: { state: string; task_id: string }; task_id: string; step: number; next: { wait: string } } }; + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "make-known-accepted-id"); + assert.equal(out.result.task_id, "make-known-accepted-id"); + assert.equal(out.result.step, 1); + assert.match(out.result.next.wait, /wait make-known-accepted-id/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + + // Image route: same contract. + api.requests.length = 0; + const dir = tmpDir(); + const png = join(dir, "cat.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + const img = await runCli(["make", png, "--async", "--output-schema", "v1"], { env: shared, cwd: dir }); + assert.equal(img.code, 11, `${img.stderr}\n${img.stdout}`); + const io = parseSingleJson(img.stdout) as { error: { code: string }; result: { submission: { state: string; task_id: string } } }; + assert.equal(io.error.code, "local_io"); + assert.equal(io.result.submission.state, "accepted"); + assert.equal(io.result.submission.task_id, "make-known-accepted-id"); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/v1/image-to-3d"]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F04 / R12 — OBJ dependency copies cannot escape through a symlinked parent +// --------------------------------------------------------------------------- + +test("R12/F04 prepare-print: a symlinked materials/ (or texture) directory under the target cannot receive a copy; real directories still work", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const escape = join(dir, "escaped-materials"); + mkdirSync(join(workspace, "source", "materials", "tex"), { recursive: true }); + mkdirSync(escape); + writeFileSync(join(workspace, "source", "mesh.obj"), "mtllib materials/a.mtl\nv 0 0 0\nv 1 1 0\nv 0 1 1\nf 1 2 3\n"); + writeFileSync(join(workspace, "source", "materials", "a.mtl"), "newmtl sample\nKd 0.8 0.8 0.8\nmap_Kd tex/t.png\n"); + writeFileSync(join(workspace, "source", "materials", "tex", "t.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + + // materials/ under the target is a symlink to a directory outside the workspace. + mkdirSync(join(workspace, "target")); + symlinkSync(escape, join(workspace, "target", "materials")); + const r = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target", "mesh.obj")], { cwd: dir, env }); + assert.equal(r.code, 11, `${r.stderr}\n${r.stdout}`); + assert.match((parseSingleJson(r.stdout) as { error: { message: string } }).error.message, /material dependency target/); + assert.deepEqual(readdirSync(escape), [], "nothing was written outside"); + assert.ok(!existsSync(join(workspace, "target", "mesh.obj")), "no output either: the run is all or nothing"); + + // Same escape one level down: materials/ is real, materials/tex is the symlink. + mkdirSync(join(workspace, "target2", "materials"), { recursive: true }); + symlinkSync(escape, join(workspace, "target2", "materials", "tex")); + const r2 = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target2", "mesh.obj")], { cwd: dir, env }); + assert.equal(r2.code, 11, r2.stderr); + assert.deepEqual(readdirSync(escape), []); + assert.ok(!existsSync(join(workspace, "target2", "mesh.obj"))); + + // Without --workspace the output directory is the root: still refused. + mkdirSync(join(dir, "target3")); + symlinkSync(escape, join(dir, "target3", "materials")); + const r3 = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "-o", join(dir, "target3", "mesh.obj")], { cwd: dir, env }); + assert.equal(r3.code, 11, r3.stderr); + assert.deepEqual(readdirSync(escape), []); + + // Real directories: dependencies are copied inside the target tree and reported. + const ok = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target4", "mesh.obj")], { cwd: dir, env }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + assert.ok(existsSync(join(workspace, "target4", "mesh.obj"))); + assert.ok(existsSync(join(workspace, "target4", "materials", "a.mtl"))); + assert.ok(existsSync(join(workspace, "target4", "materials", "tex", "t.png"))); + const rep = parseSingleJson(ok.stdout) as { result: { material: { copied: string[] } } }; + assert.equal(rep.result.material.copied.length, 2); +}); + +// --------------------------------------------------------------------------- +// Reviewer note — a real two-process race on one operation id at the CLI level +// --------------------------------------------------------------------------- + +test("two concurrent creates with the same --operation-id send exactly one POST", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? laterReply(res, 150, 200, { result: "raced-task" }) : jsonReply(res, 404, {}))); + try { + const env = api.env(); + const args = [...CREATE, "--operation-id", "race-op"]; + const [a, b] = await Promise.all([runCli(args, { env }), runCli(args, { env })]); + const codes = [a.code, b.code].sort(); + assert.ok(codes.every((c) => c === 0 || c === 10), `codes ${JSON.stringify(codes)}: ${a.stderr} ${b.stderr}`); + assert.ok(codes.includes(0), "one invocation owns the accepted submission"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "the journal serialises the two processes onto one request"); + for (const r of [a, b]) { + const out = parseSingleJson(r.stdout) as { ok: boolean; result: { submission: { state: string; operation_id: string } }; error: { code: string } | null }; + assert.equal(out.result.submission.operation_id, "race-op"); + if (!out.ok) assert.equal(out.error!.code, "submission_unknown", "the loser sees the in-flight record and never re-sends"); + } + } finally { + await api.close(); + } +}); + +// Keep the MockApi type in use for readers of this file. +export type { MockApi }; diff --git a/tests/codex-review-round2.test.ts b/tests/codex-review-round2.test.ts new file mode 100644 index 0000000..3470a8b --- /dev/null +++ b/tests/codex-review-round2.test.ts @@ -0,0 +1,615 @@ +/** + * Codex review round 2 (reviews/cli-s1-730132b, R2-F01–R2-F07) as positive + * regressions. Every scenario mirrors the reviewer's probe (N01–N08) with real + * subprocesses, a loopback API/asset host that records every request, synthetic + * credentials and isolated temp directories; each asserts the required + * behaviour: exit code, envelope shape, task_id/submission, request counts, + * bytes on disk, per-file manifests, one ndjson outcome with increasing sequence. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { credentialBinding } from "../src/internal/operation-store.js"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const CREATE = ["text-to-3d", "create", "--mode", "preview", "--prompt", "round two", "--async", ...V1]; + +function taskBody(fields: Record = {}): Record { + return { id: "round2-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Directory snapshot (names only) — used to prove nothing appeared outside a boundary. */ +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : [""]; +} + +function localEnv(): Record { + return { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; +} + +async function pngBytes(color: string): Promise { + return sharp({ create: { width: 2, height: 2, channels: 3, background: color } }).png().toBuffer(); +} + +// --------------------------------------------------------------------------- +// R2-F01 / N01 — report-only tasks honour --workspace like every other write +// --------------------------------------------------------------------------- + +test("N01/R2-F01 analyze-printability get/wait/stream -o: outside targets are refused before any directory exists; inside works", async () => { + const report = taskBody({ type: "print-analyze", printability: { status: "healthy", issue_count: 0 } }); + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(report)}\n\n`); + return; + } + return jsonReply(res, 200, report); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const env = api.env(); + const outsideBefore = listing(outside); + for (const verb of ["get", "wait", "stream"]) { + // File form. + const file = join(outside, `report-${verb}`, "report.json"); + const r = await runCli(["analyze-printability", verb, "round2-task", ...V1, "--workspace", workspace, "-o", file], { env, cwd: dir }); + assert.equal(r.code, 11, `${verb} file: ${r.stderr}\n${r.stdout}`); + const out = (verb === "stream" ? parseSingleJson(r.stdout) : parseSingleJson(r.stdout)) as { error: { code: string }; result: { task_id: string; downloads: { state: string } } }; + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.task_id, "round2-task"); + assert.equal(out.result.downloads.state, "failed"); + assert.ok(!existsSync(join(outside, `report-${verb}`)), `${verb}: the parent directory was not created`); + // Directory form. + const d = await runCli(["analyze-printability", verb, "round2-task", ...V1, "--workspace", workspace, "-o", join(outside, `dir-${verb}`)], { env, cwd: dir }); + assert.equal(d.code, 11, `${verb} dir: ${d.stderr}`); + assert.ok(!existsSync(join(outside, `dir-${verb}`))); + } + assert.deepEqual(listing(outside), outsideBefore, "nothing outside the workspace changed"); + // Symlinked parent inside the workspace pointing outside. + symlinkSync(outside, join(workspace, "link")); + const viaLink = await runCli(["analyze-printability", "get", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "link", "report.json")], { env, cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(listing(outside), outsideBefore); + // Legacy schema, same boundary. + const legacy = await runCli(["analyze-printability", "get", "round2-task", "--workspace", workspace, "-o", join(outside, "legacy.json")], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + assert.deepEqual(listing(outside), outsideBefore); + // Inside the workspace: file and directory forms both write the report. + const okFile = await runCli(["analyze-printability", "get", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "reports", "r.json")], { env, cwd: dir }); + assert.equal(okFile.code, 0, `${okFile.stderr}\n${okFile.stdout}`); + const saved = JSON.parse(readFileSync(join(workspace, "reports", "r.json"), "utf8")) as { task: { printability: { status: string } } }; + assert.equal(saved.task.printability.status, "healthy"); + assert.equal((parseSingleJson(okFile.stdout) as { result: { downloads: { state: string; metadata_path: string } } }).result.downloads.state, "completed"); + const okDir = await runCli(["analyze-printability", "wait", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "report-dir")], { env, cwd: dir }); + assert.equal(okDir.code, 0, okDir.stderr); + assert.ok(existsSync(join(workspace, "report-dir", "meta.json"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F01 / N02 — the implicit history root is confined too +// --------------------------------------------------------------------------- + +test("N02/R2-F01 --workspace equal to the project dir: metadata is recorded, the parent's index/lock/temp files are never touched (record, task --project, download --project)", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const root = join(dir, "project-root"); + const env = api.env(); + const init = await runCli(["project", "init", "--root", root, "--name", "review"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const projectDir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + unlinkSync(join(root, "history.json")); + const rootBefore = listing(root); + + const rec = await runCli(["project", "record", "--project", projectDir, "--workspace", projectDir, "--task-id", "round2-task", "--stage", "preview"], { env, cwd: dir }); + assert.equal(rec.code, 0, `${rec.stderr}\n${rec.stdout}`); + const ro = parseSingleJson(rec.stdout) as { result: { index: { updated: boolean; error: string }; task_count: number }; warnings: Array<{ code: string }> }; + assert.equal(ro.result.index.updated, false); + assert.match(ro.result.index.error, /outside --workspace/); + assert.equal(ro.result.task_count, 1, "metadata inside the workspace was recorded"); + assert.ok(ro.warnings.some((w) => w.code === "index_dirty")); + assert.deepEqual(listing(root), rootBefore, "no history.json, lock or temp file appeared in the parent"); + + const get = await runCli(["text-to-3d", "get", "round2-task", ...V1, "--project", projectDir, "--workspace", projectDir], { env, cwd: dir }); + assert.equal(get.code, 0, `${get.stderr}\n${get.stdout}`); + const go = parseSingleJson(get.stdout) as { result: { project: { index: { updated: boolean } } }; warnings: Array<{ code: string }> }; + assert.equal(go.result.project.index.updated, false); + assert.ok(go.warnings.some((w) => w.code === "index_dirty")); + assert.deepEqual(listing(root), rootBefore); + + const taskJson = join(projectDir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/asset.glb` } }))); + const dl = await runCli(["download", "--task-json", taskJson, "--asset", "model.glb", "--project", projectDir, "--workspace", projectDir], { env, cwd: dir }); + assert.equal(dl.code, 0, `${dl.stderr}\n${dl.stdout}`); + assert.ok((parseSingleJson(dl.stdout) as { warnings: Array<{ code: string }> }).warnings.some((w) => w.code === "index_dirty")); + assert.ok(existsSync(join(projectDir, "model.glb"))); + assert.deepEqual(listing(root), rootBefore); + + // An explicit --root outside the workspace stays a refusal before any write. + const outsideRoot = join(dir, "elsewhere"); + const explicit = await runCli(["project", "record", "--project", projectDir, "--workspace", projectDir, "--root", outsideRoot, "--task-id", "t2", "--stage", "preview"], { env, cwd: dir }); + assert.equal(explicit.code, 11, explicit.stderr); + assert.ok(!existsSync(outsideRoot)); + // Workspace containing both project and root: the index is written normally. + const normal = await runCli(["project", "record", "--project", projectDir, "--workspace", root, "--task-id", "t3", "--stage", "preview"], { env, cwd: dir }); + assert.equal(normal.code, 0, normal.stderr); + assert.equal((parseSingleJson(normal.stdout) as { result: { index: { updated: boolean } } }).result.index.updated, true); + assert.ok(existsSync(join(root, "history.json"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F01 / N03 — a refused download creates no directory +// --------------------------------------------------------------------------- + +test("N03/R2-F01 download: a target outside --workspace is refused before mkdir (directory and file forms), with zero requests", async () => { + const host = await startMockApi((_req, res) => { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const outside = join(dir, "standalone-outside"); + const env = host.env({ MESHY_API_KEY: undefined }); + const byDir = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output-dir", join(outside, "new-subdir")], { env, cwd: dir }); + assert.equal(byDir.code, 11, byDir.stderr); + assert.equal((parseSingleJson(byDir.stdout) as { error: { code: string } }).error.code, "local_io"); + assert.ok(!existsSync(outside), "neither the target nor its parent was created"); + const byFile = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output", join(outside, "sub", "model.glb")], { env, cwd: dir }); + assert.equal(byFile.code, 11, byFile.stderr); + assert.ok(!existsSync(outside)); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { glb: `${host.url}/model.glb` } }))); + const byTask = await runCli(["download", "--task-json", taskJson, "--all", "--workspace", workspace, "--output-dir", join(outside, "x")], { env, cwd: dir }); + assert.equal(byTask.code, 11, byTask.stderr); + assert.ok(!existsSync(outside)); + assert.equal(host.requests.length, 0, "nothing was fetched for a refused target"); + const inside = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output-dir", join(workspace, "new", "deep")], { env, cwd: dir }); + assert.equal(inside.code, 0, inside.stderr); + assert.ok(existsSync(join(workspace, "new", "deep"))); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F02 / N04 — multi-material texture mapping +// --------------------------------------------------------------------------- + +async function materialHost(files: Record): Promise { + return startMockApi((req, res) => { + const entry = files[req.path]; + if (!entry) return jsonReply(res, 404, {}); + res.writeHead(200, { "content-type": entry.type }); + res.end(entry.body); + }); +} + +test("N04/R2-F02 two material groups keep distinct textures: source names decide, never the first candidate; case/dir/extension variants resolve", async () => { + const red = await pngBytes("#ff0000"); + const green = await pngBytes("#00ff00"); + const obj = "mtllib character.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nusemtl body\nf 1 2 3\nusemtl eyes\nf 3 2 1\n"; + const mtl = "newmtl body\nmap_Kd body.png\nmap_Bump -bm 0.5 textures/Body_N.PNG\nnewmtl eyes\nmap_Kd eyes.jpeg\nmap_Bump eyes_n.png\n"; + const host = await materialHost({ + "/character.obj": { body: obj, type: "model/obj" }, + "/character.mtl": { body: mtl, type: "text/plain" }, + "/assets/body.png": { body: red, type: "image/png" }, + "/assets/body_n.png": { body: red, type: "image/png" }, + "/assets/eyes.jpg": { body: green, type: "image/png" }, + "/assets/eyes_n.png": { body: green, type: "image/png" }, + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "multi.json"); + writeFileSync( + fixture, + JSON.stringify( + taskBody({ + model_urls: { obj: `${host.url}/character.obj`, mtl: `${host.url}/character.mtl` }, + texture_urls: [ + { base_color: `${host.url}/assets/body.png?sig=1`, normal: `${host.url}/assets/body_n.png?sig=2` }, + { base_color: `${host.url}/assets/eyes.jpg?sig=3`, normal: `${host.url}/assets/eyes_n.png?sig=4` }, + ], + }), + ), + ); + const out = join(dir, "materials"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + const lines = savedMtl.split("\n"); + const body = lines.indexOf("newmtl body"); + const eyes = lines.indexOf("newmtl eyes"); + assert.equal(lines[body + 1], "map_Kd texture_0_base_color.png", "body base color → set 0 (source name body.png)"); + assert.equal(lines[body + 2], "map_Bump -bm 0.5 texture_0_normal.png", "body normal → set 0 (source name, directory and case ignored)"); + assert.equal(lines[eyes + 1], "map_Kd texture_1_base_color.png", "eyes base color → set 1 (source stem eyes, .jpeg vs .jpg)"); + assert.equal(lines[eyes + 2], "map_Bump texture_1_normal.png", "eyes normal → set 1"); + for (const ref of ["texture_0_base_color.png", "texture_0_normal.png", "texture_1_base_color.png", "texture_1_normal.png"]) assert.ok(existsSync(join(out, ref))); + assert.ok(readFileSync(join(out, "texture_1_base_color.png")).equals(green), "set 1 really is the second texture"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ material: string; reference: string; resolved_to: string; method: string }> } } } }; + assert.equal(env.result.downloads.material_links.status, "complete"); + assert.deepEqual( + env.result.downloads.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), + [ + ["body", "body.png", "texture_0_base_color.png", "source_name"], + ["body", "textures/Body_N.PNG", "texture_0_normal.png", "source_name"], + ["eyes", "eyes.jpeg", "texture_1_base_color.png", "source_stem"], + ["eyes", "eyes_n.png", "texture_1_normal.png", "source_name"], + ], + ); + assert.ok(!env.warnings.some((w) => w.code.startsWith("material_reference"))); + + // Legacy `-o` uses the same mapping. + const api = await startMockApi((req, res) => { + if (req.path.startsWith("/openapi/")) { + return jsonReply(res, 200, taskBody({ model_urls: { obj: `${host.url}/character.obj`, mtl: `${host.url}/character.mtl` }, texture_urls: [{ base_color: `${host.url}/assets/body.png` }, { base_color: `${host.url}/assets/eyes.jpg` }] })); + } + return jsonReply(res, 404, {}); + }); + try { + const legacyOut = join(dir, "legacy"); + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", legacyOut], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 0, legacy.stderr); + const lm = readFileSync(join(legacyOut, "model.mtl"), "utf8"); + assert.match(lm, /^map_Kd texture_0_base_color\.png$/m); + assert.match(lm, /^map_Kd texture_1_base_color\.png$/m); + } finally { + await api.close(); + } + } finally { + await host.close(); + } +}); + +test("N04/R2-F02 ambiguous references stay as written and are reported: two base-color candidates, no source match → no guess", async () => { + const red = await pngBytes("#ff0000"); + const green = await pngBytes("#00ff00"); + const mtl = "newmtl body\nmap_Kd skin.png\nnewmtl eyes\nmap_Kd face.png\n"; + const host = await materialHost({ + "/c.obj": { body: "mtllib c.mtl\nv 0 0 0\n", type: "model/obj" }, + "/c.mtl": { body: mtl, type: "text/plain" }, + "/a.png": { body: red, type: "image/png" }, + "/b.png": { body: green, type: "image/png" }, + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "ambiguous.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/c.obj`, mtl: `${host.url}/c.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }, { base_color: `${host.url}/b.png` }] }))); + const out = join(dir, "amb"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd skin\.png$/m, "kept as written"); + assert.match(savedMtl, /^map_Kd face\.png$/m, "kept as written"); + assert.ok(!savedMtl.includes("texture_0_base_color.png"), "the first candidate was not picked"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { state: string; material_links: { status: string; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string; candidates?: string[] }> } } } }; + assert.equal(env.result.downloads.state, "completed", "every file landed"); + assert.equal(env.result.downloads.material_links.status, "incomplete"); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to, l.method, l.candidates]), [ + ["skin.png", null, "ambiguous", ["texture_0_base_color.png", "texture_1_base_color.png"]], + ["face.png", null, "ambiguous", ["texture_0_base_color.png", "texture_1_base_color.png"]], + ]); + const warn = env.warnings.find((w) => w.code === "material_reference_ambiguous"); + assert.ok(warn, "ambiguity is warned"); + assert.match(warn!.message, /skin\.png.*texture_0_base_color\.png or texture_1_base_color\.png/); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F03 / N05 — every post-stream failure ends in one outcome event +// --------------------------------------------------------------------------- + +test("N05/R2-F03 stream: save-json conflict, project failure and download failure each end in exactly one outcome with the next sequence", async () => { + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/missing.glb` } }))}\n\n`); + return; + } + return jsonReply(res, 404, { message: "gone" }); + }); + try { + const dir = tmpDir(); + const occupied = join(dir, "occupied.json"); + writeFileSync(occupied, "do not overwrite"); + const cases: Array<[string, string[], number, string]> = [ + ["save-json conflict", ["--save-json", occupied], 11, "local_io"], + ["project not initialised", ["--project", join(dir, "nope")], 11, "local_io"], + ["asset 404", ["-o", join(dir, "asset.glb")], 5, "not_found"], + ]; + for (const [label, extra, exitCode, code] of cases) { + const nd = await runCli(["text-to-3d", "stream", "round2-task", ...V1, "--format", "ndjson", ...extra], { env: api.env(), cwd: dir }); + assert.equal(nd.code, exitCode, `${label}: ${nd.stderr}\n${nd.stdout}`); + const lines = parseNdjson(nd.stdout) as Array<{ event?: string; sequence?: number; ok: boolean; error: { code: string } | null; result: { task_id: string; task: { status: string } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"], `${label}: one task event, one outcome`); + assert.deepEqual(lines.map((l) => l.sequence), [1, 2], `${label}: sequence keeps counting`); + assert.equal(lines[1]!.ok, false); + assert.equal(lines[1]!.error!.code, code); + assert.equal(lines[1]!.result.task_id, "round2-task"); + assert.equal(lines[1]!.result.task.status, "SUCCEEDED"); + // json/pretty: exactly one final document with the same classification. + const js = await runCli(["text-to-3d", "stream", "round2-task", ...V1, ...extra], { env: api.env(), cwd: dir }); + assert.equal(js.code, exitCode, `${label} json: ${js.stderr}`); + const env = parseSingleJson(js.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, code); + assert.equal(env.result.task_id, "round2-task"); + const pretty = await runCli(["text-to-3d", "stream", "round2-task", ...V1, "--format", "pretty", ...extra], { env: api.env(), cwd: dir }); + assert.equal(pretty.code, exitCode, `${label} pretty: ${pretty.stderr}`); + assert.equal((pretty.stdout.match(/^ok: false$/gm) ?? []).length, 1, `${label} pretty: one result`); + } + assert.equal(readFileSync(occupied, "utf8"), "do not overwrite"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F04 / N06 — partial downloads keep their manifest and HTTP class +// --------------------------------------------------------------------------- + +test("N06/R2-F04 task -o: first asset written, second asset 503/404/403 → the manifest lists both, the HTTP class and status survive, files stay on disk", async () => { + let second: { status: number; body: unknown } = { status: 503, body: { message: "temporary asset host outage" } }; + const api = await startMockApi((req, res) => { + if (req.path === "/first.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb("first")); + } + if (req.path === "/second.png") return jsonReply(res, second.status, second.body); + if (req.method === "POST") return jsonReply(res, 200, { result: "round2-task" }); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/first.glb` }, thumbnail_url: `${api.url}/second.png` })); + }); + try { + const dir = tmpDir(); + const expectations: Array<[number, number, string]> = [ + [503, 7, "network"], + [404, 5, "not_found"], + [403, 4, "validation"], + ]; + for (const [status, exitCode, code] of expectations) { + second = { status, body: { message: `asset ${status}` } }; + for (const verb of ["get", "wait"]) { + const out = join(dir, `${verb}-${status}`); + const r = await runCli(["text-to-3d", verb, "round2-task", ...V1, "-o", out], { env: api.env(), cwd: dir }); + assert.equal(r.code, exitCode, `${verb} ${status}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; downloads: { state: string; files: Array<{ key: string; path: string; status: string; sha256: string; error: string | null }> } } }; + assert.equal(env.error.code, code); + assert.equal(env.error.http_status, status); + assert.equal(env.result.task_id, "round2-task"); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + const written = env.result.downloads.files[0]!; + assert.equal(written.path, join(out, "model.glb")); + assert.ok(existsSync(written.path), "the committed file is not rolled back"); + assert.equal(sha(written.path), written.sha256); + assert.match(env.result.downloads.files[1]!.error ?? "", new RegExp(String(status))); + assert.ok(!existsSync(join(out, "meta.json")), "no sidecar for an incomplete set"); + assert.deepEqual(readdirSync(out), ["model.glb"], "no temp files linger"); + } + } + // make: same rule on its final download. + second = { status: 503, body: { message: "outage" } }; + const makeOut = join(dir, "make-out"); + const mk = await runCli(["make", "a cactus", ...V1, "-o", makeOut], { env: api.env(), cwd: dir }); + assert.equal(mk.code, 7, `${mk.stderr}\n${mk.stdout}`); + const mo = parseSingleJson(mk.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; executed: unknown[]; downloads: { state: string; files: Array<{ key: string; status: string }> } } }; + assert.equal(mo.error.code, "network"); + assert.equal(mo.error.http_status, 503); + assert.equal(mo.result.task_id, "round2-task"); + assert.equal(mo.result.executed.length, 2); + assert.equal(mo.result.downloads.state, "partial"); + assert.deepEqual(mo.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + assert.ok(existsSync(join(makeOut, "model.glb"))); + // Legacy schema keeps the class in its payload too. + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", join(dir, "legacy-out")], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 7, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as { code: string; status: number; result: { downloads: { state: string; files: unknown[] } } }; + assert.equal(lp.code, "network"); + assert.equal(lp.status, 503); + assert.equal(lp.result.downloads.state, "partial"); + assert.equal(lp.result.downloads.files.length, 2); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F05 / N08 — SIGINT cancels the asset transfer +// --------------------------------------------------------------------------- + +test("N08/R2-F05 SIGINT during the asset transfer (before headers, mid-body, on the second asset) exits 130 with the task kept, no extra requests, no leftovers", async () => { + let child: ChildProcess | null = null; + let mode: "headers" | "body" | "second" = "headers"; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST" || req.method === "DELETE") return jsonReply(res, 500, { message: "unexpected" }); + if (req.path === "/fast.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb("fast")); + } + if (req.path === "/slow.glb" || req.path === "/slow.png") { + if (mode === "body") { + res.writeHead(200, { "content-type": req.path.endsWith(".png") ? "image/png" : "model/gltf-binary" }); + res.write(glb("slow").subarray(0, 8)); + } + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + try { + if (mode !== "body") res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(mode === "body" ? glb("slow").subarray(8) : glb("slow")); + } catch { + /* client gone */ + } + return; + } + if (mode === "second") return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/fast.glb` }, thumbnail_url: `${api.url}/slow.png` })); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/slow.glb` } })); + }); + try { + const dir = tmpDir(); + for (const m of ["headers", "body"] as const) { + mode = m; + api.requests.length = 0; + const target = join(dir, `${m}.glb`); + const r = await runCli(["text-to-3d", "get", "round2-task", ...V1, "-o", target], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, 130, `${m}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { task_id: string; submission: { state: string }; next: { get: string }; downloads: { state: string; files: Array<{ key: string; status: string; error: string | null }> } } }; + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.task_id, "round2-task"); + assert.equal(env.result.submission.state, "accepted"); + assert.match(env.result.next.get, /get round2-task/); + assert.equal(env.result.downloads.state, "failed"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); + assert.ok(!existsSync(target), `${m}: no final file`); + assert.ok(!existsSync(join(dir, `${m}_meta.json`)), `${m}: no sidecar`); + assert.ok(!readdirSync(dir).some((f) => f.includes(".tmp-")), `${m}: temp file cleaned`); + assert.deepEqual(api.requests.map((q) => q.method), ["GET", "GET"], `${m}: task GET + one asset GET, nothing else`); + } + // Second asset: the first stays committed in the manifest. + mode = "second"; + api.requests.length = 0; + const out = join(dir, "second"); + const r = await runCli(["text-to-3d", "wait", "round2-task", ...V1, "-o", out], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { downloads: { state: string; files: Array<{ key: string; status: string; path: string }> } } }; + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + assert.ok(existsSync(env.result.downloads.files[0]!.path)); + assert.deepEqual(readdirSync(out), ["model.glb"], "no temp file, no sidecar"); + assert.ok(!api.requests.some((q) => q.method === "DELETE" || q.method === "POST")); + // Legacy schema and make share the behaviour. + mode = "headers"; + api.requests.length = 0; + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", join(dir, "legacy.glb")], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(legacy.code, 130, legacy.stderr); + assert.ok(!existsSync(join(dir, "legacy.glb"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F06 / N07 — OAuth logins without a user id +// --------------------------------------------------------------------------- + +test("N07/R2-F06 an OAuth profile without user_id or login_id cannot replay an existing operation; a login id binds the login and survives refresh", async () => { + let tokens = 0; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/oauth/token") { + tokens += 1; + return jsonReply(res, 200, { access_token: `refreshed-${tokens}`, token_type: "Bearer", expires_in: 3600, refresh_token: "fixture-refresh" }); + } + if (req.method === "POST") return jsonReply(res, 200, { result: req.headers["authorization"] === "Bearer fixture-account-b-token" ? "oauth-account-b-task" : "oauth-account-a-task" }); + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const creds = join(dir, "oauth-credentials.json"); + const profile = (token: string, extra: Record = {}, expiresAt = Date.now() + 3_600_000) => + JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "oauth", access_token: token, refresh_token: "fixture-refresh", expires_at: expiresAt, created_at: 1, ...extra } } }); + const env: Record = { ...api.env(), MESHY_CREDENTIALS_PATH: creds }; + delete env["MESHY_API_KEY"]; + const args = [...CREATE, "--operation-id", "missing-subject-op"]; + + // No identity at all: the first submission works, a second login under the same profile name is refused a replay. + writeFileSync(creds, profile("fixture-account-a-token")); + const a = await runCli(args, { env, cwd: dir }); + assert.equal(a.code, 0, a.stderr); + writeFileSync(creds, profile("fixture-account-b-token")); + const b = await runCli(args, { env, cwd: dir }); + assert.equal(b.code, 2, `${b.stderr}\n${b.stdout}`); + const bo = parseSingleJson(b.stdout) as { error: { code: string; message: string; recovery: { command: string } }; result: { conflict: string[]; submission: { task_id: string } } }; + assert.equal(bo.error.code, "operation_conflict"); + assert.deepEqual(bo.result.conflict, ["credential_unverified"]); + assert.match(bo.error.recovery.command, /meshy auth login/); + assert.equal(bo.result.submission.task_id, "oauth-account-a-task", "the record is shown, never re-used"); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 1, "account B sent nothing"); + + // A login id binds the login: rotated token → replay; a new login (new id) → conflict. + const withLogin = [...CREATE, "--operation-id", "login-bound-op"]; + writeFileSync(creds, profile("tok-1", { login_id: "login-aaaa" })); + const first = await runCli(withLogin, { env, cwd: dir }); + assert.equal(first.code, 0, first.stderr); + writeFileSync(creds, profile("tok-2", { login_id: "login-aaaa" })); + const rotated = await runCli(withLogin, { env, cwd: dir }); + assert.equal(rotated.code, 0, rotated.stderr); + assert.equal((parseSingleJson(rotated.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + writeFileSync(creds, profile("tok-3", { login_id: "login-bbbb" })); + const relogin = await runCli(withLogin, { env, cwd: dir }); + assert.equal(relogin.code, 2, relogin.stderr); + assert.deepEqual((parseSingleJson(relogin.stdout) as { result: { conflict: string[] } }).result.conflict, ["credential"]); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 2); + + // A silent refresh keeps the login id (and therefore the identity): expired token → refresh → replay, no new POST. + const refreshOp = [...CREATE, "--operation-id", "refresh-op"]; + writeFileSync(creds, profile("tok-old", { login_id: "login-cccc" })); + const before = await runCli(refreshOp, { env, cwd: dir }); + assert.equal(before.code, 0, before.stderr); + writeFileSync(creds, profile("tok-expired", { login_id: "login-cccc" }, Date.now() - 1000)); + const after = await runCli(refreshOp, { env, cwd: dir }); + assert.equal(after.code, 0, `${after.stderr}\n${after.stdout}`); + assert.equal((parseSingleJson(after.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + const saved = JSON.parse(readFileSync(creds, "utf8")) as { profiles: { default: { access_token: string; login_id: string } } }; + assert.equal(saved.profiles.default.login_id, "login-cccc", "refresh preserved the login id"); + assert.match(saved.profiles.default.access_token, /^refreshed-/, "the token was rotated by the refresh"); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 3); + // Journals hold neither tokens nor login ids in the clear. + const ops = join(String(env["MESHY_CONFIG_DIR"]), "operations"); + for (const f of readdirSync(ops).filter((n) => n.endsWith(".json"))) { + const text = readFileSync(join(ops, f), "utf8"); + assert.ok(!/fixture-account|tok-|refreshed-|login-[abc]{4}/.test(text), `${f}: no secret or raw login id`); + } + } finally { + await api.close(); + } +}); + +test("R2-F06 credentialBinding: OAuth without subject or login id is unverified; subject or login id verifies; API keys bind to the key", () => { + assert.deepEqual(credentialBinding({ source: "file", origin: "o", kind: "oauth" }), { binding: "unverified", verified: false }); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", subject: "u1" }).verified, true); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", loginId: "l1" }).verified, true); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", loginId: "l1" }).binding, "login:l1"); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", subject: "u1", loginId: "l1" }).binding, "subject:u1", "the account subject wins over the login id"); + assert.equal(credentialBinding({ source: "env", origin: "o", kind: "api_key", secret: "msy_x" }).verified, true); + assert.ok(!credentialBinding({ source: "env", origin: "o", kind: "api_key", secret: "msy_x" }).binding.includes("msy_x")); +}); diff --git a/tests/codex-review-round3.test.ts b/tests/codex-review-round3.test.ts new file mode 100644 index 0000000..b841126 --- /dev/null +++ b/tests/codex-review-round3.test.ts @@ -0,0 +1,452 @@ +/** + * Codex review round 3 (reviews/cli-s1-cf8905d, R3-F01–R3-F06) as positive + * regressions. Each scenario mirrors the reviewer's probe (C01–C06) with real + * subprocesses, a loopback API/asset host that records every request, synthetic + * credentials and isolated temp directories, and asserts the required outcome: + * the sidecar is published like an asset (no overwrite, no new symlink, root + * re-proven at publication), every failure after the transfers keeps the + * per-file manifest with the bytes actually on disk, Ctrl-C during the material + * rewrite is an interrupt, legacy-schema errors still name the accepted task, + * texture identity follows the server-side name even when it collides with a + * generated one, and an aliased project path still records its files. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { CliError } from "../src/internal/errors.js"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; + +function taskBody(fields: Record = {}): Record { + return { id: "round3-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function tmpFiles(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).filter((f) => f.includes(".tmp-")) : []; +} + +function journalOperationIds(configDir: string): string[] { + const ops = join(configDir, "operations"); + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => (JSON.parse(readFileSync(join(ops, f), "utf8")) as { operation_id: string; state: string; task_id: string | null }).operation_id); +} + +interface LegacyErrorPayload { + code: string; + status?: number; + hint?: string; + task_id?: string; + operation_id?: string; + result?: { task_id?: string; submission?: { state: string; operation_id: string | null; task_id?: string }; next?: { wait: string; get: string }; downloads?: { state: string; files: Array<{ key: string; status: string }>; failed_step?: string } }; +} + +// --------------------------------------------------------------------------- +// R3-F01 / C01 + R3-F04 / C02 — the sidecar is published like an asset +// --------------------------------------------------------------------------- + +test("C01+C02/R3-F01,R3-F04 sidecar targets planted after the preflight (symlink, file, directory) are refused; outside bytes untouched; the model manifest survives", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + // Another process changes the sidecar target between preflight and publication. + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const outside = join(dir, "outside-user-file.json"); + writeFileSync(outside, "original-user-data"); + const env = api.env(); + + const cases: Array<[string, (out: string) => void, RegExp]> = [ + ["symlink", (out) => symlinkSync(outside, join(out, "meta.json")), /symbolic link/], + ["file", (out) => writeFileSync(join(out, "meta.json"), "keep me"), /refusing to overwrite/], + ["directory", (out) => mkdirSync(join(out, "meta.json")), /refusing to overwrite|EEXIST|EISDIR/], + ]; + for (const [label, planter, re] of cases) { + const out = join(workspace, `race-${label}`); + plant = () => { + mkdirSync(out, { recursive: true }); + planter(out); + }; + const r = await runCli(["text-to-3d", "get", "round3-task", ...V1, "--workspace", workspace, "-o", out], { env, cwd: dir }); + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const env1 = parseSingleJson(r.stdout) as { error: { code: string; message: string }; result: { task_id: string; downloads: { state: string; failed_step: string; files: Array<{ key: string; path: string; status: string; sha256: string }> } } }; + assert.equal(env1.error.code, "local_io"); + assert.match(env1.error.message, re, `${label}: the refusal names its cause`); + assert.equal(env1.result.task_id, "round3-task"); + assert.equal(env1.result.downloads.state, "partial"); + assert.equal(env1.result.downloads.failed_step, "sidecar"); + assert.deepEqual(env1.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + const model = env1.result.downloads.files[0]!; + assert.ok(existsSync(model.path), `${label}: the committed model is not rolled back`); + assert.equal(sha(model.path), model.sha256); + assert.equal(readFileSync(outside, "utf8"), "original-user-data", `${label}: nothing outside the workspace changed`); + if (label === "file") assert.equal(readFileSync(join(out, "meta.json"), "utf8"), "keep me", "the planted file keeps its content"); + assert.deepEqual(tmpFiles(out), [], `${label}: no temp file left behind`); + } + assert.equal(api.requests.filter((q) => q.method !== "GET").length, 0); + + // Single-file mode: the per-file sidecar (`_meta.json`) follows the same rule. + const single = join(workspace, "single.glb"); + plant = () => symlinkSync(outside, join(workspace, "single_meta.json")); + const s = await runCli(["text-to-3d", "get", "round3-task", ...V1, "--workspace", workspace, "-o", single], { env, cwd: dir }); + assert.equal(s.code, 11, `${s.stderr}\n${s.stdout}`); + assert.equal(readFileSync(outside, "utf8"), "original-user-data"); + assert.ok(existsSync(single), "the model itself was published"); + // Legacy schema: same refusal, the task stays discoverable. + const legacyOut = join(workspace, "race-legacy"); + plant = () => { + mkdirSync(legacyOut, { recursive: true }); + symlinkSync(outside, join(legacyOut, "meta.json")); + }; + const legacy = await runCli(["text-to-3d", "get", "round3-task", "--workspace", workspace, "-o", legacyOut], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as LegacyErrorPayload; + assert.equal(lp.code, "local_io"); + assert.equal(lp.task_id, "round3-task"); + assert.equal(lp.result?.downloads?.failed_step, "sidecar"); + assert.deepEqual(lp.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + assert.equal(readFileSync(outside, "utf8"), "original-user-data"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R3-F05 / C03 — SIGINT during the material rewrite +// --------------------------------------------------------------------------- + +const BIG_OBJ = `mtllib original.mtl\n${"v 0.123456 0.654321 0.111111\n".repeat(400_000)}f 1 2 3\n`; + +test("C03/R3-F05 SIGINT while the OBJ is being relinked exits 130, keeps the committed files and their real digests, publishes no sidecar, leaves no temp file", async () => { + let child: ChildProcess | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/large.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end(BIG_OBJ); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end("newmtl a\nKd 1 0 0\n"); + } + if (req.method !== "GET") return jsonReply(res, 500, { message: "unexpected" }); + return jsonReply(res, 200, taskBody({ model_urls: { obj: `${api.url}/large.obj`, mtl: `${api.url}/original.mtl` } })); + }); + try { + const dir = tmpDir(); + const out = join(dir, "relink-interrupt"); + mkdirSync(out); + let signalSent = false; + // Fire once the relink pass has started: the rewrite goes through a temp + // file beside model.obj, and both downloads must already be on disk. + const poll = setInterval(() => { + if (signalSent || !existsSync(out)) return; + const names = readdirSync(out); + if (names.some((n) => n.startsWith(".model.obj.tmp-")) && names.includes("model.mtl")) { + signalSent = true; + child?.kill("SIGINT"); + } + }, 1); + const r = await runCli(["text-to-3d", "get", "round3-task", ...V1, "-o", out], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c), timeoutMs: 60_000 }); + clearInterval(poll); + assert.ok(signalSent, "the test caught the relink phase (temp file observed)"); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string; next: { get: string }; downloads: { state: string; failed_step: string; files: Array<{ key: string; path: string; status: string; sha256: string; bytes: number; relinked: boolean }> } } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.task_id, "round3-task"); + assert.equal(env.result.downloads.state, "partial"); + assert.equal(env.result.downloads.failed_step, "relink"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_obj", "written"], ["model_mtl", "written"]]); + for (const f of env.result.downloads.files) { + assert.ok(existsSync(f.path), `${f.key} stays on disk`); + assert.equal(sha(f.path), f.sha256, `${f.key}: manifest digest is the file actually on disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + const obj = readFileSync(join(out, "model.obj"), "utf8"); + assert.ok(obj === BIG_OBJ || obj === BIG_OBJ.replace("mtllib original.mtl", "mtllib model.mtl"), "the OBJ is either the original or the fully rewritten file, never a partial one"); + assert.ok(!existsSync(join(out, "meta.json")), "no sidecar after an interrupt"); + assert.deepEqual(tmpFiles(out), [], "the unfinished temp file was removed"); + assert.equal(api.requests.filter((q) => q.method !== "GET").length, 0, "no DELETE, no POST"); + } finally { + await api.close(); + } +}); + +test("R3-F05 relinkMaterials is cooperative: an aborted signal stops before any read/write and mid-way, leaving the originals intact and no temp files", async () => { + const dir = tmpDir(); + const objPath = join(dir, "model.obj"); + const mtlPath = join(dir, "model.mtl"); + writeFileSync(objPath, BIG_OBJ); + writeFileSync(mtlPath, "newmtl a\nmap_Kd tex.png\n"); + const before = sha(objPath); + const files = [ + { key: "model_obj", path: objPath, sourceName: "large.obj" }, + { key: "model_mtl", path: mtlPath, sourceName: "original.mtl" }, + ]; + // Already aborted: nothing is touched. + const done = new AbortController(); + done.abort(); + await assert.rejects(relinkMaterials(files, { signal: done.signal }), (e: unknown) => e instanceof CliError && e.code === "interrupted"); + assert.equal(sha(objPath), before); + // Aborted while the (large) OBJ is being rewritten: the temp file is removed, the original stays. + const midway = new AbortController(); + setTimeout(() => midway.abort(), 2); + await assert.rejects(relinkMaterials(files, { signal: midway.signal }), (e: unknown) => e instanceof CliError && e.code === "interrupted"); + assert.equal(sha(objPath), before, "the original OBJ is untouched"); + assert.deepEqual(tmpFiles(dir), []); + // Without a signal the same set relinks normally. + const report = await relinkMaterials(files); + assert.equal(report?.rewritten.length, 1); + assert.match(readFileSync(objPath, "utf8").slice(0, 40), /^mtllib model\.mtl\n/); +}); + +// --------------------------------------------------------------------------- +// R3-F06 / C04 — project reached through an alias path +// --------------------------------------------------------------------------- + +test("C04/R3-F06 a project reached through a symlinked parent (or the macOS /var alias) still records its downloaded files; files truly outside are not recorded", async () => { + const host = await startMockApi((_req, res) => { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + }); + try { + const dir = tmpDir(); // on macOS this is itself an alias (/var → /private/var) + const realRoot = join(dir, "real-projects"); + mkdirSync(realRoot); + const alias = join(dir, "alias-projects"); + symlinkSync(realRoot, alias); + const env = host.env({ MESHY_API_KEY: undefined }); + const init = await runCli(["project", "init", "--root", alias, "--name", "alias-review"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const projectDir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(projectDir.startsWith(alias), "the project is addressed through the alias"); + const fixture = join(projectDir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${host.url}/model.glb` } }))); + const r = await runCli(["download", "--task-json", fixture, "--all", "--project", projectDir], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { result: { project: { recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(out.result.project.recorded_files, ["model.glb"]); + assert.ok(!out.warnings.some((w) => w.code === "files_outside_project"), "no false files_outside_project warning"); + const meta = JSON.parse(readFileSync(join(projectDir, "metadata.json"), "utf8")) as { tasks: Array<{ files: string[] }> }; + assert.deepEqual(meta.tasks[0]!.files, ["model.glb"]); + assert.ok(existsSync(join(realRoot, readdirSync(realRoot).find((n) => n !== "history.json")!, "model.glb"))); + // Files written outside the project (explicit --output-dir elsewhere, no workspace) are still not recorded. + const elsewhere = join(dir, "elsewhere"); + const outside = await runCli(["download", "--task-json", fixture, "--all", "--project", projectDir, "--output-dir", elsewhere], { env, cwd: dir }); + assert.equal(outside.code, 0, outside.stderr); + const oo = parseSingleJson(outside.stdout) as { result: { project: { recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(oo.result.project.recorded_files, []); + assert.ok(oo.warnings.some((w) => w.code === "files_outside_project")); + assert.ok(existsSync(join(elsewhere, "model.glb"))); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R3-F03 / C05 — texture identity when source names collide with generated names +// --------------------------------------------------------------------------- + +test("C05/R3-F03 source names that collide with the CLI's generated names still map to the right image (verified by bytes); a generated-name reference with a different source is ambiguous", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const green = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#00ff00" } }).png().toBuffer(); + const mtl = "newmtl red\nmap_Kd texture_1_base_color.png\nnewmtl green\nmap_Kd texture_0_base_color.png\n"; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib model.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/model.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (req.path === "/texture_1_base_color.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + if (req.path === "/texture_0_base_color.png" || req.path === "/a.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(green); + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const env = host.env({ MESHY_API_KEY: undefined }); + // texture_urls[0] is served as texture_1_base_color.png (red) and lands as texture_0_base_color.png; [1] the other way round. + const fixture = join(dir, "collision.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/model.mtl` }, texture_urls: [{ base_color: `${host.url}/texture_1_base_color.png` }, { base_color: `${host.url}/texture_0_base_color.png` }] }))); + const out = join(dir, "collision"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const lines = readFileSync(join(out, "model.mtl"), "utf8").split("\n"); + const redRef = lines[lines.indexOf("newmtl red") + 1]!.split(" ")[1]!; + const greenRef = lines[lines.indexOf("newmtl green") + 1]!.split(" ")[1]!; + assert.ok(readFileSync(join(out, redRef)).equals(red), `the red material references the red image (${redRef})`); + assert.ok(readFileSync(join(out, greenRef)).equals(green), `the green material references the green image (${greenRef})`); + assert.equal(redRef, "texture_0_base_color.png"); + assert.equal(greenRef, "texture_1_base_color.png"); + const env1 = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ material: string; reference: string; resolved_to: string; method: string }> } } } }; + assert.equal(env1.result.downloads.material_links.status, "complete"); + assert.deepEqual(env1.result.downloads.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), [ + ["red", "texture_1_base_color.png", "texture_0_base_color.png", "source_name"], + ["green", "texture_0_base_color.png", "texture_1_base_color.png", "source_name"], + ]); + assert.ok(!env1.warnings.some((w) => w.code.startsWith("material_reference"))); + + // A reference that equals a generated name whose source is something else, with no source matching it: ambiguous, kept, warned. + const fixture2 = join(dir, "collision2.json"); + writeFileSync(fixture2, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/model.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }] }))); + const out2 = join(dir, "collision2"); + const r2 = await runCli(["download", "--task-json", fixture2, "--model-format", "obj", "--output-dir", out2], { env, cwd: dir }); + assert.equal(r2.code, 0, r2.stderr); + const saved2 = readFileSync(join(out2, "model.mtl"), "utf8"); + assert.equal(saved2, mtl, "both references stay as written"); + const env2 = parseSingleJson(r2.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string; note?: string }> } } } }; + assert.equal(env2.result.downloads.material_links.status, "incomplete"); + const generated = env2.result.downloads.material_links.texture_maps.find((l) => l.reference === "texture_0_base_color.png")!; + assert.equal(generated.method, "ambiguous"); + assert.equal(generated.resolved_to, null); + assert.match(generated.note ?? "", /served as 'a\.png'/); + assert.ok(env2.warnings.some((w) => w.code === "material_reference_ambiguous" && w.message.includes("served as 'a.png'"))); + } finally { + await host.close(); + } +}); + +test("R3-F03 without source evidence a saved-name match is still accepted (conservative fallback)", async () => { + const dir = tmpDir(); + writeFileSync(join(dir, "model.obj"), "mtllib m.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), "newmtl a\nmap_Kd texture_0_base_color.png\n"); + writeFileSync(join(dir, "texture_0_base_color.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const report = await relinkMaterials([ + { key: "model.obj", path: join(dir, "model.obj") }, + { key: "model.mtl", path: join(dir, "model.mtl") }, + { key: "texture.0.base_color", path: join(dir, "texture_0_base_color.png") }, + ]); + assert.equal(report?.status, "complete"); + assert.deepEqual(report?.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [["texture_0_base_color.png", "texture_0_base_color.png", "unchanged"]]); +}); + +// --------------------------------------------------------------------------- +// R3-F02 / C06 — legacy schema keeps the accepted task on every download failure +// --------------------------------------------------------------------------- + +test("C06/R3-F02 legacy sync create/wait/get: an asset 503, a sidecar failure and SIGINT still report task_id, the real operation_id, next and the manifest; exactly one POST (make: codex-review-round4 R4-T01 with distinct step ids)", async () => { + let assetMode: "503" | "ok" | "slow" = "503"; + let plant: (() => void) | null = null; + let child: ChildProcess | null = null; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "paid-legacy-created-id" }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + if (req.path === "/asset.glb") { + plant?.(); + plant = null; + if (assetMode === "503") return jsonReply(res, 503, { message: "asset host down" }); + if (assetMode === "slow") { + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + } + try { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + } catch { + /* client gone */ + } + return; + } + return jsonReply(res, 200, taskBody({ id: "paid-legacy-created-id", model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + + // Sync create (default schema), asset host 503. + const created = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture legacy", "-o", join(dir, "legacy-created")], { env, cwd: dir }); + assert.equal(created.code, 7, `${created.stderr}\n${created.stdout}`); + const payload = parseSingleJson(created.stdout) as LegacyErrorPayload; + assert.equal(payload.code, "network"); + assert.equal(payload.status, 503); + assert.equal(payload.task_id, "paid-legacy-created-id"); + assert.equal(payload.result?.task_id, "paid-legacy-created-id"); + assert.equal(payload.result?.submission?.state, "accepted"); + const [operationId] = journalOperationIds(configDir); + assert.ok(operationId, "the journal holds the accepted record"); + assert.equal(payload.operation_id, operationId, "the error names the journal's operation id"); + assert.equal(payload.result?.submission?.operation_id, operationId); + assert.match(payload.result?.next?.wait ?? "", /wait paid-legacy-created-id/); + assert.deepEqual(payload.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); + assert.ok(payload.hint && /paid-legacy-created-id/.test(payload.hint), "the hint (also printed on stderr) names the task"); + assert.match(created.stderr, /paid-legacy-created-id/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + + // Legacy wait -o: same fields. + const waited = await runCli(["text-to-3d", "wait", "paid-legacy-created-id", "-o", join(dir, "legacy-wait")], { env, cwd: dir }); + assert.equal(waited.code, 7, waited.stderr); + const wp = parseSingleJson(waited.stdout) as LegacyErrorPayload; + assert.equal(wp.task_id, "paid-legacy-created-id"); + assert.equal(wp.status, 503); + + // Legacy sidecar failure after the asset landed. + assetMode = "ok"; + const sideDir = join(dir, "legacy-sidecar"); + plant = () => { + mkdirSync(sideDir, { recursive: true }); + mkdirSync(join(sideDir, "meta.json")); + }; + const side = await runCli(["text-to-3d", "get", "paid-legacy-created-id", "-o", sideDir], { env, cwd: dir }); + assert.equal(side.code, 11, `${side.stderr}\n${side.stdout}`); + const sp = parseSingleJson(side.stdout) as LegacyErrorPayload; + assert.equal(sp.task_id, "paid-legacy-created-id"); + assert.equal(sp.result?.downloads?.state, "partial"); + assert.equal(sp.result?.downloads?.failed_step, "sidecar"); + assert.deepEqual(sp.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + assert.ok(existsSync(join(sideDir, "model.glb"))); + + // Legacy SIGINT during the transfer. + assetMode = "slow"; + const target = join(dir, "legacy-int.glb"); + const interrupted = await runCli(["text-to-3d", "get", "paid-legacy-created-id", "-o", target], { env, cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(interrupted.code, 130, `${interrupted.stderr}\n${interrupted.stdout}`); + const ip = parseSingleJson(interrupted.stdout) as LegacyErrorPayload; + assert.equal(ip.code, "interrupted"); + assert.equal(ip.task_id, "paid-legacy-created-id"); + assert.ok(!existsSync(target)); + + } finally { + await api.close(); + } +}); diff --git a/tests/codex-review-round4.test.ts b/tests/codex-review-round4.test.ts new file mode 100644 index 0000000..0bea98d --- /dev/null +++ b/tests/codex-review-round4.test.ts @@ -0,0 +1,651 @@ +/** + * Codex review round 4 (reviews/cli-s1-235d6de: R4-F01, R4-F02, R4-T01) as + * positive regressions. Each scenario mirrors the reviewer's probe (D01, D02, + * round4-context-checks) with real subprocesses, a loopback API/asset host that + * records every request, synthetic credentials and isolated temp directories, + * and asserts the required outcome: two different MTL references that only + * reach the same sole texture through channel fallbacks are never merged (in + * either order, whichever channel rule each one took), a project-record failure + * after the transfers keeps the whole download result and says how to redo the + * bookkeeping alone, foreseeable --project problems are refused before any + * transfer, and `make`'s reported operation id is the *last* accepted journal + * record's (two steps with different ids, asset 503 and SIGINT, both schemas). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import sharp from "sharp"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round4-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function tmpFiles(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).filter((f) => f.includes(".tmp-")) : []; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +/** Split a recovery command the way a POSIX shell would (bare words, single quotes with the '\'' escape). */ +function shellSplit(command: string): string[] { + const words: string[] = []; + let cur = ""; + let pending = false; + let inQuote = false; + let escape = false; + for (const ch of command) { + if (escape) { + cur += ch; + escape = false; + pending = true; + continue; + } + if (inQuote) { + if (ch === "'") inQuote = false; + else cur += ch; + pending = true; + continue; + } + if (ch === "'") { + inQuote = true; + pending = true; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (/\s/.test(ch)) { + if (pending) { + words.push(cur); + cur = ""; + pending = false; + } + continue; + } + cur += ch; + pending = true; + } + if (pending) words.push(cur); + return words; +} + +interface ManifestFile { + key: string; + path: string; + status: string; + bytes: number; + sha256: string; + relinked: boolean; +} + +interface TextureMap { + line: number; + material: string | null; + reference: string; + resolved_to: string | null; + method: string; + candidates?: string[]; + note?: string; +} + +interface MaterialEnvelope { + ok: boolean; + result: { + downloads: { + state: string; + files: ManifestFile[]; + material_links: { status: string; rewritten: string[]; texture_maps: TextureMap[] }; + }; + }; + warnings: Array<{ code: string; message: string }>; +} + +// --------------------------------------------------------------------------- +// R4-F01 / D01 — channel fallbacks compete on the texture they actually reach +// --------------------------------------------------------------------------- + +test("D01/R4-F01 two different references that reach the sole base color only through channel fallbacks (key channel and name channel) stay as written in both orders; ambiguous + incomplete with one shared note; MTL byte-identical; digests match disk", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const refs = ["body_normal.png", "eyes_diffuse.png"] as const; + for (const order of [ + [0, 1], + [1, 0], + ] as const) { + const first = refs[order[0]]; + const second = refs[order[1]]; + const mtl = `newmtl body\nmap_Kd ${first}\nnewmtl eyes\nmap_Kd ${second}\n`; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib original.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (req.path === "/a.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + return jsonReply(res, 404, {}); + }); + try { + const label = `${first} then ${second}`; + const dir = tmpDir(); + const fixture = join(dir, "channel-fallback.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/original.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }] }))); + const out = join(dir, "channel-fallback"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${label}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as MaterialEnvelope & Record; + assert.deepEqual(Object.keys(env).sort(), ENVELOPE_KEYS); + assert.equal(env.ok, true); + assert.equal(readFileSync(join(out, "model.mtl"), "utf8"), mtl, `${label}: the MTL is byte-identical — neither reference was rewritten`); + assert.match(readFileSync(join(out, "model.obj"), "utf8"), /^mtllib model\.mtl$/m, `${label}: the OBJ still points at the saved MTL`); + assert.ok(readFileSync(join(out, "texture_0_base_color.png")).equals(red), `${label}: the one texture is the served image`); + const dl = env.result.downloads; + assert.equal(dl.state, "completed", `${label}: every file landed`); + assert.equal(dl.material_links.status, "incomplete"); + assert.deepEqual( + dl.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method, l.candidates]), + [ + ["body", first, null, "ambiguous", ["texture_0_base_color.png"]], + ["eyes", second, null, "ambiguous", ["texture_0_base_color.png"]], + ], + `${label}: both references are ambiguous, not resolved`, + ); + for (const l of dl.material_links.texture_maps) { + assert.match(l.note ?? "", /'body_normal\.png' \(channel_of_key\)/, `${label}: the note names the key-channel fallback`); + assert.match(l.note ?? "", /'eyes_diffuse\.png' \(channel_in_name\)/, `${label}: the note names the name-channel fallback`); + assert.match(l.note ?? "", /compete for texture_0_base_color\.png/); + } + assert.equal(dl.material_links.texture_maps[0]!.note, dl.material_links.texture_maps[1]!.note, `${label}: one shared note for the group`); + assert.deepEqual(dl.material_links.rewritten.map((p) => basename(p)), ["model.obj"], `${label}: only the OBJ's mtllib was rewritten`); + const byKey = Object.fromEntries(dl.files.map((f) => [f.key, f])); + assert.equal(byKey["model.obj"]!.relinked, true); + assert.equal(byKey["model.mtl"]!.relinked, false, `${label}: the MTL was not touched`); + assert.equal(byKey["texture.0.base_color"]!.relinked, false); + for (const f of dl.files) { + assert.equal(f.status, "written"); + assert.equal(sha(f.path), f.sha256, `${label}: ${f.key} manifest digest is the file on disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + const ambiguous = env.warnings.filter((w) => w.code === "material_reference_ambiguous"); + assert.equal(ambiguous.length, 1, `${label}: exactly one ambiguity warning`); + assert.match(ambiguous[0]!.message, /'body_normal\.png' \(channel_of_key\) and 'eyes_diffuse\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.match(ambiguous[0]!.message, /\(body, eyes\)|\(eyes, body\)/, `${label}: the warning names both material groups`); + assert.equal(ambiguous[0]!.message.split("compete for").length, 2, `${label}: the shared reason is said once`); + assert.ok(!env.warnings.some((w) => w.code === "material_reference_unresolved")); + assert.deepEqual(host.requests.map((q) => [q.method, q.path]), [ + ["GET", "/model.obj"], + ["GET", "/original.mtl"], + ["GET", "/a.png"], + ]); + assert.deepEqual(tmpFiles(out), []); + } finally { + await host.close(); + } + } +}); + +test("R4-F01 arbitration: an identity match keeps its texture while a heuristic rival stays as written; an unresolved rival that could mean the texture blocks it too; one reference sent to different textures by different keys is not rewritten; lone and distinct-channel fallbacks still resolve", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const setup = (mtl: string, textures: Array<{ key: string; name: string; sourceName: string | null }>) => { + const dir = tmpDir(); + writeFileSync(join(dir, "model.obj"), "mtllib m.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), mtl); + for (const t of textures) writeFileSync(join(dir, t.name), png); + return { + dir, + files: [ + { key: "model.obj", path: join(dir, "model.obj"), sourceName: "m.obj" }, + { key: "model.mtl", path: join(dir, "model.mtl"), sourceName: "m.mtl" }, + ...textures.map((t) => ({ key: t.key, path: join(dir, t.name), sourceName: t.sourceName })), + ], + }; + }; + + // a) identity (source name) + heuristic (name channel) on the same sole texture: identity wins, the heuristic stays as written. + { + const mtl = "newmtl body\nmap_Kd body.png\nnewmtl eyes\nmap_Kd eyes_diffuse.png\n"; + const { dir, files } = setup(mtl, [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "body.png" }]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), [ + ["body", "body.png", "texture_0_base_color.png", "source_name"], + ["eyes", "eyes_diffuse.png", null, "ambiguous"], + ]); + assert.match(report.texture_maps[1]!.note ?? "", /'body\.png' \(source_name\) and 'eyes_diffuse\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), "newmtl body\nmap_Kd texture_0_base_color.png\nnewmtl eyes\nmap_Kd eyes_diffuse.png\n"); + assert.equal(report.warnings.length, 1); + assert.match(report.warnings[0]!.message, /compete for texture_0_base_color\.png; .* name the same image \(eyes\); the references stay as written/); + } + + // b) a rival that could not be resolved (generated name, other source) still contends for the texture: the heuristic must not take it. + { + const mtl = "newmtl red\nmap_Kd texture_1_base_color.png\nnewmtl green\nmap_Kd texture_0_base_color.png\n"; + const { dir, files } = setup(mtl, [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["texture_1_base_color.png", null, "ambiguous"], + ["texture_0_base_color.png", null, "ambiguous"], + ]); + assert.match(report.texture_maps[0]!.note ?? "", /'texture_0_base_color\.png' \(ambiguous\) and 'texture_1_base_color\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.match(report.texture_maps[1]!.note ?? "", /served as 'a\.png'/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl, "nothing rewritten"); + } + + // c) the same reference under two keys would land on two textures: one reference names one file, so it stays as written. + { + const mtl = "newmtl a\nmap_Kd shared.png\nmap_Bump shared.png\n"; + const { dir, files } = setup(mtl, [ + { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "x.png" }, + { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "y.png" }, + ]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["shared.png", null, "ambiguous"], + ["shared.png", null, "ambiguous"], + ]); + for (const l of report.texture_maps) assert.match(l.note ?? "", /one reference names one file/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl); + } + + // d) a lone reference may still fall back by key; two references on distinct channels resolve independently. + { + const lone = setup("newmtl a\nmap_Kd body_normal.png\n", [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }]); + const r1 = (await relinkMaterials(lone.files))!; + assert.equal(r1.status, "complete"); + assert.deepEqual(r1.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [["body_normal.png", "texture_0_base_color.png", "channel_of_key"]]); + assert.equal(readFileSync(join(lone.dir, "model.mtl"), "utf8"), "newmtl a\nmap_Kd texture_0_base_color.png\n"); + + const two = setup("newmtl a\nmap_Kd skin.png\nmap_Bump -bm 0.5 Body_Normal.png\n", [ + { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }, + { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "b.png" }, + ]); + const r2 = (await relinkMaterials(two.files))!; + assert.equal(r2.status, "complete"); + assert.deepEqual(r2.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["skin.png", "texture_0_base_color.png", "channel_of_key"], + ["Body_Normal.png", "texture_0_normal.png", "channel_in_name"], + ]); + assert.equal(readFileSync(join(two.dir, "model.mtl"), "utf8"), "newmtl a\nmap_Kd texture_0_base_color.png\nmap_Bump -bm 0.5 texture_0_normal.png\n"); + assert.deepEqual(r2.warnings, []); + } +}); + +// --------------------------------------------------------------------------- +// R4-F02 / D02 — a project-record failure after the transfer keeps the result +// --------------------------------------------------------------------------- + +interface ProjectFailureEnvelope { + schema_version: string; + command: string; + ok: boolean; + result: { + source: { kind: string; task_id: string }; + selection: { selected: string[]; dependencies: string[] }; + downloads: { state: string; files: ManifestFile[]; metadata_path: null; material_links: unknown }; + unknown_urls: unknown[]; + saved_json: { path: string; bytes: number } | null; + project: { + project_dir: string; + action: string; + stage: string; + recorded_files: string[]; + error: { code: string; message: string }; + recovery: { action: string; automatic: boolean; command: string }; + }; + }; + error: { code: string; message: string; http_status: number | null; retryable: boolean; recovery: { action: string; automatic: boolean; command: string }; hint?: string }; + warnings: Array<{ code: string }>; +} + +test("D02/R4-F02 download --project: a record failure after the transfer (metadata.json swapped for a symlink, damaged, project dir unwritable) keeps source/selection/manifest/saved_json, reports the project failure with a record-only recovery, exit 11, one GET, assets kept, outside bytes unchanged; the recovery command then records the task", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + // Another process changes the project between the preflight and the record step. + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const env = api.env({ MESHY_API_KEY: undefined }); + const initProject = async (name: string): Promise => { + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", name, "--workspace", workspace], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + return (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + }; + const fixture = join(workspace, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + const expectFailure = (r: { code: number; stdout: string; stderr: string }, label: string, recordedFiles: string[] = ["model.glb"]): ProjectFailureEnvelope => { + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as ProjectFailureEnvelope; + assert.deepEqual(Object.keys(e).sort(), ENVELOPE_KEYS, label); + assert.equal(e.schema_version, "meshy.cli/v1"); + assert.equal(e.command, "download"); + assert.equal(e.ok, false); + assert.equal(e.error.code, "local_io", label); + assert.equal(e.error.http_status, null); + assert.equal(e.error.retryable, false); + assert.match(e.error.message, /^1 file\(s\) were downloaded to .* but recording task round4-task in project .* failed: /, label); + assert.equal(e.error.recovery.action, "record_project", label); + assert.equal(e.error.recovery.automatic, false); + assert.equal(e.error.hint, e.error.recovery.command, `${label}: the hint is the recovery command`); + assert.match(r.stderr, /^hint: meshy project record /m, `${label}: stderr carries the same command`); + // The whole download result survives. + assert.equal(e.result.source.kind, "task-json"); + assert.equal(e.result.source.task_id, "round4-task"); + assert.deepEqual(e.result.selection, { selected: ["model.glb"], dependencies: [] }); + assert.equal(e.result.downloads.state, "completed", `${label}: the transfer itself completed`); + assert.deepEqual(e.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"]]); + const model = e.result.downloads.files[0]!; + assert.ok(existsSync(model.path), `${label}: the asset is not rolled back`); + assert.ok(readFileSync(model.path).equals(glb()), `${label}: the asset has the served bytes`); + assert.equal(sha(model.path), model.sha256, `${label}: manifest digest is the file on disk`); + assert.equal(readFileSync(model.path).length, model.bytes); + assert.deepEqual(e.result.unknown_urls, []); + // The project record says what failed and how to redo just that. + assert.equal(e.result.project.action, "failed", label); + assert.equal(e.result.project.stage, "preview"); + assert.deepEqual(e.result.project.recorded_files, []); + assert.equal(e.result.project.error.code, "local_io"); + assert.deepEqual(e.result.project.recovery, e.error.recovery); + assert.match( + e.error.recovery.command, + new RegExp(`^meshy project record --project \\S+ --task-id round4-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED${recordedFiles.map((f) => ` --file ${f.replaceAll(".", "\\.")}`).join("")} --workspace \\S+$`), + `${label}: the recovery redoes exactly the metadata entry (files that landed inside the project) under the original workspace`, + ); + return e; + }; + + // (1) metadata.json becomes a symlink to a valid metadata file outside the workspace during the GET. + const proj1 = await initProject("record-failure"); + const meta1 = join(proj1, "metadata.json"); + const outside = join(dir, "outside-metadata.json"); + copyFileSync(meta1, outside); + const outsideBefore = readFileSync(outside); + const backup = join(proj1, "original-metadata.json"); + plant = () => { + renameSync(meta1, backup); + symlinkSync(outside, meta1); + }; + const saveJson = join(workspace, "task-copy.json"); + const r1 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj1, "--workspace", workspace, "--save-json", saveJson], { env, cwd: dir }); + const e1 = expectFailure(r1, "symlink"); + assert.match(e1.error.message, /not a regular file/); + assert.match(e1.result.project.error.message, /not a regular file/); + assert.equal(realpathSync(e1.result.project.project_dir), realpathSync(proj1)); + assert.ok(e1.result.saved_json && existsSync(e1.result.saved_json.path), "the raw task JSON was saved and is reported"); + assert.equal((JSON.parse(readFileSync(e1.result.saved_json!.path, "utf8")) as { id: string }).id, "round4-task"); + assert.ok(readFileSync(outside).equals(outsideBefore), "the outside metadata file is untouched"); + assert.ok(lstatSync(meta1).isSymbolicLink(), "the planted symlink was not replaced"); + assert.ok(readFileSync(backup).equals(outsideBefore), "the original metadata is intact"); + assert.deepEqual(tmpFiles(proj1), [], "no temp file left in the project"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]], "one asset GET, nothing else"); + // Repair the directory and run the recovery command verbatim — nothing appended: the + // command itself carries the original --workspace (round 5, R5-F01) and records exactly the downloaded file with no request. + unlinkSync(meta1); + renameSync(backup, meta1); + const words = shellSplit(e1.error.recovery.command); + assert.equal(words[0], "meshy"); + assert.equal(realpathSync(words[words.indexOf("--workspace") + 1]!), realpathSync(workspace), "the recovery command carries the original workspace"); + const rec = await runCli(words.slice(1), { env, cwd: dir }); + assert.equal(rec.code, 0, `${rec.stderr}\n${rec.stdout}`); + const meta = JSON.parse(readFileSync(meta1, "utf8")) as { tasks: Array<{ task_id: string; stage: string; files: string[]; status: string | null; resource: string | null }> }; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.stage, t.files, t.status, t.resource]), [["round4-task", "preview", ["model.glb"], "SUCCEEDED", "text-to-3d"]]); + assert.equal(api.requests.length, 1, "the recovery made no request"); + assert.ok(existsSync(join(proj1, "model.glb"))); + + // (2) metadata.json is damaged (not JSON) during the GET: refused, never overwritten, result kept. + const proj2 = await initProject("damaged"); + const meta2 = join(proj2, "metadata.json"); + plant = () => writeFileSync(meta2, "{ not json"); + const r2 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj2, "--workspace", workspace], { env, cwd: dir }); + const e2 = expectFailure(r2, "damaged"); + assert.match(e2.error.message, /not valid JSON/); + assert.equal(readFileSync(meta2, "utf8"), "{ not json", "the damaged file is left for the user to repair, not overwritten"); + assert.equal(e2.result.saved_json, null); + assert.ok(existsSync(join(proj2, "model.glb"))); + assert.deepEqual(api.requests.slice(1).map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + + // (3) the project directory becomes unwritable during the GET (the asset goes elsewhere in the workspace): the lock cannot be created — a plain errno, still local_io with the result. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + const proj3 = await initProject("unwritable"); + const elsewhere = join(workspace, "elsewhere"); + plant = () => chmodSync(proj3, 0o555); + try { + const r3 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj3, "--output-dir", elsewhere, "--workspace", workspace], { env, cwd: dir }); + const e3 = expectFailure(r3, "unwritable", []); + assert.match(e3.error.message, /EACCES|permission denied/i); + assert.ok(e3.warnings.some((w) => w.code === "files_outside_project") === false, "the record never ran, so no outside-file warning is invented"); + assert.ok(existsSync(join(elsewhere, "model.glb"))); + assert.equal(readdirSync(proj3).includes(".meshy.lock"), false, "no lock file appeared"); + } finally { + chmodSync(proj3, 0o755); + } + assert.equal((JSON.parse(readFileSync(join(proj3, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, "metadata untouched"); + } + } finally { + await api.close(); + } +}); + +test("R4-F02 preflight: a --project whose metadata.json is a symlink or damaged, a task JSON without an id, or a blank --stage is refused before any transfer — no request, nothing written; a healthy project still records normally", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const env = api.env({ MESHY_API_KEY: undefined }); + const initProject = async (name: string): Promise => { + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", name, "--workspace", workspace], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + return (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + }; + const fixture = join(workspace, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + const refused = (r: { code: number; stdout: string; stderr: string }, code: number, re: RegExp, label: string): void => { + assert.equal(r.code, code, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { ok: boolean; result: unknown; error: { code: string; message: string } }; + assert.equal(e.ok, false); + assert.match(e.error.message, re, label); + assert.equal(api.requests.length, 0, `${label}: nothing was requested`); + }; + + // metadata.json is a symlink before the run + const projA = await initProject("symlinked"); + const outside = join(dir, "outside.json"); + renameSync(join(projA, "metadata.json"), outside); + symlinkSync(outside, join(projA, "metadata.json")); + const a = await runCli(["download", "--task-json", fixture, "--all", "--project", projA, "--workspace", workspace], { env, cwd: dir }); + refused(a, 11, /metadata\.json is not a regular file \(a symbolic link\); nothing was downloaded/, "symlink"); + assert.ok(!existsSync(join(projA, "model.glb")), "symlink: nothing written"); + + // metadata.json is damaged before the run + const projB = await initProject("damaged"); + writeFileSync(join(projB, "metadata.json"), "{ not json"); + const b = await runCli(["download", "--task-json", fixture, "--all", "--project", projB, "--workspace", workspace], { env, cwd: dir }); + refused(b, 11, /not valid JSON.*\(nothing was downloaded\)/, "damaged"); + assert.ok(!existsSync(join(projB, "model.glb"))); + + // a task JSON without an id is not a task the CLI can record: refused as usage before the transfer + const projC = await initProject("healthy"); + const noId = join(workspace, "no-id.json"); + const body = taskBody({ model_urls: { glb: `${api.url}/model.glb` } }); + delete body["id"]; + writeFileSync(noId, JSON.stringify(body)); + const c = await runCli(["download", "--task-json", noId, "--all", "--project", projC, "--workspace", workspace], { env, cwd: dir }); + refused(c, 2, /does not contain a task/, "no id"); + assert.ok(!existsSync(join(projC, "model.glb"))); + + // blank --stage + const d = await runCli(["download", "--task-json", fixture, "--all", "--project", projC, "--stage", " ", "--workspace", workspace], { env, cwd: dir }); + refused(d, 2, /--stage must not be blank/, "blank stage"); + + // the healthy project records as before + const ok = await runCli(["download", "--task-json", fixture, "--all", "--project", projC, "--workspace", workspace], { env, cwd: dir }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + const e = parseSingleJson(ok.stdout) as { result: { project: { project_dir: string; action: string; stage: string; recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(e.result.project, { project_dir: e.result.project.project_dir, action: "added", stage: "preview", recorded_files: ["model.glb"] }); + assert.ok(!e.warnings.some((w) => w.code === "index_dirty" || w.code === "files_outside_project")); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R4-T01 — make reports the last step's identity, reconciled with the journal +// --------------------------------------------------------------------------- + +interface MakeResult { + task_id: string; + submission: { state: string; operation_id: string | null; task_id?: string }; + executed: Array<{ step: number; task_id: string; status: string | null; operation_id: string }>; + next: { wait: string }; + downloads: { state: string; files: Array<{ key: string; status: string }> }; +} + +test("R4-T01 make: two chain steps get different task ids; submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's, refine carries step 1's id as preview_task_id — asset 503 and SIGINT, legacy and v1, POST GET POST GET GET", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const mode of ["503", "sigint"] as const) { + const label = `${schema}/${mode}`; + let n = 0; + let child: ChildProcess | null = null; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: `${schema}-${mode}-step-${++n}` }); + if (req.path === "/asset.glb") { + if (mode === "503") return jsonReply(res, 503, { message: "asset host down" }); + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + try { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + } catch { + /* client gone */ + } + return; + } + if (req.method !== "GET") return jsonReply(res, 500, { message: "unexpected" }); + const id = req.path.split("/").at(-1)!; + return jsonReply(res, 200, taskBody({ id, model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const target = mode === "503" ? join(dir, "out") : join(dir, "out.glb"); + const r = await runCli(["make", "a fixture cactus", ...(schema === "v1" ? V1 : []), "-o", target], { env, cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, mode === "503" ? 7 : 130, `${label}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record & { result: MakeResult }; + const step1 = `${schema}-${mode}-step-1`; + const step2 = `${schema}-${mode}-step-2`; + const result = out.result; + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, label); + assert.equal(out["ok"], false); + const error = out["error"] as { code: string; http_status: number | null }; + assert.equal(error.code, mode === "503" ? "network" : "interrupted", label); + assert.equal(error.http_status, mode === "503" ? 503 : null); + } else { + assert.equal(out["code"], mode === "503" ? "network" : "interrupted", label); + if (mode === "503") assert.equal(out["status"], 503); + assert.equal(out["task_id"], step2, `${label}: the legacy top-level task_id is the last step's`); + } + assert.equal(result.task_id, step2, `${label}: the reported task is the last step, not the preview`); + assert.equal(result.submission.state, "accepted"); + assert.equal(result.submission.task_id, step2); + assert.deepEqual( + result.executed.map((e) => [e.step, e.task_id, e.status]), + [ + [1, step1, "SUCCEEDED"], + [2, step2, "SUCCEEDED"], + ], + label, + ); + const records = journal(configDir); + assert.equal(records.length, 2, `${label}: exactly two journal records — nothing was re-submitted`); + const rec1 = records.find((o) => o.task_id === step1)!; + const rec2 = records.find((o) => o.task_id === step2)!; + assert.ok(rec1 && rec2, `${label}: both steps are journaled`); + assert.equal(rec1.state, "accepted"); + assert.equal(rec2.state, "accepted"); + assert.notEqual(rec1.operation_id, rec2.operation_id); + assert.equal(result.submission.operation_id, rec2.operation_id, `${label}: submission.operation_id is the last accepted record's`); + assert.equal(result.executed.at(-1)!.operation_id, rec2.operation_id, `${label}: executed[-1].operation_id matches the journal`); + assert.equal(result.executed[0]!.operation_id, rec1.operation_id, `${label}: executed[0].operation_id is step 1's own record`); + if (schema === "legacy") assert.equal(out["operation_id"], rec2.operation_id, `${label}: the legacy top-level operation_id is the last step's`); + assert.match(result.next.wait, new RegExp(`wait ${step2}`), label); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "GET", "POST", "GET", "GET"], label); + const bodies = api.requests.map((q) => q.json as Record | undefined); + assert.equal(bodies[0]!["mode"], "preview"); + assert.equal(bodies[2]!["mode"], "refine"); + assert.equal(bodies[2]!["preview_task_id"], step1, `${label}: refine names step 1`); + assert.ok(api.requests[1]!.path.endsWith(`/${step1}`), `${label}: step 1 was polled by its own id`); + assert.ok(api.requests[3]!.path.endsWith(`/${step2}`), `${label}: step 2 was polled by its own id`); + assert.equal(api.requests[4]!.path, "/asset.glb"); + if (mode === "503") { + assert.deepEqual(result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]], label); + } else { + assert.equal(result.downloads.state, "failed", `${label}: the interrupted transfer wrote nothing, so the manifest says failed (not not_requested)`); + assert.deepEqual(result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]], label); + assert.ok(!existsSync(target), `${label}: no final file after the interrupt`); + } + } finally { + await api.close(); + } + } + } +}); diff --git a/tests/codex-review-round5.test.ts b/tests/codex-review-round5.test.ts new file mode 100644 index 0000000..07d2c88 --- /dev/null +++ b/tests/codex-review-round5.test.ts @@ -0,0 +1,532 @@ +/** + * Codex review round 5 (reviews/cli-s1-68690f9: R5-F01–R5-F03) as positive + * regressions with real subprocesses, a loopback API that records every request, + * synthetic credentials and isolated temp directories. The recovery command a + * project-record failure hands out keeps the original `--workspace` and is + * replayed *verbatim* (nothing appended) — a workspace equal to the project must + * still leave the parent's history index alone; one MTL reference used under + * several keys is reconciled across them, an ambiguous key included; and the + * task verbs put the project's location and metadata checks inside the recovery + * context (missing/damaged metadata → record_project with the task's journal + * identity and workspace; a project that left the workspace → no command that + * would cross the boundary). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import sharp from "sharp"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round5-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : []; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + if (!existsSync(ops)) return []; + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +/** Split a recovery command the way a POSIX shell would (bare words, single quotes with the '\'' escape). */ +function shellSplit(command: string): string[] { + const words: string[] = []; + let cur = ""; + let pending = false; + let inQuote = false; + let escape = false; + for (const ch of command) { + if (escape) { + cur += ch; + escape = false; + pending = true; + continue; + } + if (inQuote) { + if (ch === "'") inQuote = false; + else cur += ch; + pending = true; + continue; + } + if (ch === "'") { + inQuote = true; + pending = true; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (/\s/.test(ch)) { + if (pending) { + words.push(cur); + cur = ""; + pending = false; + } + continue; + } + cur += ch; + pending = true; + } + if (pending) words.push(cur); + return words; +} + +function optionValue(words: string[], flag: string): string | undefined { + const i = words.indexOf(flag); + return i === -1 ? undefined : words[i + 1]; +} + +interface RecordReplay { + code: number; + stdout: string; + stderr: string; +} + +/** + * Replay a `meshy project record …` recovery command exactly as handed out + * (only the launcher word is replaced by the test's dist entry) and check that + * it redid the record under the original boundary: the record lands, the + * parent's history index is untouched and reported as skipped, no lock or temp + * file appears outside, and no request is made. + */ +async function replayRecord( + command: string, + env: Record, + cwd: string, + expect: { projectDir: string; workspace: string; taskId: string; requestsBefore: number; requests: () => number }, +): Promise<{ replay: RecordReplay; words: string[]; entry: Record }> { + const words = shellSplit(command); + assert.equal(words[0], "meshy"); + assert.equal(words[1], "project"); + assert.equal(words[2], "record"); + assert.equal(realpathSync(optionValue(words, "--project")!), realpathSync(expect.projectDir), "the command names the same project"); + assert.equal(realpathSync(optionValue(words, "--workspace")!), realpathSync(expect.workspace), "the command carries the original --workspace, resolved"); + assert.equal(optionValue(words, "--task-id"), expect.taskId); + const parent = dirname(expect.projectDir); + const historyPath = join(parent, "history.json"); + const historyBefore = readFileSync(historyPath); + const parentBefore = listing(parent); + const replay = await runCli(words.slice(1), { env, cwd }); + assert.equal(replay.code, 0, `verbatim replay: ${replay.stderr}\n${replay.stdout}`); + const out = parseSingleJson(replay.stdout) as { ok: boolean; result: { action: string; entry: Record; index: { updated: boolean; error: string | null } }; warnings: Array<{ code: string }> }; + assert.equal(out.ok, true); + assert.equal(out.result.action, "added"); + assert.equal(out.result.index.updated, false, "workspace == project: the parent's index is not touched"); + assert.match(out.result.index.error ?? "", /outside --workspace/); + assert.ok(out.warnings.some((w) => w.code === "index_dirty"), "index_dirty says exactly that: metadata committed, history not updated"); + assert.ok(readFileSync(historyPath).equals(historyBefore), "the parent's history.json bytes are unchanged"); + assert.deepEqual(listing(parent).filter((n) => n.includes(".lock") || n.includes(".tmp-")), [], "no lock or temp file outside the boundary"); + assert.deepEqual(listing(parent), parentBefore, "nothing appeared in the parent directory"); + assert.equal(expect.requests(), expect.requestsBefore, "the recovery made no request"); + const meta = JSON.parse(readFileSync(join(expect.projectDir, "metadata.json"), "utf8")) as { tasks: Array> }; + assert.equal(meta.tasks.length, 1, "exactly one task recorded"); + assert.equal(meta.tasks[0]!["task_id"], expect.taskId); + return { replay, words, entry: meta.tasks[0]! }; +} + +// --------------------------------------------------------------------------- +// R5-F01 / E01 — the recovery command keeps the original write boundary +// --------------------------------------------------------------------------- + +test("E01/R5-F01 download --project P --workspace P (path with a space and a quote): the record_project command carries --workspace and, replayed verbatim after the repair, records the task with 0 requests, leaves the parent history.json bytes unchanged and reports index_dirty; without --workspace the command has none and the index is updated as before", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env({ MESHY_API_KEY: undefined }); + const fixture = join(dir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + + // (1) workspace == project, and the path needs shell quoting. + const awkward = join(dir, "work space's"); + mkdirSync(awkward); + const init = await runCli(["project", "init", "--root", join(awkward, "projects"), "--name", "recovery scope"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const parent = dirname(proj); + assert.ok(existsSync(join(parent, "history.json")), "the parent holds a history index the recovery must not touch"); + const meta = join(proj, "metadata.json"); + const outside = join(dir, "outside-metadata.json"); + writeFileSync(outside, readFileSync(meta)); + const outsideBefore = readFileSync(outside); + plant = () => { + renameSync(meta, `${meta}.backup`); + symlinkSync(outside, meta); + }; + const r = await runCli(["download", "--task-json", fixture, "--all", "--project", proj, "--workspace", proj], { env, cwd: dir }); + assert.equal(r.code, 11, `${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { error: { code: string; recovery: { action: string; command: string }; hint: string }; result: { project: { action: string; recovery: { command: string } }; downloads: { files: Array<{ path: string; sha256: string }> } } }; + assert.equal(e.error.code, "local_io"); + assert.equal(e.error.recovery.action, "record_project"); + assert.equal(e.error.hint, e.error.recovery.command); + assert.equal(e.result.project.action, "failed"); + assert.equal(e.result.project.recovery.command, e.error.recovery.command); + assert.match(e.error.recovery.command, /--workspace '.*work space'\\''s\/projects\/[^']+'$/, "the workspace is quoted for a shell and carries the quote character"); + assert.match(e.error.recovery.command, /--project '.*work space'\\''s\/projects\/[^']+' --task-id round5-task/, "the project path is quoted the same way"); + assert.ok(readFileSync(outside).equals(outsideBefore), "the outside metadata is untouched"); + assert.equal(sha(e.result.downloads.files[0]!.path), e.result.downloads.files[0]!.sha256); + const requestsAfterDownload = api.requests.length; + assert.equal(requestsAfterDownload, 1); + // Repair, then replay exactly the command that was handed out. + unlinkSync(meta); + renameSync(`${meta}.backup`, meta); + const { entry } = await replayRecord(e.error.recovery.command, env, dir, { projectDir: proj, workspace: proj, taskId: "round5-task", requestsBefore: requestsAfterDownload, requests: () => api.requests.length }); + assert.deepEqual([entry["stage"], entry["files"], entry["status"], entry["resource"]], ["preview", ["model.glb"], "SUCCEEDED", "text-to-3d"]); + + // (2) no explicit workspace: the command has none and the parent's index is refreshed, as it always was. + const init2 = await runCli(["project", "init", "--root", join(dir, "plain-projects"), "--name", "plain"], { env, cwd: dir }); + assert.equal(init2.code, 0, init2.stderr); + const proj2 = (parseSingleJson(init2.stdout) as { result: { project_dir: string } }).result.project_dir; + const meta2 = join(proj2, "metadata.json"); + plant = () => { + renameSync(meta2, `${meta2}.backup`); + symlinkSync(outside, meta2); + }; + const r2 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj2], { env, cwd: dir }); + assert.equal(r2.code, 11, `${r2.stderr}\n${r2.stdout}`); + const e2 = parseSingleJson(r2.stdout) as { error: { recovery: { command: string } } }; + const words2 = shellSplit(e2.error.recovery.command); + assert.equal(words2.includes("--workspace"), false, "no workspace was given, none is invented"); + unlinkSync(meta2); + renameSync(`${meta2}.backup`, meta2); + const historyBefore = readFileSync(join(dirname(proj2), "history.json")); + const rec2 = await runCli(words2.slice(1), { env, cwd: dir }); + assert.equal(rec2.code, 0, `${rec2.stderr}\n${rec2.stdout}`); + const o2 = parseSingleJson(rec2.stdout) as { result: { index: { updated: boolean } }; warnings: Array<{ code: string }> }; + assert.equal(o2.result.index.updated, true, "without a workspace the parent index is refreshed as before"); + assert.ok(!o2.warnings.some((w) => w.code === "index_dirty")); + assert.ok(!readFileSync(join(dirname(proj2), "history.json")).equals(historyBefore), "the parent history now lists the task"); + assert.equal(api.requests.length, 2, "one GET per download, nothing during either replay"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R5-F02 / E02 — one reference used under several keys is reconciled across them +// --------------------------------------------------------------------------- + +interface TextureMap { + line: number; + material: string | null; + reference: string; + resolved_to: string | null; + method: string; + candidates?: string[]; + note?: string; +} + +test("E02/R5-F02 map_Kd shared.png + map_Bump shared.png with one base color and two normals: neither line is rewritten in either order (MTL byte-identical), both are ambiguous with their own candidates and one cross-key note; digests match disk", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const lines = ["map_Kd shared.png", "map_Bump shared.png"] as const; + for (const order of [ + [0, 1], + [1, 0], + ] as const) { + const mtl = `newmtl a\n${lines[order[0]]}\n${lines[order[1]]}\n`; + const label = `${lines[order[0]]} first`; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib original.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (/\.png$/.test(req.path)) { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "mixed.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/original.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png`, normal: `${host.url}/n1.png` }, { normal: `${host.url}/n2.png` }] }))); + const out = join(dir, "mixed"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${label}: ${r.stderr}\n${r.stdout}`); + assert.equal(readFileSync(join(out, "model.mtl"), "utf8"), mtl, `${label}: the MTL is byte-identical`); + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string; files: Array<{ key: string; path: string; sha256: string; bytes: number; relinked: boolean }>; material_links: { status: string; rewritten: string[]; texture_maps: TextureMap[] } } }; warnings: Array<{ code: string; message: string }> }; + const dl = env.result.downloads; + assert.equal(dl.state, "completed"); + assert.equal(dl.material_links.status, "incomplete"); + const candidatesByLine = { "map_Kd shared.png": ["texture_0_base_color.png"], "map_Bump shared.png": ["texture_0_normal.png", "texture_1_normal.png"] } as const; + assert.deepEqual( + dl.material_links.texture_maps.map((l) => [l.line, l.candidates]), + [ + [2, [...candidatesByLine[lines[order[0]]]]], + [3, [...candidatesByLine[lines[order[1]]]]], + ], + `${label}: each line carries the candidate set of its own key (line 2 = ${lines[order[0]]}, line 3 = ${lines[order[1]]})`, + ); + assert.equal(dl.material_links.texture_maps.length, 2); + for (const l of dl.material_links.texture_maps) { + assert.equal(l.reference, "shared.png"); + assert.equal(l.resolved_to, null, `${label}: nothing rewritten`); + assert.equal(l.method, "ambiguous"); + assert.match(l.note ?? "", /'shared\.png' is used by map_Kd and map_Bump|'shared\.png' is used by map_Bump and map_Kd/, `${label}: the note names both keys`); + assert.match(l.note ?? "", /map_Kd would make it texture_0_base_color\.png \(channel_of_key\)/); + assert.match(l.note ?? "", /map_Bump could only make it texture_0_normal\.png or texture_1_normal\.png/); + assert.match(l.note ?? "", /one reference names one file/); + } + assert.deepEqual(dl.material_links.rewritten.map((p) => basename(p)), ["model.obj"], `${label}: only the OBJ's mtllib changed`); + for (const f of dl.files) { + assert.equal(sha(f.path), f.sha256, `${label}: ${f.key} digest matches disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + assert.equal(dl.files.find((f) => f.key === "model.mtl")!.relinked, false); + const ambiguous = env.warnings.filter((w) => w.code === "material_reference_ambiguous"); + assert.equal(ambiguous.length, 1, `${label}: one warning, said once`); + assert.equal(ambiguous[0]!.message.split("one reference names one file").length, 2); + assert.deepEqual(host.requests.map((q) => q.method), ["GET", "GET", "GET", "GET", "GET"]); + } finally { + await host.close(); + } + } +}); + +test("R5-F02 arbitration matrix: eight key/reference combinations in both orders — identity keeps its texture, ambiguous rivals block heuristics, one reference is reconciled across keys (two hits, hit + ambiguity, same identity, same fallback), distinct channels stay independent", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const B = { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }; + const N = { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "n1.png" }; + const N2 = { key: "texture.1.normal", name: "texture_1_normal.png", sourceName: "n2.png" }; + type Tex = { key: string; name: string; sourceName: string }; + const cases: Array<{ id: string; lines: [string, string]; textures: Tex[]; expected: [Array, Array]; note?: RegExp }> = [ + { id: "identity-plus-heuristic", lines: ["map_Kd body.png", "map_Kd eyes_diffuse.png"], textures: [{ ...B, sourceName: "body.png" }], expected: [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]], note: /compete for texture_0_base_color\.png/ }, + { id: "ambiguous-plus-heuristic", lines: ["map_Kd texture_0_base_color.png", "map_Kd eyes_diffuse.png"], textures: [B], expected: [[null, "ambiguous"], [null, "ambiguous"]] }, + { id: "same-ref-two-hits", lines: ["map_Kd shared.png", "map_Bump shared.png"], textures: [B, N], expected: [[null, "ambiguous"], [null, "ambiguous"]], note: /one reference names one file/ }, + { id: "same-ref-hit-and-ambiguous", lines: ["map_Kd shared.png", "map_Bump shared.png"], textures: [B, N, N2], expected: [[null, "ambiguous"], [null, "ambiguous"]], note: /one reference names one file/ }, + { id: "same-ref-same-identity", lines: ["map_Kd a.png", "map_Bump a.png"], textures: [B, N], expected: [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] }, + { id: "same-ref-same-fallback", lines: ["map_Bump unknown_normal.png", "norm unknown_normal.png"], textures: [B, N], expected: [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] }, + { id: "distinct-channel-fallbacks", lines: ["map_Kd skin.png", "map_Bump other_normal.png"], textures: [B, N], expected: [["texture_0_base_color.png", "channel_of_key"], ["texture_0_normal.png", "channel_in_name"]] }, + { id: "identity-with-ambiguous-source-rival", lines: ["map_Kd a.png", "map_Bump shared.png"], textures: [B, N, N2], expected: [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] }, + ]; + for (const c of cases) { + for (const reversed of [false, true]) { + const label = `${c.id}${reversed ? " (reversed)" : ""}`; + const order = reversed ? [1, 0] : [0, 1]; + const dir = tmpDir(); + const mtl = `newmtl fixture\n${order.map((i) => c.lines[i]!).join("\n")}\n`; + writeFileSync(join(dir, "model.obj"), "mtllib old.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), mtl); + for (const t of c.textures) writeFileSync(join(dir, t.name), png); + const report = (await relinkMaterials([ + { key: "model.obj", path: join(dir, "model.obj"), sourceName: "source.obj" }, + { key: "model.mtl", path: join(dir, "model.mtl"), sourceName: "old.mtl" }, + ...c.textures.map((t) => ({ key: t.key, path: join(dir, t.name), sourceName: t.sourceName })), + ]))!; + const expected = order.map((i) => c.expected[i]!); + assert.deepEqual(report.texture_maps.map((l) => [l.resolved_to, l.method]), expected, label); + if (expected.every((x) => x[0] === null)) assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl, `${label}: nothing rewritten`); + if (c.note) for (const l of report.texture_maps.filter((l) => l.method === "ambiguous")) assert.match(l.note ?? "", c.note, label); + assert.equal(report.status, expected.some((x) => x[0] === null) ? "incomplete" : "complete", label); + if (c.id === "same-ref-hit-and-ambiguous") { + const sets = report.texture_maps.map((l) => (l.candidates ?? []).join(",")).sort(); + assert.deepEqual(sets, ["texture_0_base_color.png", "texture_0_normal.png,texture_1_normal.png"], `${label}: each line keeps its own candidate set`); + assert.equal(report.warnings.length, 1, `${label}: the cross-key reason is one warning`); + } + } + } +}); + +// --------------------------------------------------------------------------- +// R5-F03 / E03 — project location and metadata checks inside the recovery context +// --------------------------------------------------------------------------- + +type Verb = "get" | "wait" | "stream" | "create-async" | "create-sync"; +type Fault = "missing" | "damaged"; + +test("E03/R5-F03 legacy/v1 × get/wait/stream/create-async/create-sync × metadata missing/damaged during the request (--project P --workspace P): exit 11 with the task, journal operation and a record_project command that carries --workspace and --operation-id; single POST for create; verbatim replay after the repair records the task with 0 requests and index_dirty", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const verb of ["get", "wait", "stream", "create-async", "create-sync"] as Verb[]) { + for (const fault of ["missing", "damaged"] as Fault[]) { + const id = `${schema}-${verb}-${fault}`; + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: id }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + const body = taskBody({ id }); + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + return void res.end(`data: ${JSON.stringify(body)}\n\n`); + } + return jsonReply(res, 200, body); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const init = await runCli(["project", "init", "--root", join(dir, "projects"), "--name", id], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const meta = join(proj, "metadata.json"); + const original = readFileSync(meta); + plant = fault === "missing" ? () => renameSync(meta, `${meta}.backup`) : () => writeFileSync(meta, "{ invalid json"); + const args = verb.startsWith("create") ? ["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", ...(verb === "create-async" ? ["--async"] : [])] : ["text-to-3d", verb, id]; + const r = await runCli([...args, ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", proj], { env, cwd: dir }); + assert.equal(r.code, 11, `${id}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { state: string; operation_id: string | null; task_id?: string } }; + const isCreate = verb.startsWith("create"); + const records = journal(configDir); + let command: string; + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, id); + const error = out["error"] as { code: string; message: string; recovery: { action: string; automatic: boolean; command: string } | null; hint?: string }; + assert.equal(error.code, "local_io", id); + assert.ok(error.recovery, `${id}: a recovery is offered`); + assert.equal(error.recovery!.action, "record_project", id); + assert.equal(error.hint, error.recovery!.command, id); + assert.match(error.message, fault === "missing" ? /has no metadata\.json any more/ : /not valid JSON/, id); + command = error.recovery!.command; + } else { + assert.equal(out["code"], "local_io", id); + assert.equal(out["task_id"], id, `${id}: the legacy payload names the task`); + assert.ok(String(out["hint"]).startsWith("meshy project record "), `${id}: the legacy hint is the record command: ${String(out["hint"])}`); + command = String(out["hint"]); + } + assert.equal(result.task_id, id, `${id}: the task is kept`); + assert.equal(result.submission.state, "accepted"); + const words = shellSplit(command); + assert.equal(optionValue(words, "--stage"), "preview", id); + assert.equal(optionValue(words, "--resource"), "text-to-3d", id); + if (isCreate) { + assert.equal(records.length, 1, `${id}: exactly one journal record — nothing re-submitted`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(records[0]!.task_id, id); + assert.equal(result.submission.operation_id, records[0]!.operation_id, `${id}: submission names the journal record`); + if (schema === "legacy") assert.equal(out["operation_id"], records[0]!.operation_id, id); + assert.equal(optionValue(words, "--operation-id"), records[0]!.operation_id, `${id}: the recovery keeps the operation id`); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, id); + } else { + assert.equal(records.length, 0, id); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 0, id); + } + // Exact request sequence for every entry: the fixture is SUCCEEDED on the first GET, so no extra poll is tolerated. + const base = "/openapi/v2/text-to-3d"; + const expectedRequests: Array<[string, string]> = + verb === "get" || verb === "wait" ? [["GET", `${base}/${id}`]] : verb === "stream" ? [["GET", `${base}/${id}/stream`]] : verb === "create-async" ? [["POST", base]] : [["POST", base], ["GET", `${base}/${id}`]]; + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), expectedRequests, `${id}: exact method/path sequence`); + // A damaged metadata still lets the snapshot land; a missing one does not — and the command says which. + const taskJson = optionValue(words, "--task-json"); + if (fault === "missing" || verb === "create-async") assert.equal(taskJson, undefined, `${id}: no snapshot was written`); + else { + assert.equal(taskJson, `task_${id}.json`, id); + assert.ok(existsSync(join(proj, taskJson!)), `${id}: the snapshot exists in the project`); + } + // Repair the project, then replay exactly the command handed out. + if (fault === "missing") renameSync(`${meta}.backup`, meta); + else writeFileSync(meta, original); + const before = api.requests.length; + const { entry } = await replayRecord(command, env, dir, { projectDir: proj, workspace: proj, taskId: id, requestsBefore: before, requests: () => api.requests.length }); + assert.equal(entry["stage"], "preview", id); + assert.equal(entry["resource"], "text-to-3d", id); + if (isCreate) assert.equal(entry["operation_id"], records[0]!.operation_id, `${id}: the journal operation is recorded`); + if (taskJson) assert.equal(entry["task_json"], taskJson, id); + } finally { + await api.close(); + } + } + } + } +}); + +test("R5-F03 a project that leaves the workspace during the request (replaced by a symlink to an outside directory) is refused without any record command: exit 11, task and journal kept, nothing written outside, recovery null", async () => { + let plant: (() => void) | null = null; + let taskId = ""; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: taskId }); + return jsonReply(res, 200, taskBody({ id: taskId })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + for (const schema of ["legacy", "v1"] as const) { + taskId = `escaped-${schema}`; + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", `escape-${schema}`], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const outside = join(dir, `outside-${schema}`); + // The project passes the preflight, then leaves the workspace while the POST is in flight. + plant = () => { + renameSync(proj, outside); + symlinkSync(outside, proj); + }; + api.requests.length = 0; + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", "--async", ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", workspace], { env, cwd: dir }); + assert.equal(r.code, 11, `${schema}: ${r.stderr}\n${r.stdout}`); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, `${schema}: the POST happened (the preflight had passed)`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { operation_id: string | null } }; + assert.equal(result.task_id, taskId, `${schema}: the accepted task is named`); + const records = journal(configDir).filter((j) => j.task_id === taskId); + assert.equal(records.length, 1, `${schema}: journaled exactly once`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(result.submission.operation_id, records[0]!.operation_id); + const message = schema === "v1" ? (out["error"] as { message: string }).message : String(out["message"]); + assert.match(message, /no longer a target inside the authorised boundary/, schema); + assert.match(message, /symbolic link|outside the authorised root/, schema); + assert.match(message, /nothing was recorded/, schema); + assert.match(message, new RegExp(`operation ${records[0]!.operation_id}`), `${schema}: the journal operation is named`); + const hint = schema === "v1" ? (out["error"] as { hint?: string }).hint : (out["hint"] as string | undefined); + assert.ok(!(hint ?? "").startsWith("meshy project record"), `${schema}: no record command that would cross the boundary`); + if (schema === "v1") assert.equal((out["error"] as { recovery: unknown }).recovery, null, "no recovery that drops the workspace"); + else assert.equal(out["task_id"], taskId); + assert.deepEqual(listing(outside), ["metadata.json"], `${schema}: nothing was written through the symlink (no snapshot, no lock, no temp)`); + assert.equal((JSON.parse(readFileSync(join(outside, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, `${schema}: the escaped metadata was not written`); + } + } finally { + await api.close(); + } +}); diff --git a/tests/codex-review-round6.test.ts b/tests/codex-review-round6.test.ts new file mode 100644 index 0000000..49066c4 --- /dev/null +++ b/tests/codex-review-round6.test.ts @@ -0,0 +1,313 @@ +/** + * Codex review round 6 (reviews/cli-s1-7b7c24c: R6-F01, R6-F02) as positive + * regressions with real subprocesses, a loopback API that records every request, + * synthetic credentials and isolated temp directories. The write boundary is + * frozen when a command starts — the real path and the directory identity of + * the --workspace (or of the project directory when no workspace is given) — + * and every later check proves that this very directory is still there and + * that the target resolves inside it. While a request is in flight the project + * directory, its parent, the workspace itself or the alias the workspace was + * given through is replaced by a symlink to an outside directory holding a + * valid project: the download command and every task verb refuse to record, + * keep the completed manifest / accepted task, write nothing outside (bytes of + * the outside metadata.json and history.json compared whole), and offer no + * command that would write across the boundary. Stable aliases keep working. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, join, relative } from "node:path"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round6-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : []; +} + +/** Every file under `dir` (relative POSIX paths → sha256), so "nothing changed outside" is a whole-tree statement. */ +function treeDigest(dir: string): Record { + const out: Record = {}; + const walk = (d: string): void => { + for (const name of readdirSync(d, { withFileTypes: true })) { + const p = join(d, name.name); + if (name.isDirectory()) walk(p); + else if (name.isFile()) out[relative(dir, p).split("\\").join("/")] = sha(p); + else out[relative(dir, p).split("\\").join("/")] = `<${name.isSymbolicLink() ? "symlink" : "special"}>`; + } + }; + walk(dir); + return out; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + if (!existsSync(ops)) return []; + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +interface ProjectFailure { + project_dir: string; + action: string; + stage: string; + recorded_files: string[]; + error: { code: string; message: string }; + recovery: unknown; +} + +// --------------------------------------------------------------------------- +// R6-F01 — the download command re-validates the project against the frozen boundary +// --------------------------------------------------------------------------- + +test("R6-F01 download --project P --workspace W --output-dir W/assets: when P (leaf) or W/projects (parent) is replaced by a symlink to an outside project during the asset GET — task-json and API sources — the record is refused: exit 11, completed manifest kept, project.failed with recovery null, outside tree byte-identical, exact requests; a healthy run still records", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + if (req.path.startsWith("/openapi/")) return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/model.glb` } })); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const fixture = join(dir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + for (const source of ["task-json", "api"] as const) { + for (const target of ["leaf", "parent"] as const) { + const label = `${source}/${target}`; + const workspace = join(dir, `ws-${source}-${target}`); + mkdirSync(workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", "escape"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + // A valid project tree outside the workspace, mirroring what the symlink will point at. + const outside = join(dir, `outside-${source}-${target}`); + cpSync(target === "leaf" ? proj : join(workspace, "projects"), outside, { recursive: true }); + const outsideBefore = treeDigest(outside); + const replaced = target === "leaf" ? proj : join(workspace, "projects"); + plant = () => { + renameSync(replaced, `${replaced}.moved`); + symlinkSync(outside, replaced); + }; + const assets = join(workspace, "assets"); + api.requests.length = 0; + const args = source === "task-json" ? ["download", "--task-json", fixture] : ["download", "--resource", "text-to-3d", "--task-id", "round6-task"]; + const r = await runCli([...args, "--all", "--project", proj, "--workspace", workspace, "--output-dir", assets], { env, cwd: dir }); + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { ok: boolean; result: { source: { task_id: string }; downloads: { state: string; files: Array<{ key: string; path: string; status: string; bytes: number; sha256: string }> }; project: ProjectFailure }; error: { code: string; message: string; recovery: unknown; hint?: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(Object.keys(e).sort(), ENVELOPE_KEYS, label); + assert.equal(e.ok, false); + assert.equal(e.error.code, "local_io", label); + assert.match(e.error.message, /no longer a target inside the authorised boundary/, label); + assert.match(e.error.message, target === "leaf" ? /symbolic link/ : /outside the authorised root/, label); + assert.match(e.error.message, /nothing was recorded/, label); + assert.equal(e.error.recovery, null, `${label}: no command that would write across the boundary`); + assert.ok(!(e.error.hint ?? "").startsWith("meshy project record"), label); + assert.equal(e.result.source.task_id, "round6-task"); + assert.equal(e.result.downloads.state, "completed", `${label}: the transfer itself completed`); + assert.deepEqual(e.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"]]); + const model = e.result.downloads.files[0]!; + assert.ok(existsSync(model.path) && readFileSync(model.path).equals(glb()), `${label}: the asset is on disk and not rolled back`); + assert.equal(sha(model.path), model.sha256); + assert.equal(readFileSync(model.path).length, model.bytes); + assert.equal(realpathSync(model.path), realpathSync(join(assets, "model.glb"))); + assert.equal(e.result.project.action, "failed", label); + assert.deepEqual(e.result.project.recorded_files, []); + assert.equal(e.result.project.error.code, "local_io"); + assert.equal(e.result.project.recovery, null, label); + assert.ok(!e.warnings.some((w) => w.code === "index_dirty"), `${label}: a boundary refusal is not disguised as index_dirty`); + assert.deepEqual(treeDigest(outside), outsideBefore, `${label}: the outside tree is byte-identical (metadata.json, history.json), no snapshot/lock/temp`); + assert.deepEqual( + api.requests.map((q) => [q.method, q.path]), + source === "task-json" ? [["GET", "/model.glb"]] : [["GET", "/openapi/v2/text-to-3d/round6-task"], ["GET", "/model.glb"]], + `${label}: exact requests, no retry, no POST`, + ); + // The moved original project was not written either. + const original = target === "leaf" ? `${proj}.moved` : join(`${replaced}.moved`, basename(proj)); + assert.equal((JSON.parse(readFileSync(join(original, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, `${label}: the original project is untouched`); + } + } + // Healthy control: nothing planted — the record lands, assets outside the project are reported as such. + const workspace = join(dir, "ws-healthy"); + mkdirSync(workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", "healthy"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + api.requests.length = 0; + const ok = await runCli(["download", "--task-json", fixture, "--all", "--project", proj, "--workspace", workspace, "--output-dir", join(workspace, "assets")], { env, cwd: dir }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + const o = parseSingleJson(ok.stdout) as { result: { project: { action: string; recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.equal(o.result.project.action, "added"); + assert.deepEqual(o.result.project.recorded_files, []); + assert.ok(o.warnings.some((w) => w.code === "files_outside_project")); + assert.equal((JSON.parse(readFileSync(join(proj, "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string }> }).tasks[0]!.task_id, "round6-task"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R6-F02 — the authorised root is frozen before the first request +// --------------------------------------------------------------------------- + +type Verb = "get" | "wait" | "stream" | "create-async" | "create-sync"; +type Swap = "directory" | "alias"; + +test("R6-F02 legacy/v1 × get/wait/stream/create-async/create-sync × workspace replaced (the directory itself swapped for a symlink to an outside copy, or the alias the workspace was given through re-pointed) while the request is in flight: exit 11, task and accepted journal kept, single POST, exact method/path, outside tree byte-identical, no snapshot/lock/temp, no cross-boundary record command", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const verb of ["get", "wait", "stream", "create-async", "create-sync"] as Verb[]) { + for (const swap of ["directory", "alias"] as Swap[]) { + const id = `${schema}-${verb}-${swap}`; + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: id }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + const body = taskBody({ id }); + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + return void res.end(`data: ${JSON.stringify(body)}\n\n`); + } + return jsonReply(res, 200, body); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const real = join(dir, "real-workspace"); + mkdirSync(real); + // "directory": the workspace is a plain directory that gets swapped for a symlink. + // "alias": the workspace is given through a symlink that keeps pointing at `real` … until it is re-pointed. + const workspace = swap === "directory" ? real : join(dir, "workspace-alias"); + if (swap === "alias") symlinkSync(real, workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", id], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const outside = join(dir, "outside"); + cpSync(real, outside, { recursive: true }); + const outsideBefore = treeDigest(outside); + const realBefore = treeDigest(real); + plant = + swap === "directory" + ? () => { + renameSync(real, `${real}.moved`); + symlinkSync(outside, real); + } + : () => { + unlinkSync(workspace); + symlinkSync(outside, workspace); + }; + const args = verb.startsWith("create") ? ["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", ...(verb === "create-async" ? ["--async"] : [])] : ["text-to-3d", verb, id]; + const r = await runCli([...args, ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", workspace], { env, cwd: dir }); + assert.equal(r.code, 11, `${id}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { state: string; operation_id: string | null } }; + assert.equal(result.task_id, id, `${id}: the task is kept`); + assert.equal(result.submission.state, "accepted"); + const message = schema === "v1" ? (out["error"] as { message: string }).message : String(out["message"]); + assert.match(message, /no longer a target inside the authorised boundary/, id); + assert.match(message, /changed since the command started|outside the authorised root|symbolic link/, id); + assert.match(message, /nothing was recorded/, id); + const hint = schema === "v1" ? (out["error"] as { hint?: string }).hint : (out["hint"] as string | undefined); + assert.ok(!(hint ?? "").startsWith("meshy project record"), `${id}: no record command that would cross the boundary`); + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, id); + assert.equal((out["error"] as { code: string }).code, "local_io", id); + assert.equal((out["error"] as { recovery: unknown }).recovery, null, `${id}: recovery null`); + } else { + assert.equal(out["code"], "local_io", id); + assert.equal(out["task_id"], id, id); + } + const records = journal(configDir); + const isCreate = verb.startsWith("create"); + if (isCreate) { + assert.equal(records.length, 1, `${id}: exactly one accepted journal record — nothing re-submitted`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(records[0]!.task_id, id); + assert.equal(result.submission.operation_id, records[0]!.operation_id, id); + if (schema === "legacy") assert.equal(out["operation_id"], records[0]!.operation_id, id); + } else { + assert.equal(records.length, 0, id); + } + const base = "/openapi/v2/text-to-3d"; + const expected: Array<[string, string]> = + verb === "get" || verb === "wait" ? [["GET", `${base}/${id}`]] : verb === "stream" ? [["GET", `${base}/${id}/stream`]] : verb === "create-async" ? [["POST", base]] : [["POST", base], ["GET", `${base}/${id}`]]; + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), expected, `${id}: exact request sequence`); + assert.deepEqual(treeDigest(outside), outsideBefore, `${id}: the outside tree is byte-identical (no metadata/history change, no snapshot/lock/temp)`); + const original = swap === "directory" ? `${real}.moved` : real; + assert.deepEqual(treeDigest(original), realBefore, `${id}: the original workspace tree is untouched too`); + } finally { + await api.close(); + } + } + } + } +}); + +test("R6-F02 stable aliases keep working: a workspace given through a symlink that stays put, and a project inside a symlinked parent, record normally (snapshot in the real project, index refreshed)", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "alias-ok" }); + return jsonReply(res, 200, taskBody({ id: "alias-ok" })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const real = join(dir, "real"); + mkdirSync(real); + const alias = join(dir, "alias"); + symlinkSync(real, alias); + const init = await runCli(["project", "init", "--root", join(alias, "projects"), "--name", "alias-ok"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(proj.startsWith(alias)); + const r = await runCli(["text-to-3d", "get", "alias-ok", ...V1, "--project", proj, "--workspace", alias], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const o = parseSingleJson(r.stdout) as { result: { project: { action: string; snapshot: string | null; index: { updated: boolean } } } }; + assert.equal(o.result.project.action, "added"); + assert.equal(o.result.project.index.updated, true); + assert.ok(o.result.project.snapshot && existsSync(o.result.project.snapshot)); + assert.equal(realpathSync(o.result.project.snapshot!), join(realpathSync(real), "projects", basename(proj), "task_alias-ok.json")); + const meta = JSON.parse(readFileSync(join(real, "projects", basename(proj), "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string; task_json: string | null }> }; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.task_json]), [["alias-ok", "task_alias-ok.json"]]); + assert.deepEqual(listing(join(real, "projects")).includes("history.json"), true); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/openapi/v2/text-to-3d/alias-ok"]]); + } finally { + await api.close(); + } +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..1034102 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,513 @@ +/** + * doctor — read-only diagnosis (T-102) and the two opt-in probes (T-103). + * runDoctor is exercised with injected env / cwd / probe / detector; the + * command is driven in-process with a loopback mock for --check-api. The + * default run must never call fetch, the probe or the detector, and no secret + * value may appear anywhere in the report. + */ + +process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MeshyApiError } from "../src/client/errors.js"; +import { TransportError } from "../src/client/transport.js"; +import { apiCheckFailure, buildDoctorCommand } from "../src/cmd/doctor.js"; +import { resetCommandContextForTests } from "../src/internal/context.js"; +import { runDoctor, runDoctorDetailed, type DoctorReport } from "../src/internal/doctor.js"; +import { authRequiredError, CliError, UsageError } from "../src/internal/errors.js"; +import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, walkCommands } from "../src/internal/global-options.js"; +import type { V1Envelope } from "../src/internal/result.js"; +import type { GlobalFlags } from "../src/internal/runtime.js"; +import { VERSION } from "../src/internal/version.js"; +import { jsonReply, parseSingleJson, startMockApi } from "./helpers/cli.js"; + +const FLAG_SECRET = "msy_flag_secret_value_789"; +const ENV_SECRET = "msy_env_secret_value_456"; +const DOTENV_SECRET = "msy_dotenv_secret_value_123"; +const FILE_SECRET = "msy_keyfile_secret_value_321"; + +function flagsOf(extra: Partial = {}): GlobalFlags { + return { format: "json", updateCheck: false, verbose: false, ...extra }; +} + +function tmp(prefix = "meshy-doctor-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +/** A private env for the local checks: nothing from the developer's shell leaks in. */ +function isolatedEnv(extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CONFIG_DIR: tmp("meshy-config-"), ...extra }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + return env; +} + +function check(report: DoctorReport, id: string): { id: string; status: string; detail: string } { + const c = report.checks.find((x) => x.id === id); + assert.ok(c, `check '${id}' missing; have ${report.checks.map((x) => x.id).join(", ")}`); + return c; +} + +function spy(impl: () => T): (() => T) & { calls: number } { + const fn = (() => { + fn.calls += 1; + return impl(); + }) as (() => T) & { calls: number }; + fn.calls = 0; + return fn; +} + +/** Fail loudly if anything reaches the network while `fn` runs. */ +async function withNoNetwork(fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("network touched during a local doctor run"); + }) as typeof fetch; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +/** loadConfig reads process.env like every API command; scope the overrides to one test. */ +async function withProcessEnv(overrides: Record, fn: () => Promise): Promise { + const saved: Record = {}; + for (const [k, v] of Object.entries(overrides)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + return await fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +// --------------------------------------------------------------------------- +// runDoctor +// --------------------------------------------------------------------------- + +test("T-102 default run: local only — no probe, no detector, no fetch; booleans instead of values; .env named, never read", async () => { + const cwd = tmp("meshy-cwd-"); + writeFileSync(join(cwd, ".env"), `MESHY_API_KEY=${DOTENV_SECRET}\nNODE_OPTIONS=--require ./evil.js\n`); + const env = isolatedEnv({ MESHY_API_KEY: ENV_SECRET }); + const probeBalance = spy(() => Promise.resolve(42)); + const detectSlicers = spy(() => ({ platform: "test", slicers: [], unsupported: [] })); + + const report = await withNoNetwork(() => + runDoctor({ flags: flagsOf({ apiKey: FLAG_SECRET }), checkApi: false, checkSlicers: false, env, cwd, probeBalance, detectSlicers }), + ); + + assert.equal(probeBalance.calls, 0); + assert.equal(detectSlicers.calls, 0); + assert.deepEqual(report.cli, { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }); + assert.equal(report.local_ready, true); + assert.equal(report.api_ready, null); + assert.equal(report.api, null); + assert.equal(report.slicers, null); + assert.deepEqual(report.credential_sources, { + flag: true, + env: true, + api_key_file: null, + stored_profile: { path: join(env["MESHY_CONFIG_DIR"]!, "credentials.json"), exists: false }, + }); + assert.deepEqual(report.cwd_env_candidates, [".env"]); + assert.match(check(report, "cwd_env_files").detail, /\.env found .* not read/); + assert.deepEqual(report.base_urls, { + v1: "https://api.meshy.ai/openapi/v1", + v2: "https://api.meshy.ai/openapi/v2", + creative_lab: "https://api.meshy.ai/openapi/creative-lab", + public_web: "https://api.meshy.ai/web/public", + }); + assert.deepEqual(report.workspace, { path: null, writable: null }); + for (const [id, status] of [["cli", "ok"], ["node", "ok"], ["credentials", "ok"], ["api_key_file", "skipped"], ["workspace", "skipped"], ["api", "skipped"], ["slicers", "skipped"]]) { + assert.equal(check(report, id!).status, status, id); + } + const text = JSON.stringify(report); + for (const secret of [FLAG_SECRET, ENV_SECRET, DOTENV_SECRET]) { + assert.ok(!text.includes(secret), `secret ${secret} leaked into the report`); + } + assert.ok(!text.includes("evil.js"), "the .env content was read"); +}); + +test("T-102 no credential anywhere: local_ready stays true, credentials check warns, nothing throws", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv({ MESHY_API_KEY: "" }); + const report = await withNoNetwork(() => runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: false, env, cwd })); + assert.equal(report.local_ready, true); + assert.equal(report.api_ready, null); + assert.equal(report.credential_sources.env, false, "an empty MESHY_API_KEY means unset"); + assert.equal(report.credential_sources.flag, false); + assert.equal(check(report, "credentials").status, "warn"); + assert.match(check(report, "credentials").detail, /meshy auth login/); + assert.deepEqual(report.cwd_env_candidates, []); + // A placeholder key is also "unset". + const placeholder = await runDoctor({ flags: flagsOf({ apiKey: "YOUR_MESHY_API_KEY_HERE" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(placeholder.credential_sources.flag, false); +}); + +test("stored profile: the credentials file is stat'ed, never parsed", async () => { + const dir = tmp(); + const credFile = join(dir, "credentials.json"); + // Deliberately not JSON: parsing it would throw; doctor must only report existence. + writeFileSync(credFile, `{ this is not json; token=msy_stored_secret_value_000 `); + const env = isolatedEnv({ MESHY_CREDENTIALS_PATH: credFile }); + const report = await runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: false, env, cwd: dir }); + assert.deepEqual(report.credential_sources.stored_profile, { path: credFile, exists: true }); + assert.equal(check(report, "credentials").status, "ok"); + assert.ok(!JSON.stringify(report).includes("msy_stored_secret_value_000")); + // Non-production v1 base → the dev file is the one that applies. + const staging = await runDoctor({ flags: flagsOf({ baseUrlV1: "https://staging.example.invalid/openapi/v1/" }), checkApi: false, checkSlicers: false, env: isolatedEnv(), cwd: dir }); + assert.match(staging.credential_sources.stored_profile.path, /credentials\.dev\.json$/); + assert.equal(staging.credential_sources.stored_profile.exists, false); +}); + +test("--api-key-file: valid → ok without the value; malformed, missing, keyless → fail checks, never a throw", async () => { + const cwd = tmp("meshy-cwd-"); + writeFileSync(join(cwd, "good.env"), `# ci key\nexport MESHY_API_KEY="${FILE_SECRET}"\nOTHER=1\n`); + writeFileSync(join(cwd, "bad.env"), "this line is not an assignment\n"); + writeFileSync(join(cwd, "nokey.env"), "OTHER=1\n"); + writeFileSync(join(cwd, "dup.env"), "MESHY_API_KEY=a1234\nMESHY_API_KEY=b1234\n"); + const env = isolatedEnv(); + + const good = await runDoctor({ flags: flagsOf({ envFile: "good.env" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(check(good, "api_key_file").status, "ok"); + assert.match(check(good, "api_key_file").detail, /value not shown/); + assert.match(check(good, "api_key_file").detail, /1 other key\(s\) ignored: OTHER/); + assert.equal(good.credential_sources.api_key_file, join(cwd, "good.env")); + assert.equal(check(good, "credentials").status, "ok"); + assert.ok(!JSON.stringify(good).includes(FILE_SECRET), "the key file value leaked"); + + for (const [name, pattern] of [["bad.env", /not a KEY=value assignment/], ["nokey.env", /no usable MESHY_API_KEY/], ["dup.env", /more than once/], ["missing.env", /file not found/]] as const) { + const report = await runDoctor({ flags: flagsOf({ envFile: name }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(check(report, "api_key_file").status, "fail", name); + assert.match(check(report, "api_key_file").detail, pattern, name); + assert.equal(report.credential_sources.api_key_file, join(cwd, name), name); + assert.equal(check(report, "credentials").status, "warn", `${name}: an unusable key file is not a credential source`); + assert.equal(report.local_ready, true, name); + } +}); + +test("workspace: writable directory ok, missing directory warns, a file fails", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv(); + mkdirSync(join(cwd, "ws")); + writeFileSync(join(cwd, "afile"), "x"); + const ok = await runDoctor({ flags: flagsOf({ workspace: "ws" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(ok.workspace, { path: join(cwd, "ws"), writable: true }); + assert.equal(check(ok, "workspace").status, "ok"); + const missing = await runDoctor({ flags: flagsOf({ workspace: join(cwd, "later") }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(missing.workspace, { path: join(cwd, "later"), writable: null }); + assert.equal(check(missing, "workspace").status, "warn"); + const file = await runDoctor({ flags: flagsOf({ workspace: "afile" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(file.workspace, { path: join(cwd, "afile"), writable: false }); + assert.equal(check(file, "workspace").status, "fail"); + if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() !== 0) { + mkdirSync(join(cwd, "ro")); + chmodSync(join(cwd, "ro"), 0o500); + const ro = await runDoctor({ flags: flagsOf({ workspace: "ro" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(ro.workspace.writable, false); + assert.equal(check(ro, "workspace").status, "fail"); + } +}); + +test("base URLs follow flag → env → default, strip trailing slashes and derive Creative Lab only from the standard path", async () => { + const cwd = tmp("meshy-cwd-"); + const fromEnv = await runDoctor({ + flags: flagsOf(), + checkApi: false, + checkSlicers: false, + env: isolatedEnv({ MESHY_BASE_URL_V1: "https://staging.example.invalid/openapi/v1/", MESHY_BASE_URL_V2: "https://staging.example.invalid/openapi/v2" }), + cwd, + }); + assert.deepEqual(fromEnv.base_urls, { + v1: "https://staging.example.invalid/openapi/v1", + v2: "https://staging.example.invalid/openapi/v2", + creative_lab: "https://staging.example.invalid/openapi/creative-lab", + public_web: "https://staging.example.invalid/web/public", + }); + assert.equal(check(fromEnv, "base_urls").status, "ok"); + const custom = await runDoctor({ + flags: flagsOf({ baseUrlV1: "https://proxy.example.invalid/meshy" }), + checkApi: false, + checkSlicers: false, + env: isolatedEnv({ MESHY_BASE_URL_V1: "https://ignored.example.invalid/openapi/v1" }), + cwd, + }); + assert.equal(custom.base_urls.v1, "https://proxy.example.invalid/meshy", "the flag wins over the env"); + assert.equal(custom.base_urls.creative_lab, null); + assert.equal(check(custom, "base_urls").status, "warn"); + assert.match(check(custom, "base_urls").detail, /--base-url-creative-lab/); + const explicit = await runDoctor({ + flags: flagsOf({ baseUrlV1: "https://proxy.example.invalid/meshy", baseUrlCreativeLab: "https://lab.example.invalid/cl/" }), + checkApi: false, + checkSlicers: false, + env: isolatedEnv(), + cwd, + }); + assert.equal(explicit.base_urls.creative_lab, "https://lab.example.invalid/cl"); + assert.equal(check(explicit, "base_urls").status, "ok"); +}); + +test("T-103 --check-api: the probe runs exactly once, api_ready true, balance recorded, detector untouched", async () => { + const cwd = tmp("meshy-cwd-"); + const probeBalance = spy(() => Promise.resolve(42)); + const detectSlicers = spy(() => ({ platform: "test", slicers: [], unsupported: [] })); + // The flag key resolves through loadConfig without touching process.env or any file. + const report = await runDoctor({ flags: flagsOf({ apiKey: "msy_fixture_probe_key_555" }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd, probeBalance, detectSlicers }); + assert.equal(probeBalance.calls, 1); + assert.equal(detectSlicers.calls, 0); + assert.equal(report.api_ready, true); + assert.deepEqual(report.api, { balance: 42 }); + assert.equal(report.slicers, null); + assert.equal(check(report, "api").status, "ok"); + assert.match(check(report, "api").detail, /GET .*\/balance succeeded with the flag credential; balance 42/); + assert.equal(check(report, "slicers").status, "skipped"); + assert.ok(!JSON.stringify(report).includes("msy_fixture_probe_key_555")); +}); + +test("T-103 --check-api without any credential: api_ready false, fail check, probe never called, report still returned", async () => { + const dir = tmp(); + const probeBalance = spy(() => Promise.resolve(1)); + await withProcessEnv({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: join(dir, "absent.json"), MESHY_BASE_URL_V1: undefined }, async () => { + const { report, apiFailure } = await runDoctorDetailed({ flags: flagsOf(), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd: dir, probeBalance }); + assert.equal(probeBalance.calls, 0); + assert.equal(report.api_ready, false); + assert.equal(report.local_ready, true); + assert.deepEqual(Object.keys(report.api ?? {}), ["error"]); + assert.equal(check(report, "api").status, "fail"); + assert.match(check(report, "api").detail, /no usable credential/); + assert.equal(apiFailure?.stage, "credentials"); + // An unusable explicit key file is also "no credential", not a thrown error. + writeFileSync(join(dir, "bad.env"), "nonsense\n"); + const bad = await runDoctorDetailed({ flags: flagsOf({ envFile: join(dir, "bad.env") }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd: dir, probeBalance }); + assert.equal(bad.report.api_ready, false); + assert.equal(bad.apiFailure?.stage, "credentials"); + assert.equal(check(bad.report, "api_key_file").status, "fail"); + assert.equal(probeBalance.calls, 0); + }); +}); + +test("T-103 --check-api: a rejected or unreachable balance call is recorded, and the command maps it to exit 3 / 7", async () => { + const cwd = tmp("meshy-cwd-"); + const rejected = new MeshyApiError({ message: "meshy api 401 on /balance: invalid api key", status: 401, code: "auth", path: "/balance", credentialKind: "api_key" }); + const failing = spy(() => Promise.reject(rejected)); + const { report, apiFailure } = await runDoctorDetailed({ flags: flagsOf({ apiKey: "msy_fixture_probe_key_777" }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd, probeBalance: failing }); + assert.equal(failing.calls, 1, "exactly one attempt, no retry"); + assert.equal(report.api_ready, false); + assert.deepEqual(report.api, { error: "meshy api 401 on /balance: invalid api key" }); + assert.equal(check(report, "api").status, "fail"); + assert.equal(apiFailure?.stage, "balance"); + + const auth = apiCheckFailure(apiFailure!, { ...report }); + assert.equal(auth.code, "auth"); + assert.equal(auth.exitCode, 3); + assert.equal(auth.httpStatus, 401); + assert.equal((auth.result as unknown as DoctorReport).api_ready, false); + assert.match(auth.hint ?? "", /Credential rejected/); + + const network = apiCheckFailure( + { stage: "balance", error: new TransportError({ message: "request to /balance failed: connect ECONNREFUSED", phase: "connect", path: "/balance" }) }, + { ...report }, + ); + assert.equal(network.code, "network"); + assert.equal(network.exitCode, 7); + + const missing = apiCheckFailure({ stage: "credentials", error: authRequiredError() }, { ...report }); + assert.equal(missing.code, "auth"); + assert.equal(missing.exitCode, 3); + assert.match(missing.hint ?? "", /meshy auth login/); + const badFile = apiCheckFailure({ stage: "credentials", error: new CliError({ code: "usage", message: "--api-key-file: file not found: x.env" }) }, { ...report }); + assert.equal(badFile.code, "auth", "a credential that cannot be resolved is an auth failure for --check-api"); + assert.equal(badFile.exitCode, 3); + + const server = apiCheckFailure({ stage: "balance", error: new MeshyApiError({ message: "meshy api 500 on /balance: boom", status: 500, code: "server", path: "/balance" }) }, { ...report }); + assert.equal(server.code, "server"); + assert.equal(server.exitCode, 1); +}); + +test("T-103 --check-slicers: the injected detector runs once with the env; no probe, no network", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv(); + const detection = { + platform: "darwin", + slicers: [{ id: "orca", name: "OrcaSlicer", path: "/Applications/OrcaSlicer.app", multicolor: true, platform: "darwin" }], + unsupported: [{ id: "ideamaker", name: "ideaMaker", reason: "unsupported_on_platform" }], + }; + let seenEnv: NodeJS.ProcessEnv | undefined; + let calls = 0; + const detectSlicers = (e?: { env?: Record }): unknown => { + calls += 1; + seenEnv = e?.env; + return detection; + }; + const probeBalance = spy(() => Promise.resolve(1)); + const report = await withNoNetwork(() => runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: true, env, cwd, detectSlicers, probeBalance })); + assert.equal(calls, 1); + assert.equal(seenEnv, env); + assert.equal(probeBalance.calls, 0); + assert.equal(report.api_ready, null); + assert.equal(report.api, null); + assert.deepEqual(report.slicers, detection); + assert.equal(check(report, "slicers").status, "ok"); + assert.match(check(report, "slicers").detail, /1 slicer\(s\) detected on darwin: OrcaSlicer/); + assert.equal(check(report, "api").status, "skipped"); + + const broken = await runDoctor({ + flags: flagsOf(), + checkApi: false, + checkSlicers: true, + env, + cwd, + detectSlicers: () => { + throw new Error("registry unreadable"); + }, + }); + assert.equal(check(broken, "slicers").status, "fail"); + assert.deepEqual(broken.slicers, { error: "registry unreadable" }); +}); + +// --------------------------------------------------------------------------- +// The command, in-process +// --------------------------------------------------------------------------- + +interface InProcessRun { + stdout: string; + error: unknown; +} + +function buildTree(cmd: Command): Command { + const root = new Command("meshy"); + registerRootGlobalOptions(root); + root.addCommand(cmd); + mirrorGlobalOptionsToDescendants(root); + walkCommands(root, (c) => { + c.exitOverride(); + c.configureOutput({ writeErr: () => undefined, writeOut: () => undefined }); + }); + return root; +} + +async function runDoctorCommand(args: string[]): Promise { + const chunks: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + // The node test runner reports to its parent over this same stdout with + // binary frames; only the CLI's string writes belong to the capture. + if (typeof chunk !== "string") { + return (original as (c: string | Uint8Array, e?: unknown, cb?: unknown) => boolean).call(process.stdout, chunk, encodingOrCb, cb); + } + chunks.push(chunk); + const callback = typeof encodingOrCb === "function" ? encodingOrCb : typeof cb === "function" ? cb : undefined; + if (callback) (callback as () => void)(); + return true; + }) as typeof process.stdout.write; + let error: unknown = null; + try { + await buildTree(buildDoctorCommand()).parseAsync(["node", "meshy", "doctor", ...args]); + } catch (err) { + error = err; + } finally { + process.stdout.write = original; + resetCommandContextForTests(); + } + return { stdout: chunks.join(""), error }; +} + +type DoctorResult = DoctorReport & { saved_json: { path: string; bytes: number } | null }; + +test("T-102 command: the default run emits one ok envelope, exit 0 semantics, and touches no network", async () => { + const run = await withNoNetwork(() => runDoctorCommand(["--output-schema", "v1"])); + assert.equal(run.error, null, String(run.error)); + const env = parseSingleJson(run.stdout) as V1Envelope; + assert.deepEqual(Object.keys(env), ["schema_version", "command", "ok", "result", "error", "warnings"]); + assert.equal(env.command, "doctor"); + assert.equal(env.ok, true); + assert.equal(env.result!.api_ready, null); + assert.equal(env.result!.local_ready, true); + assert.equal(env.result!.saved_json, null); + assert.equal(env.result!.cli.version, VERSION); + // Warnings and failed local checks never change the exit: the run still succeeds. + const missingWs = await withNoNetwork(() => runDoctorCommand(["--workspace", join(tmp(), "nope")])); + assert.equal(missingWs.error, null); + assert.equal((parseSingleJson(missingWs.stdout) as V1Envelope).ok, true); +}); + +test("command: --save-json stores the report (not the envelope); legacy schema and -o are usage errors", async () => { + const dir = tmp(); + const target = join(dir, "doctor.json"); + const run = await runDoctorCommand(["--save-json", target]); + assert.equal(run.error, null, String(run.error)); + const env = parseSingleJson(run.stdout) as V1Envelope; + assert.equal(env.result!.saved_json?.path, realpathSync(target)); + const saved = JSON.parse(readFileSync(target, "utf8")) as Record; + assert.equal(saved["local_ready"], true); + assert.ok(!("schema_version" in saved)); + assert.ok(!("saved_json" in saved)); + const legacy = await runDoctorCommand(["--output-schema", "legacy"]); + assert.ok(legacy.error instanceof UsageError); + const output = await runDoctorCommand(["-o", "x.json"]); + assert.ok(output.error instanceof UsageError); + assert.match((output.error as Error).message, /--save-json/); +}); + +test("T-103 command --check-api: one GET /balance with the flag key; 401 → exit 3; unreachable → exit 7; no credential → exit 3 and no request", async () => { + let status = 200; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/balance") return jsonReply(res, status, status === 200 ? { balance: 42 } : { message: "invalid api key" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const common = ["--check-api", "--base-url-v1", `${api.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only"]; + const ok = await runDoctorCommand(common); + assert.equal(ok.error, null, String(ok.error)); + const env = parseSingleJson(ok.stdout) as V1Envelope; + assert.equal(env.ok, true); + assert.equal(env.result!.api_ready, true); + assert.deepEqual(env.result!.api, { balance: 42 }); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + assert.equal(api.requests[0]!.path, "/openapi/v1/balance"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + assert.ok(!ok.stdout.includes("msy_fixture_key_loopback_only")); + + status = 401; + const rejected = await runDoctorCommand(common); + assert.ok(rejected.error instanceof CliError, String(rejected.error)); + assert.equal(rejected.error.code, "auth"); + assert.equal(rejected.error.exitCode, 3); + assert.equal((rejected.error.result as unknown as DoctorResult).api_ready, false); + assert.equal(api.requests.length, 2, "no retry"); + assert.equal(rejected.stdout, ""); + + await withProcessEnv({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: join(tmp(), "absent.json"), MESHY_BASE_URL_V1: undefined }, async () => { + const none = await runDoctorCommand(["--check-api"]); + assert.ok(none.error instanceof CliError, String(none.error)); + assert.equal(none.error.code, "auth"); + assert.equal(none.error.exitCode, 3); + const result = none.error.result as unknown as DoctorResult; + assert.equal(result.api_ready, false); + assert.equal(result.local_ready, true); + assert.equal(result.checks.find((c) => c.id === "api")?.status, "fail"); + }); + assert.equal(api.requests.length, 2, "no credential → no request"); + } finally { + await api.close(); + } + const dead = await startMockApi(() => undefined); + await dead.close(); + const unreachable = await runDoctorCommand(["--check-api", "--base-url-v1", `${dead.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only"]); + assert.ok(unreachable.error instanceof CliError, String(unreachable.error)); + assert.equal(unreachable.error.code, "network"); + assert.equal(unreachable.error.exitCode, 7); + assert.equal((unreachable.error.result as unknown as DoctorResult).api_ready, false); +}); diff --git a/tests/download-command.test.ts b/tests/download-command.test.ts new file mode 100644 index 0000000..fd4e69e --- /dev/null +++ b/tests/download-command.test.ts @@ -0,0 +1,316 @@ +/** + * `meshy download` and the safe downloader (T-061, T-064..T-071): black-box + * subprocesses against a loopback asset host plus in-process checks of the + * fetch boundaries. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fetchToTemp, validateAssetUrl } from "../src/internal/download.js"; +import { CliError } from "../src/internal/errors.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +function glb(payload = "x"): Buffer { + const head = Buffer.alloc(12); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(12 + payload.length, 8); + return Buffer.concat([head, Buffer.from(payload)]); +} + +function sha(buf: Buffer): string { + return createHash("sha256").update(buf).digest("hex"); +} + +/** Asset host: serves fixed bodies by path, records requests. */ +async function assetHost(bodies: Record }>): Promise { + return startMockApi((req, res) => { + const entry = bodies[req.path]; + if (!entry) { + res.writeHead(404, { "content-type": "text/html" }); + res.end("missing"); + return; + } + res.writeHead(entry.status ?? 200, { "content-type": entry.type, ...(entry.headers ?? {}) }); + res.end(entry.body); + }); +} + +function rigTaskJson(host: string): Record { + return { + id: "rig-1", + type: "rig", + status: "SUCCEEDED", + progress: 100, + result: { + rigged_character_glb_url: `${host}/rigged.glb?X-Amz-Signature=secret`, + basic_animations: { walking_glb_url: `${host}/walk.glb?sig=s`, running_glb_url: `${host}/run.glb?sig=s` }, + }, + }; +} + +test("T-061/T-069 download by asset key: no Authorization, signed query never printed, sha256 recorded, dependencies for OBJ", async () => { + const bodies = { + "/rigged.glb": { body: glb("rigged"), type: "model/gltf-binary" }, + "/walk.glb": { body: glb("walk"), type: "model/gltf-binary" }, + "/run.glb": { body: glb("run"), type: "model/gltf-binary" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const taskJson = join(dir, "rig.json"); + writeFileSync(taskJson, JSON.stringify(rigTaskJson(host.url))); + const out = join(dir, "walking.glb"); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "result.basic_animations.walking_glb_url", "--output", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string; files: Array<{ key: string; path: string; bytes: number; sha256: string; status: string }> }; selection: { selected: string[] } } }; + assert.equal(env.result.downloads.state, "completed"); + assert.deepEqual(env.result.selection.selected, ["result.basic_animations.walking_glb_url"]); + const f = env.result.downloads.files[0]!; + assert.equal(f.status, "written"); + assert.equal(f.sha256, sha(bodies["/walk.glb"].body)); + assert.ok(readFileSync(out).equals(bodies["/walk.glb"].body)); + assert.ok(!r.stdout.includes("X-Amz-Signature") && !r.stdout.includes("sig=s"), "signed query stays out of stdout"); + assert.equal(host.requests.length, 1); + assert.equal(host.requests[0]!.headers["authorization"], undefined); + assert.equal(host.requests[0]!.headers["cookie"], undefined); + + // --all into a directory downloads exactly the enumerated set. + host.requests.length = 0; + const all = await runCli(["download", "--task-json", taskJson, "--all", "--output-dir", join(dir, "all")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(all.code, 0, all.stderr); + assert.deepEqual(readdirSync(join(dir, "all")).sort(), ["rigged_character.glb", "running_glb.glb", "walking_glb.glb"]); + assert.deepEqual(host.requests.map((q) => q.path).sort(), ["/rigged.glb", "/run.glb", "/walk.glb"]); + + // Several assets + --output (single file) is refused before any request. + host.requests.length = 0; + const multi = await runCli(["download", "--task-json", taskJson, "--all", "--output", join(dir, "one.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(multi.code, 2, multi.stderr); + assert.equal(host.requests.length, 0); + // No selector with several assets: usage error listing candidates, no request. + const none = await runCli(["download", "--task-json", taskJson, "--output-dir", join(dir, "x")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(none.code, 2); + const cands = parseSingleJson(none.stdout) as { result: { assets: Array<{ key: string }> } }; + assert.equal(cands.result.assets.length, 3); + assert.equal(host.requests.length, 0); + // --list never downloads. + const list = await runCli(["download", "--task-json", taskJson, "--list"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(list.code, 0, list.stderr); + assert.equal(host.requests.length, 0); + } finally { + await host.close(); + } +}); + +test("T-062 OBJ selection pulls MTL + textures by default; --geometry-only fetches the OBJ alone", async () => { + const bodies = { + "/m.obj": { body: "mtllib model.mtl\nv 0 0 0\n", type: "model/obj" }, + "/m.mtl": { body: "newmtl a\nmap_Kd texture_0_base_color.png\n", type: "text/plain" }, + "/bc.png": { body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]), type: "image/png" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "text-to-3d-refine", status: "SUCCEEDED", model_urls: { obj: `${host.url}/m.obj`, mtl: `${host.url}/m.mtl`, glb: `${host.url}/nope.glb` }, texture_urls: [{ base_color: `${host.url}/bc.png` }] })); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--output-dir", join(dir, "obj")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(readdirSync(join(dir, "obj")).sort(), ["model.mtl", "model.obj", "texture_0_base_color.png"]); + const env = parseSingleJson(r.stdout) as { result: { selection: { dependencies: string[] } } }; + assert.deepEqual(env.result.selection.dependencies, ["model.mtl", "texture.0.base_color"]); + host.requests.length = 0; + const geo = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--geometry-only", "--output-dir", join(dir, "geo")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(geo.code, 0, geo.stderr); + assert.deepEqual(readdirSync(join(dir, "geo")), ["model.obj"]); + assert.equal(host.requests.length, 1); + const warn = parseSingleJson(geo.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(warn.warnings.some((w) => w.code === "geometry_only")); + } finally { + await host.close(); + } +}); + +test("T-064/T-067 final path after MIME correction is protected; --overwrite replaces atomically, never directories", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + const host = await assetHost({ "/img": { body: png, type: "image/png" } }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "i", type: "text-to-image", status: "SUCCEEDED", image_urls: [`${host.url}/img`] })); + // Requested .bin, server says png and png is not transcodable from bin → saved as .png; a pre-existing .png must survive. + const existing = join(dir, "out", "shot.png"); + mkdirSync(join(dir, "out")); + writeFileSync(existing, "keep me"); + const r = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "out", "shot.bin")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 11, r.stderr); + assert.equal(readFileSync(existing, "utf8"), "keep me"); + assert.ok(!existsSync(join(dir, "out", "shot.bin"))); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { downloads: { state: string; files: Array<{ status: string }> } } }; + assert.equal(env.error.code, "local_io"); + assert.equal(env.result.downloads.state, "failed"); + // --overwrite replaces the file. + const ow = await runCli(["download", "--task-json", taskJson, "--output", existing, "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ow.code, 0, ow.stderr); + assert.ok(readFileSync(existing).equals(png)); + // Never a directory. + const asDir = join(dir, "adir.png"); + mkdirSync(asDir); + const bad = await runCli(["download", "--task-json", taskJson, "--output", asDir, "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(bad.code, 11, bad.stderr); + assert.ok(statSync(asDir).isDirectory()); + } finally { + await host.close(); + } +}); + +test("T-068 traversal and symlink escapes are refused; --workspace confines outputs", async () => { + const host = await assetHost({ "/m.glb": { body: glb(), type: "model/gltf-binary" } }); + try { + const dir = tmpDir(); + const outside = tmpDir("outside-"); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${host.url}/m.glb` } })); + const escape = await runCli(["download", "--task-json", taskJson, "--output", join(outside, "m.glb"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(escape.code, 11, escape.stderr); + assert.ok(!existsSync(join(outside, "m.glb"))); + // Symlinked output directory pointing outside the workspace. + symlinkSync(outside, join(dir, "link")); + const viaLink = await runCli(["download", "--task-json", taskJson, "--output-dir", join(dir, "link"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(readdirSync(outside), []); + // Symlink as the leaf. + writeFileSync(join(outside, "victim.glb"), "victim"); + symlinkSync(join(outside, "victim.glb"), join(dir, "leaf.glb")); + const leaf = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "leaf.glb"), "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(leaf.code, 11, leaf.stderr); + assert.equal(readFileSync(join(outside, "victim.glb"), "utf8"), "victim"); + assert.equal(host.requests.length, 0, "nothing was fetched for refused targets"); + // Inside the workspace works. + const ok = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "sub", "m.glb"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + } finally { + await host.close(); + } +}); + +test("T-069 asset boundaries: private redirect refused, oversized body fails, HTML page is not a GLB, bad magic fails", async () => { + const bodies = { + "/redirect": { body: "", type: "text/plain", status: 302, headers: { location: "http://10.0.0.5/secret.glb" } }, + "/big.glb": { body: glb("x".repeat(5000)), type: "model/gltf-binary" }, + "/page.glb": { body: "expired", type: "text/html" }, + "/fake.glb": { body: "not a glb at all", type: "model/gltf-binary" }, + "/ok.glb": { body: glb("fine"), type: "model/gltf-binary" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const target = join(dir, "x.glb"); + await assert.rejects(fetchToTemp(`${host.url}/redirect`, target), (e: unknown) => e instanceof CliError && /private network/.test(e.message)); + await assert.rejects(fetchToTemp(`${host.url}/big.glb`, target, { limits: { maxBytes: 1000 } }), (e: unknown) => e instanceof CliError && /limit/.test(e.message)); + assert.deepEqual(readdirSync(dir), [], "no temp files left behind"); + assert.throws(() => validateAssetUrl("http://192.168.1.4/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /plain http|private/); + assert.throws(() => validateAssetUrl("https://user:pw@assets.example/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /credentials/); + assert.throws(() => validateAssetUrl("ftp://assets.example/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /http\(s\)/); + validateAssetUrl("https://assets.meshy.ai/x.glb?Expires=1", { allowHttpLoopback: true, allowPrivateNetwork: false }); + + const write = (name: string, url: string) => { + const p = join(dir, name); + writeFileSync(p, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: url } })); + return p; + }; + const page = await runCli(["download", "--task-json", write("page.json", `${host.url}/page.glb`), "--output", join(dir, "page.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(page.code, 4, page.stderr); + assert.ok(!existsSync(join(dir, "page.glb"))); + const fake = await runCli(["download", "--task-json", write("fake.json", `${host.url}/fake.glb`), "--output", join(dir, "fake.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(fake.code, 4, fake.stderr); + assert.ok(!existsSync(join(dir, "fake.glb"))); + const ok = await runCli(["download", "--task-json", write("ok.json", `${host.url}/ok.glb`), "--output", join(dir, "ok.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + } finally { + await host.close(); + } +}); + +test("T-066 a failure after some files were written reports partial state and leaves earlier files intact", async () => { + const host = await assetHost({ "/a.glb": { body: glb("a"), type: "model/gltf-binary" } }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${host.url}/a.glb`, fbx: `${host.url}/missing.fbx` } })); + const r = await runCli(["download", "--task-json", taskJson, "--all", "--output-dir", join(dir, "out")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 5, r.stderr); + const env = parseSingleJson(r.stdout) as { ok: boolean; result: { downloads: { state: string; files: Array<{ key: string; status: string }> } } }; + assert.equal(env.ok, false); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"], ["model.fbx", "failed"]]); + assert.deepEqual(readdirSync(join(dir, "out")), ["model.glb"], "no temp or partial files remain"); + } finally { + await host.close(); + } +}); + +test("T-070 expired URL: task-json source explains it cannot refresh; API source re-gets once and never creates a task", async () => { + let denyFirst = true; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/image-to-3d/t1") { + const fresh = !denyFirst; + return jsonReply(res, 200, { id: "t1", type: "image-to-3d", status: "SUCCEEDED", progress: 100, model_urls: { glb: `${api.url}/asset.glb?v=${fresh ? "fresh" : "stale"}` } }); + } + if (req.path === "/asset.glb") { + const v = new URL(req.url, "http://x").searchParams.get("v"); + if (v === "stale") return jsonReply(res, 403, { message: "expired" }); + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb("fresh")); + return; + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + // API source: first asset GET is denied (stale URL), one re-get refreshes, then success. Never a POST. + const r = await runCli(["download", "--resource", "image-to-3d", "--task-id", "t1", "--model-format", "glb", "--output", join(dir, "m.glb")], { env: api.env(), cwd: dir }); + denyFirst = false; + // The mock flips after the first task GET; emulate by inspecting requests. + assert.equal(api.requests.filter((q) => q.method === "POST").length, 0); + const taskGets = api.requests.filter((q) => q.path === "/openapi/v1/image-to-3d/t1").length; + assert.ok(taskGets >= 1 && taskGets <= 2, `task GETs: ${taskGets}`); + if (r.code === 0) { + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(env.warnings.some((w) => w.code === "asset_url_refreshed") || taskGets === 1); + } else { + // Deterministic path when the refresh also returned the stale URL: a clear error, no task creation. + assert.equal(r.code, 4, r.stderr); + } + // task-json source: no refresh possible → error with the get hint, no API call at all. + api.requests.length = 0; + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t1", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${api.url}/asset.glb?v=stale` } })); + const stale = await runCli(["download", "--task-json", taskJson, "--model-format", "glb", "--output", join(dir, "stale.glb")], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(stale.code, 4, stale.stderr); + const env = parseSingleJson(stale.stdout) as { error: { message: string; recovery: { action: string; command: string } } }; + assert.match(env.error.message, /cannot refresh/); + assert.match(env.error.recovery.command, /image-to-3d get t1 --save-json/); + assert.deepEqual(api.requests.map((q) => q.path), ["/asset.glb"]); + } finally { + await api.close(); + } +}); + +test("T-071 report-only task: the printability report is written as JSON; not-ready tasks report not_ready", async () => { + const dir = tmpDir(); + const report = join(dir, "r.json"); + writeFileSync(report, JSON.stringify({ id: "a1", type: "print-analyze", status: "SUCCEEDED", printability: { status: "warning", issue_count: 1 } })); + const r = await runCli(["download", "--task-json", report, "--output", join(dir, "printability.json")], { cwd: dir }); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(JSON.parse(readFileSync(join(dir, "printability.json"), "utf8")), { status: "warning", issue_count: 1 }); + const pending = join(dir, "p.json"); + writeFileSync(pending, JSON.stringify({ id: "p1", type: "image-to-3d", status: "IN_PROGRESS", progress: 20 })); + const nr = await runCli(["download", "--task-json", pending, "--all", "--output-dir", join(dir, "x")], { cwd: dir }); + assert.equal(nr.code, 0, nr.stderr); + const env = parseSingleJson(nr.stdout) as { result: { downloads: { state: string } }; warnings: Array<{ code: string }> }; + assert.equal(env.result.downloads.state, "not_ready"); + assert.equal(env.warnings[0]?.code, "task_not_ready"); +}); diff --git a/tests/env-file.test.ts b/tests/env-file.test.ts new file mode 100644 index 0000000..61c640b --- /dev/null +++ b/tests/env-file.test.ts @@ -0,0 +1,81 @@ +/** + * --api-key-file parsing: only MESHY_API_KEY is read, nothing is evaluated, and a + * broken explicit file is an error rather than a fall-through (T-100, T-101). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadEnvFile, parseEnvFile } from "../src/internal/env-file.js"; +import { CliError } from "../src/internal/errors.js"; + +test("parseEnvFile — plain, quoted, export-prefixed and commented lines", () => { + const text = [ + "# leading comment", + "", + "OTHER=ignored value", + 'export MESHY_API_KEY="msy_quoted_key" # trailing comment', + "NODE_OPTIONS=--require ./evil.js", + "PATH=/tmp/evil:$PATH", + ].join("\n"); + const out = parseEnvFile(text); + assert.equal(out.apiKey, "msy_quoted_key"); + assert.deepEqual(out.otherKeys, ["OTHER", "NODE_OPTIONS", "PATH"]); + // Nothing leaked into the process. + assert.notEqual(process.env["NODE_OPTIONS"], "--require ./evil.js"); +}); + +test("parseEnvFile — unquoted value stops at a comment, single quotes keep # and $", () => { + assert.equal(parseEnvFile("MESHY_API_KEY=msy_a # c").apiKey, "msy_a"); + assert.equal(parseEnvFile("MESHY_API_KEY='msy_b#$'").apiKey, "msy_b#$"); + assert.equal(parseEnvFile("MESHY_API_KEY=\r\nOTHER=1\r\n").apiKey, ""); +}); + +test("parseEnvFile — CRLF line endings and Windows-style files parse", () => { + assert.equal(parseEnvFile("MESHY_API_KEY=msy_crlf\r\nX=1\r\n").apiKey, "msy_crlf"); +}); + +test("parseEnvFile — duplicate MESHY_API_KEY is an error", () => { + assert.throws( + () => parseEnvFile("MESHY_API_KEY=a\nMESHY_API_KEY=b"), + (err: unknown) => err instanceof CliError && err.code === "usage" && /more than once/.test(err.message), + ); +}); + +test("parseEnvFile — shell expansion syntax in the key is refused, not expanded", () => { + for (const bad of ["MESHY_API_KEY=${HOME}", "MESHY_API_KEY=`whoami`", "MESHY_API_KEY=$(id)", "MESHY_API_KEY=$FOO"]) { + assert.throws(() => parseEnvFile(bad), (err: unknown) => err instanceof CliError && err.code === "auth", bad); + } +}); + +test("parseEnvFile — malformed lines are errors, not silently skipped", () => { + assert.throws(() => parseEnvFile("this is not an assignment"), /not a KEY=value assignment/); + assert.throws(() => parseEnvFile('MESHY_API_KEY="unterminated'), /unterminated/); +}); + +test("parseEnvFile — file without the key returns apiKey null", () => { + const out = parseEnvFile("A=1\nB=2\n"); + assert.equal(out.apiKey, null); + assert.deepEqual(out.otherKeys, ["A", "B"]); +}); + +test("loadEnvFile — missing file, directory and oversized file are usage errors", () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-envfile-")); + assert.throws(() => loadEnvFile(join(dir, "nope.env")), (e: unknown) => e instanceof CliError && e.code === "usage"); + const sub = join(dir, "adir"); + mkdirSync(sub); + assert.throws(() => loadEnvFile(sub), /not a regular file/); + const big = join(dir, "big.env"); + writeFileSync(big, `X=${"a".repeat(70 * 1024)}\n`); + assert.throws(() => loadEnvFile(big), /larger than/); +}); + +test("loadEnvFile — relative paths resolve against the given cwd", () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-envfile-")); + writeFileSync(join(dir, ".env"), "MESHY_API_KEY=msy_rel\n"); + const out = loadEnvFile(".env", dir); + assert.equal(out.apiKey, "msy_rel"); + assert.equal(out.path, join(dir, ".env")); +}); diff --git a/tests/file-input.test.ts b/tests/file-input.test.ts index b361412..d23c9c2 100644 --- a/tests/file-input.test.ts +++ b/tests/file-input.test.ts @@ -162,11 +162,13 @@ test("resolveImageFields — retexture's multi-view list resolves local paths to } }); -test("resolveImageFields — data: URIs rejected with helpful message", async () => { - await assert.rejects( - () => resolveImageFields({ imageUrl: "data:image/png;base64,iVBORw0KGgo=" }), - /data: URIs aren't accepted on the command line/, - ); +test("resolveImageFields — well-formed data: URIs pass through; malformed ones are refused", async () => { + const opts = { imageUrl: "data:image/png;base64,iVBORw0KGgo=" }; + await resolveImageFields(opts); + assert.equal(opts.imageUrl, "data:image/png;base64,iVBORw0KGgo="); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:image/png,notbase64" }), /must be base64/); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:model/gltf-binary;base64,Z2xURg==" }), /expected an image/); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:garbage" }), /malformed data: URI/); }); test("resolveImageFields — fields that aren't image inputs are ignored", async () => { @@ -259,9 +261,43 @@ test("resolveModelFields — URL preflighted, passed through on 2xx", async () = } }); -test("resolveModelFields — data: URIs rejected", async () => { - await assert.rejects( - () => resolveModelFields({ modelUrl: "data:model/gltf-binary;base64,Z2xURg==" }), - /data: URIs aren't accepted on the command line/, +test("resolveModelFields — model data: URIs pass through; image data: URIs are refused", async () => { + const opts = { modelUrl: "data:model/gltf-binary;base64,Z2xURg==" }; + await resolveModelFields(opts); + assert.equal(opts.modelUrl, "data:model/gltf-binary;base64,Z2xURg=="); + await assert.rejects(() => resolveModelFields({ modelUrl: "data:image/png;base64,iVBORw0KGgo=" }), /expected a 3D-model/); +}); + +test("normalizeMediaPayload — only declared fields are touched; size cap and format lists are enforced (T-029, T-030)", async () => { + const { normalizeMediaPayload } = await import("../src/internal/file-input.js"); + const dir = mkdtempSync(join(tmpdir(), "meshy-media-")); + const png = join(dir, "a.png"); + writeFileSync(png, await tinyPng()); + const glbPath = join(dir, "m.glb"); + writeFileSync(glbPath, fakeGlb()); + const objPath = join(dir, "m.obj"); + writeFileSync(objPath, "v 0 0 0\n"); + + const fields = [ + { path: "image_url", kind: "image" as const, many: false }, + { path: "model_url", kind: "model" as const, many: false, formats: ["glb"] }, + { path: "image_urls", kind: "image" as const, many: true }, + ]; + const { payload, media } = await normalizeMediaPayload( + { image_url: png, model_url: glbPath, image_urls: [png, "data:image/png;base64,iVBORw0KGgo="], prompt: "./looks/like/a/path.png", untouched: 1 }, + fields, ); + assert.match(payload.image_url as string, /^data:image\/png;base64,/); + assert.match(payload.model_url as string, /^data:model\/gltf-binary;base64,/); + assert.equal(payload.prompt, "./looks/like/a/path.png", "undeclared strings are never read as files"); + assert.equal(payload.untouched, 1); + assert.equal((payload.image_urls as string[]).length, 2); + assert.deepEqual(media.map((m) => [m.field, m.index, m.source]), [["image_url", null, "local-file"], ["model_url", null, "local-file"], ["image_urls", 0, "local-file"], ["image_urls", 1, "data-uri"]]); + + // Format list: uv-unwrap/rigging accept GLB only. + await assert.rejects(() => normalizeMediaPayload({ model_url: objPath }, [fields[1]!]), /accepts glb only/); + // Size cap is enforced before reading. + await assert.rejects(() => normalizeMediaPayload({ image_url: png }, [fields[0]!], { limits: { maxFileBytes: 10 } }), /above the 10-byte limit/); + // Non-http schemes are refused, not read. + await assert.rejects(() => normalizeMediaPayload({ image_url: "file:///etc/passwd" }, [fields[0]!]), /only http\(s\) URLs/); }); diff --git a/tests/fixtures/skill-parity/box-height-80.expected.json b/tests/fixtures/skill-parity/box-height-80.expected.json new file mode 100644 index 0000000..e1f6e67 --- /dev/null +++ b/tests/fixtures/skill-parity/box-height-80.expected.json @@ -0,0 +1,106 @@ +{ + "fixture_origin": "synthetic independent geometry oracle; not generated from the implementation under test", + "input": "box-y-up.obj", + "height_mm": 80, + "scale": 20, + "rotation": "(x,y,z)->(x,-z,y)", + "translation": [ + 0, + 0, + -40 + ], + "vertices": [ + [ + -20, + 60, + 0 + ], + [ + 20, + 60, + 0 + ], + [ + 20, + 60, + 80 + ], + [ + -20, + 60, + 80 + ], + [ + -20, + -60, + 0 + ], + [ + 20, + -60, + 0 + ], + [ + 20, + -60, + 80 + ], + [ + -20, + -60, + 80 + ] + ], + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + -1, + 0 + ], + [ + -1, + 0, + 0 + ], + [ + 1, + 0, + 0 + ], + [ + 0, + 0, + -1 + ], + [ + 0, + 0, + 1 + ] + ], + "bbox_min": [ + -20, + -60, + 0 + ], + "bbox_max": [ + 20, + 60, + 80 + ], + "face_count": 6, + "vertex_count": 8, + "uv_count": 4, + "preserve_vertex_extra_fields": true, + "preserve_face_vertex_uv_normal_indices": true, + "material_dependency": "box.mtl", + "tolerance": { + "absolute_mm": 0.00001, + "relative_to_height": 1e-9 + } +} diff --git a/tests/fixtures/skill-parity/box-y-up.obj b/tests/fixtures/skill-parity/box-y-up.obj new file mode 100644 index 0000000..9b62019 --- /dev/null +++ b/tests/fixtures/skill-parity/box-y-up.obj @@ -0,0 +1,28 @@ +# Synthetic fixture: Y-up box, extent x[-1,1] y[2,6] z[-3,3] +mtllib box.mtl +o parity_box +v -1 2 -3 1 0 0 +v 1 2 -3 0 1 0 +v 1 6 -3 0 0 1 +v -1 6 -3 1 1 1 +v -1 2 3 1 0 1 +v 1 2 3 0 1 1 +v 1 6 3 1 1 0 +v -1 6 3 0.5 0.5 0.5 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0 1 +vn 0 0 -1 +vn 0 0 1 +vn -1 0 0 +vn 1 0 0 +vn 0 -1 0 +vn 0 1 0 +usemtl test_material +f 1/1/1 4/2/1 3/3/1 2/4/1 +f 5/1/2 6/2/2 7/3/2 8/4/2 +f 1/1/3 5/2/3 8/3/3 4/4/3 +f 2/1/4 3/2/4 7/3/4 6/4/4 +f 1/1/5 2/2/5 6/3/5 5/4/5 +f 4/1/6 8/2/6 7/3/6 3/4/6 diff --git a/tests/fixtures/skill-parity/box.mtl b/tests/fixtures/skill-parity/box.mtl new file mode 100644 index 0000000..48fffc3 --- /dev/null +++ b/tests/fixtures/skill-parity/box.mtl @@ -0,0 +1,3 @@ +# Synthetic, no external textures +newmtl test_material +Kd 0.8 0.8 0.8 diff --git a/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json b/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json new file mode 100644 index 0000000..87bbeec --- /dev/null +++ b/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json @@ -0,0 +1,17 @@ +{ + "id": "creative-lab-live-in-progress", + "type": "creative-lab-lamp-prototype", + "name": "live lamp", + "status": "IN_PROGRESS", + "progress": 5, + "created_at": 1788845091914, + "started_at": 1788845091918, + "finished_at": null, + "expires_at": 1789104291914, + "task_error": null, + "preceding_tasks": 0, + "consumed_credits": 30, + "model_urls": {}, + "thumbnail_url": "", + "image_urls": [] +} diff --git a/tests/fixtures/skill-parity/task-error.synthetic.sse b/tests/fixtures/skill-parity/task-error.synthetic.sse new file mode 100644 index 0000000..590d036 --- /dev/null +++ b/tests/fixtures/skill-parity/task-error.synthetic.sse @@ -0,0 +1,5 @@ +: synthetic HTTP 200 stream whose application result is an error + +event: error +data: {"message":"Synthetic task not found","status_code":404} + diff --git a/tests/fixtures/skill-parity/task-rigging.synthetic.json b/tests/fixtures/skill-parity/task-rigging.synthetic.json new file mode 100644 index 0000000..65bfd7e --- /dev/null +++ b/tests/fixtures/skill-parity/task-rigging.synthetic.json @@ -0,0 +1,18 @@ +{ + "id": "fixture-rig-1", + "type": "rigging", + "status": "SUCCEEDED", + "progress": 100, + "face_count": 250000, + "consumed_credits": 5, + "created_at": 0, + "finished_at": 1000, + "task_error": null, + "result": { + "rigged_character_glb_url": "https://assets.example.invalid/rigged.glb", + "basic_animations": { + "walking_glb_url": "https://assets.example.invalid/walk.glb", + "running_glb_url": "https://assets.example.invalid/run.glb" + } + } +} diff --git a/tests/helpers/begin-operation-child.ts b/tests/helpers/begin-operation-child.ts new file mode 100644 index 0000000..8cd61f2 --- /dev/null +++ b/tests/helpers/begin-operation-child.ts @@ -0,0 +1,38 @@ +/** + * Child process for the operation-journal race test: begins the same + * operation id and prints the outcome. argv: [barrier-dir] + * + * With a barrier directory the child announces itself (`ready-`), then + * spins until the parent drops a `go` file, so several children enter + * beginOperation within microseconds of each other instead of being serialised + * by process start-up time. + */ +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { beginOperation } from "../../src/internal/operation-store.js"; + +const [root, id, barrier] = process.argv.slice(2); +if (!root || !id) { + process.stderr.write("usage: begin-operation-child [barrier-dir]\n"); + process.exit(2); +} +if (barrier) { + writeFileSync(join(barrier, `ready-${process.pid}`), ""); + const go = join(barrier, "go"); + const deadline = Date.now() + 10_000; + while (!existsSync(go)) { + if (Date.now() > deadline) { + process.stderr.write("barrier timeout\n"); + process.exit(3); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1); + } +} +const res = beginOperation(root, id, { + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + apiOrigin: "http://127.0.0.1:1", + credentialFingerprint: "cred", + payloadFingerprint: "payload", +}); +process.stdout.write(`${JSON.stringify({ outcome: res.outcome, state: res.record.state })}\n`); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts new file mode 100644 index 0000000..9587690 --- /dev/null +++ b/tests/helpers/cli.ts @@ -0,0 +1,187 @@ +/** + * Subprocess harness for black-box CLI tests. + * + * Every run gets an isolated MESHY_CONFIG_DIR (so no developer credential or + * update cache leaks in), the update notifier disabled, MESHY_API_KEY cleared + * unless the test sets it, and a private cwd. Tests inject a loopback API via + * MESHY_BASE_URL_V1/V2 when they need one. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +export const DIST_ENTRY = join(repoRoot, "dist", "index.js"); + +export interface RunResult { + code: number; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +export interface RunOptions { + env?: Record; + cwd?: string; + timeoutMs?: number; + /** Send SIGINT after this many ms. */ + sigintAfterMs?: number; + stdin?: string; + /** Receives the child right after spawn (e.g. to signal it from a mock handler). */ + onSpawn?: (child: ChildProcess) => void; +} + +export function tmpDir(prefix = "meshy-cli-test-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function isolatedEnv(extra: Record = {}): Record { + const configDir = tmpDir("meshy-config-"); + const env: Record = { + PATH: process.env["PATH"], + HOME: process.env["HOME"], + TMPDIR: process.env["TMPDIR"], + SystemRoot: process.env["SystemRoot"], + MESHY_CLI_NO_UPDATE_NOTIFIER: "1", + MESHY_CLI_NO_BROWSER: "1", + MESHY_CONFIG_DIR: configDir, + MESHY_API_KEY: undefined, + MESHY_BASE_URL_V1: undefined, + MESHY_BASE_URL_V2: undefined, + MESHY_BASE_URL_CREATIVE_LAB: undefined, + MESHY_CREDENTIALS_PATH: undefined, + ...extra, + }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + return env; +} + +export function runCli(args: string[], opts: RunOptions = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [DIST_ENTRY, ...args], { + env: opts.env ?? isolatedEnv(), + cwd: opts.cwd ?? tmpDir("meshy-cwd-"), + stdio: ["pipe", "pipe", "pipe"], + }); + opts.onSpawn?.(child); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 20_000); + let sigint: NodeJS.Timeout | undefined; + if (opts.sigintAfterMs !== undefined) sigint = setTimeout(() => child.kill("SIGINT"), opts.sigintAfterMs); + child.stdout.on("data", (c: Buffer) => (stdout += c.toString())); + child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); + child.on("error", reject); + child.on("close", (code, signal) => { + clearTimeout(timer); + if (sigint) clearTimeout(sigint); + resolve({ code: code ?? -1, signal, stdout, stderr }); + }); + if (opts.stdin !== undefined) child.stdin.write(opts.stdin); + child.stdin.end(); + }); +} + +export interface RecordedRequest { + method: string; + url: string; + path: string; + headers: Record; + body: string; + json: unknown; +} + +export type Handler = (req: RecordedRequest, res: ServerResponse, raw: IncomingMessage) => void | Promise; + +export interface MockApi { + url: string; + origin: string; + requests: RecordedRequest[]; + close(): Promise; + /** Environment pointing every base URL at this server. */ + env(extra?: Record): Record; +} + +/** Loopback HTTP server; handler receives the buffered request. */ +export async function startMockApi(handler: Handler): Promise { + const requests: RecordedRequest[] = []; + const server = createServer(async (raw, res) => { + let body = ""; + for await (const chunk of raw) body += chunk; + let json: unknown = undefined; + try { + json = body ? JSON.parse(body) : undefined; + } catch { + json = undefined; + } + const rec: RecordedRequest = { + method: raw.method ?? "GET", + url: raw.url ?? "/", + path: (raw.url ?? "/").split("?")[0] ?? "/", + headers: raw.headers, + body, + json, + }; + requests.push(rec); + try { + await handler(rec, res, raw); + } catch (err) { + if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: String(err) })); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const addr = server.address(); + if (!addr || typeof addr !== "object") throw new Error("no address"); + const origin = `http://127.0.0.1:${addr.port}`; + return { + url: origin, + origin, + requests, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + env: (extra = {}) => + isolatedEnv({ + MESHY_API_KEY: "msy_fixture_key_loopback_only", + MESHY_BASE_URL_V1: `${origin}/openapi/v1`, + MESHY_BASE_URL_V2: `${origin}/openapi/v2`, + MESHY_POLL_INTERVAL_MS: "20", + ...extra, + }), + }; +} + +export function jsonReply(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +/** Parse stdout as exactly one JSON document (fails on stray text). */ +export function parseSingleJson(stdout: string): unknown { + const trimmed = stdout.trim(); + assertNoTrailingGarbage(trimmed); + return JSON.parse(trimmed); +} + +function assertNoTrailingGarbage(text: string): void { + // JSON.parse throws on trailing content; the helper exists to name the failure. + try { + JSON.parse(text); + } catch (err) { + throw new Error(`stdout is not a single JSON document:\n${text}\n(${(err as Error).message})`); + } +} + +export function parseNdjson(stdout: string): unknown[] { + return stdout + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l)); +} diff --git a/tests/helpers/record-task-child.ts b/tests/helpers/record-task-child.ts new file mode 100644 index 0000000..fdc6fee --- /dev/null +++ b/tests/helpers/record-task-child.ts @@ -0,0 +1,13 @@ +/** + * Child for the project-store concurrency test: records one task in the given + * project. argv: + */ +import { recordTask } from "../../src/internal/project-store.js"; + +const [dir, taskId, stage, root] = process.argv.slice(2); +if (!dir || !taskId || !stage || !root) { + process.stderr.write("usage: record-task-child \n"); + process.exit(2); +} +const res = recordTask(dir, { taskId, stage, resource: "text-to-3d", files: [`${taskId}.glb`] }, { root }); +process.stdout.write(`${JSON.stringify({ action: res.action, count: res.metadata.tasks.length, index: res.index.updated })}\n`); diff --git a/tests/inspect.test.ts b/tests/inspect.test.ts new file mode 100644 index 0000000..1962c3a --- /dev/null +++ b/tests/inspect.test.ts @@ -0,0 +1,449 @@ +/** + * inspect faces — verdict logic (T-080, T-081) and the command driven + * in-process: the file source needs no credential or network, the API source + * makes exactly one GET against a loopback mock (T-082). The dist/ subprocess + * variant is present but skipped until root.ts registers the command. + */ + +// Keep envelope rendering hermetic whatever the dev machine's update cache holds. +process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildInspectCommand, parseMaxFaces, readTaskJsonFile, type FacesResult } from "../src/cmd/inspect.js"; +import { resetCommandContextForTests } from "../src/internal/context.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, walkCommands } from "../src/internal/global-options.js"; +import { faceCountFromTask, judgeFaceCount, judgeTask, remeshSuggestion, type FaceVerdict } from "../src/internal/inspect.js"; +import type { V1Envelope } from "../src/internal/result.js"; +import { toTaskView } from "../src/internal/task-view.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const FIXTURE_URL = new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url); +const FIXTURE_PATH = fileURLToPath(FIXTURE_URL); +const fixture = JSON.parse(readFileSync(FIXTURE_URL, "utf8")) as Record; +const FIXTURE_FACES = 250_000; + +// --------------------------------------------------------------------------- +// In-process harness: a fresh command tree per run (Commander keeps option +// state on the instance), stdout captured (writeStdout needs its callback +// invoked), and the module-level command context reset afterwards. +// --------------------------------------------------------------------------- + +interface InProcessRun { + stdout: string; + error: unknown; +} + +function buildTree(cmd: Command): Command { + const root = new Command("meshy"); + registerRootGlobalOptions(root); + root.addCommand(cmd); + mirrorGlobalOptionsToDescendants(root); + walkCommands(root, (c) => { + c.exitOverride(); + c.configureOutput({ writeErr: () => undefined, writeOut: () => undefined }); + }); + return root; +} + +async function runInspect(args: string[]): Promise { + const chunks: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + // The node test runner reports to its parent over this same stdout with + // binary frames; only the CLI's string writes belong to the capture. + if (typeof chunk !== "string") { + return (original as (c: string | Uint8Array, e?: unknown, cb?: unknown) => boolean).call(process.stdout, chunk, encodingOrCb, cb); + } + chunks.push(chunk); + const callback = typeof encodingOrCb === "function" ? encodingOrCb : typeof cb === "function" ? cb : undefined; + if (callback) (callback as () => void)(); + return true; + }) as typeof process.stdout.write; + let error: unknown = null; + try { + await buildTree(buildInspectCommand()).parseAsync(["node", "meshy", "inspect", "faces", ...args]); + } catch (err) { + error = err; + } finally { + process.stdout.write = original; + resetCommandContextForTests(); + } + return { stdout: chunks.join(""), error }; +} + +function envelopeOf(run: InProcessRun): V1Envelope { + assert.equal(run.error, null, run.error instanceof Error ? run.error.stack : String(run.error)); + return parseSingleJson(run.stdout) as V1Envelope; +} + +function cliErrorOf(run: InProcessRun): CliError { + assert.ok(run.error instanceof CliError, `expected a CliError, got ${String(run.error)}`); + assert.equal(run.stdout, "", "a failed check prints nothing in-process; the entry point renders the envelope"); + return run.error; +} + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "meshy-inspect-")); +} + +function writeJson(dir: string, name: string, value: unknown): string { + const path = join(dir, name); + writeFileSync(path, JSON.stringify(value, null, 2)); + return path; +} + +// --------------------------------------------------------------------------- +// Pure verdict logic +// --------------------------------------------------------------------------- + +test("T-080 judgeFaceCount: limit-1 / limit / limit+1 → pass / pass / fail", () => { + for (const limit of [300_000, 40_000, 1]) { + assert.equal(judgeFaceCount(limit - 1, limit).verdict, limit - 1 >= 0 ? "pass" : "unknown", `${limit}-1`); + assert.equal(judgeFaceCount(limit, limit).verdict, "pass", `${limit} == limit`); + const over = judgeFaceCount(limit + 1, limit); + assert.equal(over.verdict, "fail", `${limit}+1`); + assert.equal(over.face_count, limit + 1); + assert.match(over.reason ?? "", /exceeds the limit/); + } + // The fixture (250000 faces) against the three limits the black-box tests use. + const at = (limit: number): FaceVerdict => judgeFaceCount(fixture["face_count"], limit); + assert.deepEqual(at(300_000), { face_count: FIXTURE_FACES, limit: 300_000, comparison: "lte", verdict: "pass", reason: null }); + assert.equal(at(FIXTURE_FACES).verdict, "pass"); + assert.equal(at(FIXTURE_FACES - 1).verdict, "fail"); + // A real 0 from the server is a known count, not "unknown". + assert.deepEqual(judgeFaceCount(0, 10), { face_count: 0, limit: 10, comparison: "lte", verdict: "pass", reason: null }); +}); + +test("T-081 judgeFaceCount: missing/null/negative/string/NaN/float/… → unknown, never a fabricated 0", () => { + const cases: Array<[unknown, RegExp]> = [ + [undefined, /face_count missing/], + [null, /face_count is null/], + [-1, /not a non-negative integer/], + ["1234", /string, not a number/], + ["", /string, not a number/], + [Number.NaN, /not a finite number/], + [Number.POSITIVE_INFINITY, /not a finite number/], + [3.5, /not a non-negative integer/], + [true, /not a number \(got boolean\)/], + [{ count: 5 }, /not a number \(got object\)/], + [[5], /not a number \(got array\)/], + ]; + for (const [raw, reason] of cases) { + const v = judgeFaceCount(raw, 300_000); + assert.equal(v.verdict, "unknown", `raw=${String(raw)}`); + assert.equal(v.face_count, null, `raw=${String(raw)} must not become a number`); + assert.equal(v.comparison, "lte"); + assert.equal(v.limit, 300_000); + assert.match(v.reason ?? "", reason, `raw=${String(raw)}`); + } + // "1234" is never parsed even when it would pass or fail numerically. + assert.equal(judgeFaceCount("1234", 1000).verdict, "unknown"); + assert.equal(judgeFaceCount("1234", 2000).verdict, "unknown"); +}); + +test("judgeFaceCount rejects an invalid limit instead of guessing", () => { + for (const bad of [0, -1, 2.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => judgeFaceCount(10, bad), RangeError, String(bad)); + } +}); + +test("faceCountFromTask reads only the top-level face_count and reports absence as absence", () => { + const nested = { id: "t", status: "SUCCEEDED", result: { face_count: 5 }, printability: { metrics: { face_count: 7 } } }; + assert.deepEqual(faceCountFromTask(nested), { value: undefined, source: "none", status: "SUCCEEDED" }); + assert.deepEqual(faceCountFromTask({ id: "t", status: "SUCCEEDED", face_count: null }), { value: null, source: "face_count", status: "SUCCEEDED" }); + assert.deepEqual(faceCountFromTask({ id: "t", face_count: 12 }), { value: 12, source: "face_count", status: null }); + assert.deepEqual(faceCountFromTask({ id: "t", status: 3, face_count: 0 }), { value: 0, source: "face_count", status: null }); +}); + +test("T-081 judgeTask: a non-terminal task without face_count is unknown because it is still running", () => { + for (const status of ["PENDING", "IN_PROGRESS", "QUEUED"]) { + const v = judgeTask({ id: "t", status }, 300_000); + assert.equal(v.verdict, "unknown", status); + assert.equal(v.face_count, null); + assert.equal(v.reason, `task is ${status}; no face count yet`); + assert.equal(judgeTask({ id: "t", status, face_count: null }, 300_000).reason, `task is ${status}; no face count yet`); + } + // Terminal tasks without the field: the field is simply missing. + assert.equal(judgeTask({ id: "t", status: "SUCCEEDED" }, 300_000).reason, "face_count missing"); + assert.equal(judgeTask({ id: "t", status: "FAILED" }, 300_000).reason, "face_count missing"); + assert.equal(judgeTask({ id: "t" }, 300_000).reason, "face_count missing"); + // A malformed value on a running task is reported as malformed, not as "not yet". + assert.match(judgeTask({ id: "t", status: "IN_PROGRESS", face_count: "12" }, 300_000).reason ?? "", /string/); + // A count the server did send is judged whatever the status. + assert.equal(judgeTask({ id: "t", status: "IN_PROGRESS", face_count: 10 }, 300_000).verdict, "pass"); + assert.equal(judgeTask(fixture, FIXTURE_FACES - 1).verdict, "fail"); +}); + +test("remeshSuggestion describes an unexecuted remesh and clamps the target to the endpoint range", () => { + const s = remeshSuggestion("fixture-rig-1", 249_999); + assert.equal(s.executed, false); + assert.equal(s.command, "meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999 --output-schema v1"); + assert.match(s.description, /not executed/); + assert.ok(s.description.includes(s.command)); + assert.match(remeshSuggestion("t", 50).command ?? "", /--target-polycount 100 /); + assert.match(remeshSuggestion("t", 10_000_000).command ?? "", /--target-polycount 300000 /); + // No id, or an id that is not a plain token: no command is fabricated. + const none = remeshSuggestion(null, 1000); + assert.equal(none.command, null); + assert.equal(none.executed, false); + assert.match(none.description, /task id is unknown/); + const unsafe = remeshSuggestion("a b; rm -rf /", 1000); + assert.equal(unsafe.command, null); + assert.ok(!unsafe.description.includes("rm -rf")); +}); + +test("parseMaxFaces accepts plain positive integers only", () => { + assert.equal(parseMaxFaces("300000"), 300_000); + assert.equal(parseMaxFaces(" 40000 "), 40_000); + for (const bad of ["0", "-1", "3.5", "abc", "1e5", "", "0x10"]) { + assert.throws(() => parseMaxFaces(bad), UsageError, bad); + } +}); + +test("readTaskJsonFile: bounded read, invalid JSON, non-task JSON and missing files are usage errors", () => { + const dir = tmp(); + const ok = readTaskJsonFile(FIXTURE_PATH); + assert.equal(ok.shape, "api"); + assert.equal(ok.task["id"], "fixture-rig-1"); + assert.throws(() => readTaskJsonFile(FIXTURE_PATH, { maxBytes: 64 }), (e: unknown) => e instanceof UsageError && /exceeds 64 bytes/.test(e.message)); + assert.throws(() => readTaskJsonFile(join(dir, "missing.json")), (e: unknown) => e instanceof UsageError && /cannot open/.test(e.message)); + assert.throws(() => readTaskJsonFile(dir), (e: unknown) => e instanceof UsageError && /not a regular file/.test(e.message)); + writeFileSync(join(dir, "bad.json"), "{ not json"); + assert.throws(() => readTaskJsonFile(join(dir, "bad.json")), (e: unknown) => e instanceof UsageError && /not valid JSON/.test(e.message)); + writeJson(dir, "notask.json", { hello: "world" }); + assert.throws(() => readTaskJsonFile(join(dir, "notask.json")), (e: unknown) => e instanceof UsageError && /does not contain a task/.test(e.message)); + // Relative paths resolve against the given cwd. + writeJson(dir, "rel.json", fixture); + assert.equal(readTaskJsonFile("rel.json", { cwd: dir }).path, join(dir, "rel.json")); +}); + +// --------------------------------------------------------------------------- +// The command, in-process: file source (T-080/T-081/T-082 local part) +// --------------------------------------------------------------------------- + +test("T-080 command: fixture 250000 vs 300000 / 250000 / 249999 → pass 0 / pass 0 / fail 12", async () => { + const pass = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "v1"])); + assert.deepEqual(Object.keys(pass), ["schema_version", "command", "ok", "result", "error", "warnings"]); + assert.equal(pass.schema_version, "meshy.cli/v1"); + assert.equal(pass.command, "inspect.faces"); + assert.equal(pass.ok, true); + assert.equal(pass.error, null); + const r = pass.result!; + assert.equal(r.verdict, "pass"); + assert.equal(r.face_count, FIXTURE_FACES); + assert.equal(r.limit, 300_000); + assert.equal(r.comparison, "lte"); + assert.equal(r.reason, null); + assert.equal(r.task_id, "fixture-rig-1"); + assert.equal(r.status, "SUCCEEDED"); + assert.equal(r.suggestion, null); + assert.equal(r.saved_json, null); + assert.equal(r.source.kind, "task-json"); + assert.equal((r.source as { path: string }).path, FIXTURE_PATH); + // The gate answers the face count and nothing more. + assert.doesNotMatch(JSON.stringify(pass), /rig[- ]?ready|riggable/i); + + const equal = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES)])); + assert.equal(equal.result!.verdict, "pass"); + + const fail = cliErrorOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES - 1), "--output-schema", "v1"])); + assert.equal(fail.code, "check_failed"); + assert.equal(fail.exitCode, 12); + const failResult = fail.result as unknown as FacesResult; + assert.equal(failResult.verdict, "fail"); + assert.equal(failResult.face_count, FIXTURE_FACES); + assert.match(failResult.reason ?? "", /exceeds the limit 249999 by 1/); + assert.equal(failResult.suggestion?.executed, false); + assert.equal(failResult.suggestion?.command, "meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999 --output-schema v1"); +}); + +test("T-081 command: missing/null/string face_count and a running task → unknown, exit 13, no remesh suggestion", async () => { + const dir = tmp(); + const cases: Array<[string, Record, RegExp]> = [ + ["running.json", { id: "t-run", status: "IN_PROGRESS", progress: 40 }, /task is IN_PROGRESS; no face count yet/], + ["pending-null.json", { id: "t-pend", status: "PENDING", face_count: null }, /task is PENDING; no face count yet/], + ["missing.json", { id: "t-done", status: "SUCCEEDED", result: { face_count: 5 } }, /face_count missing/], + ["null.json", { id: "t-null", status: "SUCCEEDED", face_count: null }, /face_count is null/], + ["string.json", { id: "t-str", status: "SUCCEEDED", face_count: "1234" }, /string, not a number/], + ["negative.json", { id: "t-neg", status: "SUCCEEDED", face_count: -5 }, /not a non-negative integer/], + ["float.json", { id: "t-flt", status: "SUCCEEDED", face_count: 3.5 }, /not a non-negative integer/], + ]; + for (const [name, task, reason] of cases) { + const path = writeJson(dir, name, task); + const err = cliErrorOf(await runInspect(["--task-json", path, "--max-faces", "300000"])); + assert.equal(err.code, "check_unknown", name); + assert.equal(err.exitCode, 13, name); + const result = err.result as unknown as FacesResult; + assert.equal(result.verdict, "unknown", name); + assert.equal(result.face_count, null, `${name}: nothing is fabricated`); + assert.match(result.reason ?? "", reason, name); + assert.equal(result.suggestion, null, `${name}: unknown never suggests a remesh`); + assert.equal(result.task_id, task["id"]); + } +}); + +test("T-082 local: meta.json and v1 envelope shapes yield the same verdict as the API task shape", async () => { + const dir = tmp(); + const meta = writeJson(dir, "meta.json", { resource: "rigging", endpoint: "/openapi/v1/rigging", task: fixture, saved_files: [] }); + const envelopeWithRaw = writeJson(dir, "envelope-raw.json", { + schema_version: "meshy.cli/v1", + command: "rigging.get", + ok: true, + result: { task: toTaskView(fixture, { includeRaw: true }), submission: null, downloads: null, saved_json: null }, + error: null, + warnings: [], + }); + const envelopePlain = writeJson(dir, "envelope-plain.json", { + schema_version: "meshy.cli/v1", + command: "rigging.get", + ok: true, + result: { task: toTaskView(fixture) }, + error: null, + warnings: [], + }); + const shapes: Array<[string, string]> = [ + [FIXTURE_PATH, "api"], + [meta, "meta.json"], + [envelopeWithRaw, "v1-envelope"], + [envelopePlain, "v1-result"], + ]; + for (const [path, shape] of shapes) { + const pass = envelopeOf(await runInspect(["--task-json", path, "--max-faces", "300000"])); + assert.equal(pass.result!.verdict, "pass", shape); + assert.equal(pass.result!.face_count, FIXTURE_FACES, shape); + assert.equal(pass.result!.task_id, "fixture-rig-1", shape); + assert.equal((pass.result!.source as { shape: string }).shape, shape); + const fail = cliErrorOf(await runInspect(["--task-json", path, "--max-faces", String(FIXTURE_FACES - 1)])); + assert.equal(fail.code, "check_failed", shape); + assert.equal((fail.result as unknown as FacesResult).face_count, FIXTURE_FACES, shape); + } +}); + +test("command: usage errors before any work — sources, --max-faces, schema, -o and --save-json", async () => { + const usage = async (args: string[], pattern: RegExp): Promise => { + const run = await runInspect(args); + assert.ok(run.error instanceof UsageError, `${args.join(" ")}: expected UsageError, got ${String(run.error)}`); + assert.match(run.error.message, pattern, args.join(" ")); + assert.equal(run.stdout, ""); + }; + await usage(["--task-json", FIXTURE_PATH], /--max-faces is required/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "0"], /positive integer/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "3.5"], /positive integer/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "abc"], /positive integer/); + await usage(["--max-faces", "300000"], /task source is required/); + await usage(["--task-json", FIXTURE_PATH, "--resource", "rigging", "--task-id", "x", "--max-faces", "300000"], /not both/); + await usage(["--task-json", FIXTURE_PATH, "--task-id", "x", "--max-faces", "300000"], /not both/); + await usage(["--resource", "rigging", "--max-faces", "300000"], /together with --task-id/); + await usage(["--task-id", "x", "--max-faces", "300000"], /together with --task-id/); + await usage(["--resource", "no-such-resource", "--task-id", "x", "--max-faces", "300000"], /unknown --resource 'no-such-resource'.*rigging.*uv-unwrap/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "legacy"], /only emits the v1 envelope/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "-o", "out.glb"], /--save-json/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--save-json", "x.json"], /only applies to the API source/); + await usage(["--task-json", join(tmp(), "missing.json"), "--max-faces", "300000"], /cannot open/); +}); + +// --------------------------------------------------------------------------- +// The command, in-process: API source (T-082) against a loopback mock +// --------------------------------------------------------------------------- + +test("T-082 api: exactly one GET through the registry, same verdict as the file, no download and no remesh", async () => { + const noFace = { id: "no-face", type: "rigging", status: "SUCCEEDED", progress: 100, result: {} }; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/rigging/fixture-rig-1") return jsonReply(res, 200, fixture); + if (req.method === "GET" && req.path === "/openapi/v1/rigging/no-face") return jsonReply(res, 200, noFace); + return jsonReply(res, 404, { message: "nope" }); + }); + const dir = tmp(); + try { + const common = ["--base-url-v1", `${api.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only", "--output-schema", "v1"]; + const pass = envelopeOf(await runInspect(["--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000", ...common])); + assert.equal(pass.result!.verdict, "pass"); + assert.equal(pass.result!.face_count, FIXTURE_FACES); + assert.deepEqual(pass.result!.source, { kind: "api", resource: "rigging", task_id: "fixture-rig-1", endpoint: "/openapi/v1/rigging", requests_made: 1 }); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + assert.equal(api.requests[0]!.path, "/openapi/v1/rigging/fixture-rig-1"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + + // Same fact, other source: identical verdict. + const local = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", "300000"])); + assert.equal(local.result!.verdict, pass.result!.verdict); + assert.equal(local.result!.face_count, pass.result!.face_count); + assert.equal(api.requests.length, 1, "the file source made no request"); + + // A failing verdict describes a remesh; the mock sees no POST. + const saveTo = join(dir, "task.json"); + const fail = cliErrorOf( + await runInspect(["--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", String(FIXTURE_FACES - 1), "--save-json", saveTo, ...common]), + ); + assert.equal(fail.code, "check_failed"); + assert.equal(fail.exitCode, 12); + const failResult = fail.result as unknown as FacesResult; + assert.equal(failResult.suggestion?.executed, false); + assert.match(failResult.suggestion?.command ?? "", /^meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999/); + assert.equal(failResult.saved_json?.path, realpathSync(saveTo)); + assert.deepEqual(JSON.parse(readFileSync(saveTo, "utf8")), fixture, "--save-json keeps the raw task, not the envelope"); + assert.equal(api.requests.length, 2); + assert.ok(api.requests.every((r) => r.method === "GET"), "never a POST (no remesh, no download)"); + + // D-009: the public task DTO usually carries no face_count → unknown, exit 13, still one GET. + const unknown = cliErrorOf(await runInspect(["--resource", "rigging", "--task-id", "no-face", "--max-faces", "300000", ...common])); + assert.equal(unknown.code, "check_unknown"); + assert.equal(unknown.exitCode, 13); + assert.equal((unknown.result as unknown as FacesResult).reason, "face_count missing"); + assert.equal(api.requests.length, 3); + + // An unknown resource is refused before any request or credential resolution. + const bad = await runInspect(["--resource", "nope", "--task-id", "x", "--max-faces", "1", ...common]); + assert.ok(bad.error instanceof UsageError); + assert.equal(api.requests.length, 3); + } finally { + await api.close(); + } +}); + +test("T-082 subprocess: dist/ inspect faces — file source needs no credential, API source makes one GET", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/rigging/fixture-rig-1") return jsonReply(res, 200, fixture); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + // File source: no MESHY_API_KEY, no profile, no base URL — still exit 0. + const local = await runCli(["inspect", "faces", "--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "v1"]); + assert.equal(local.code, 0, local.stderr); + const localOut = parseSingleJson(local.stdout) as V1Envelope; + assert.equal(localOut.command, "inspect.faces"); + assert.equal(localOut.result!.verdict, "pass"); + + const remote = await runCli(["inspect", "faces", "--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000"], { env: api.env() }); + assert.equal(remote.code, 0, remote.stderr); + const remoteOut = parseSingleJson(remote.stdout) as V1Envelope; + assert.equal(remoteOut.result!.verdict, localOut.result!.verdict); + assert.equal(remoteOut.result!.face_count, localOut.result!.face_count); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + + const fail = await runCli(["inspect", "faces", "--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES - 1)]); + assert.equal(fail.code, 12, fail.stderr); + const failOut = parseSingleJson(fail.stdout) as V1Envelope; + assert.equal(failOut.ok, false); + assert.equal(failOut.error?.code, "check_failed"); + assert.equal(failOut.result?.verdict, "fail"); + assert.equal(failOut.result?.suggestion?.executed, false); + + const unknown = await runCli(["inspect", "faces", "--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000"], { + env: api.env({ MESHY_API_KEY: undefined }), + }); + assert.equal(unknown.code, 3, "API source without a credential is an auth failure, not unknown"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); diff --git a/tests/live-verification.test.ts b/tests/live-verification.test.ts new file mode 100644 index 0000000..c382e90 --- /dev/null +++ b/tests/live-verification.test.ts @@ -0,0 +1,128 @@ +/** + * Regressions for defects found during the live (real-account) verification. + * + * L01 — Creative Lab endpoints report `finished_at: null` while a task is + * IN_PROGRESS (the v2 endpoints report 0). The task schema required a number, + * so `creative-lab … get`/`wait` failed with "unexpected task shape" (code + * `server`, HTTP 200) on every poll until the task finished. The body captured + * live is the fixture; null timestamps and counts now read as 0. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { TaskSchema } from "../src/client/types.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const FIXTURE = join(import.meta.dirname, "fixtures", "skill-parity", "creative-lab-lamp-prototype.in-progress.json"); +const inProgress = JSON.parse(readFileSync(FIXTURE, "utf8")) as Record; + +test("L01 a Creative Lab task body with finished_at: null (captured live while IN_PROGRESS) parses; null timestamps and counts read as 0, the v2 zero form is unchanged", () => { + assert.equal(inProgress["finished_at"], null, "the fixture really carries null"); + const parsed = TaskSchema.parse(inProgress); + assert.equal(parsed.status, "IN_PROGRESS"); + assert.equal(parsed.progress, 5); + assert.equal(parsed.finished_at, 0, "null → 0, the same value the v2 endpoints send before completion"); + assert.equal(parsed.started_at, 1788845091918); + assert.equal(parsed.consumed_credits, 30); + // Every "not yet" field tolerates null and absence alike. + const sparse = TaskSchema.parse({ id: "x", status: "PENDING", progress: null, preceding_tasks: null, created_at: null, started_at: null, finished_at: null, expires_at: null }); + assert.deepEqual([sparse.progress, sparse.preceding_tasks, sparse.created_at, sparse.started_at, sparse.finished_at, sparse.expires_at], [0, 0, 0, 0, 0, 0]); + const absent = TaskSchema.parse({ id: "y", status: "PENDING" }); + assert.deepEqual([absent.progress, absent.finished_at], [0, 0]); + const v2 = TaskSchema.parse({ id: "z", status: "IN_PROGRESS", progress: 40, created_at: 1, started_at: 2, finished_at: 0, expires_at: 3 }); + assert.equal(v2.finished_at, 0); +}); + +test("L01 creative-lab lamp prototype get/wait on a task that is still IN_PROGRESS: get reports the status (exit 0), wait polls through to SUCCEEDED (2 GETs) instead of failing on the first poll", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && /\/openapi\/creative-lab\/lamp\/v1\/prototype\/cl-live$/.test(req.path)) { + gets += 1; + if (gets <= 2) return jsonReply(res, 200, { ...inProgress, id: "cl-live" }); + return jsonReply(res, 200, { ...inProgress, id: "cl-live", status: "SUCCEEDED", progress: 100, finished_at: 1788845191918, thumbnail_url: `${api.url}/thumb.png`, model_urls: { glb: `${api.url}/model.glb` } }); + } + return jsonReply(res, 404, { message: "unexpected" }); + }); + try { + const env = api.env({ MESHY_POLL_INTERVAL_MS: "20" }); + const got = await runCli(["creative-lab", "lamp", "prototype", "get", "cl-live", "--output-schema", "v1"], { env }); + assert.equal(got.code, 0, `${got.stderr}\n${got.stdout}`); + const g = parseSingleJson(got.stdout) as { ok: boolean; result: { task: { status: string; progress: number; finished_at: number } } }; + assert.equal(g.ok, true); + assert.equal(g.result.task.status, "IN_PROGRESS"); + assert.equal(g.result.task.progress, 5); + assert.equal(g.result.task.finished_at, null, "the v1 view shows a timestamp that has not happened as null — for the Creative Lab null and the v2 zero alike"); + + const waited = await runCli(["creative-lab", "lamp", "prototype", "wait", "cl-live", "--output-schema", "v1"], { env }); + assert.equal(waited.code, 0, `${waited.stderr}\n${waited.stdout}`); + const w = parseSingleJson(waited.stdout) as { ok: boolean; result: { task: { status: string; finished_at: number }; wait: { polls: number; timed_out: boolean } } }; + assert.equal(w.ok, true); + assert.equal(w.result.task.status, "SUCCEEDED"); + assert.equal(w.result.task.finished_at, 1788845191918); + assert.equal(w.result.wait.timed_out, false); + assert.equal(w.result.wait.polls, 2, "the first poll saw IN_PROGRESS and was accepted, the second saw SUCCEEDED"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [ + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// L02 — the legacy `-o` layout names Creative Lab parts and bundles like `meshy download` +// --------------------------------------------------------------------------- + +test("L02 -o on a Creative Lab lamp build saves lamp.stl / base.stl (not model.lamp_stl), and a keychain OBJ bundle saves as model.obj.zip; slot keys and digests unchanged", async () => { + const { mkdirSync, statSync } = await import("node:fs"); + const { tmpDir } = await import("./helpers/cli.js"); + const stl = Buffer.concat([Buffer.alloc(80, 0), Buffer.from([1, 0, 0, 0]), Buffer.alloc(50, 7)]); + const zip = Buffer.from("504b0304140000000800" + "00".repeat(20) + "504b0506" + "00".repeat(18), "hex"); + const api = await startMockApi((req, res) => { + if (req.path === "/lamp.stl" || req.path === "/base.stl") { + res.writeHead(200, { "content-type": "application/octet-stream" }); + return void res.end(stl); + } + if (req.path === "/bundle") { + res.writeHead(200, { "content-type": "application/zip" }); + return void res.end(zip); + } + if (/\/openapi\/creative-lab\/lamp\/v1\/build\/lamp-1$/.test(req.path)) { + return jsonReply(res, 200, { id: "lamp-1", type: "creative-lab-lamp-build", status: "SUCCEEDED", progress: 100, finished_at: 1, model_urls: { lamp_stl: `${api.url}/lamp.stl`, base_stl: `${api.url}/base.stl` } }); + } + if (/\/openapi\/creative-lab\/keychain\/v1\/build\/key-1$/.test(req.path)) { + return jsonReply(res, 200, { id: "key-1", type: "creative-lab-keychain-build", status: "SUCCEEDED", progress: 100, finished_at: 1, model_urls: { obj: `${api.url}/bundle` } }); + } + return jsonReply(res, 404, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const lampOut = join(dir, "lamp"); + mkdirSync(lampOut); + const lamp = await runCli(["creative-lab", "lamp", "build", "get", "lamp-1", "--output-schema", "v1", "-o", lampOut], { env, cwd: dir }); + assert.equal(lamp.code, 0, `${lamp.stderr}\n${lamp.stdout}`); + const l = parseSingleJson(lamp.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; bytes: number; status: string }> } } }; + assert.deepEqual(l.result.downloads.files.map((f) => [f.key, f.path.split("/").at(-1), f.status]), [ + ["model_lamp_stl", "lamp.stl", "written"], + ["model_base_stl", "base.stl", "written"], + ]); + for (const f of l.result.downloads.files) assert.equal(statSync(f.path).size, stl.length); + assert.deepEqual(Object.keys((JSON.parse(readFileSync(join(lampOut, "meta.json"), "utf8")) as { task: { model_urls: Record } }).task.model_urls).sort(), ["base_stl", "lamp_stl"]); + + const keyOut = join(dir, "keychain"); + mkdirSync(keyOut); + const key = await runCli(["creative-lab", "keychain", "build", "get", "key-1", "--output-schema", "v1", "-o", keyOut], { env, cwd: dir }); + assert.equal(key.code, 0, `${key.stderr}\n${key.stdout}`); + const k = parseSingleJson(key.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; status: string }>; material_links: unknown } } }; + assert.deepEqual(k.result.downloads.files.map((f) => [f.key, f.path.split("/").at(-1), f.status]), [["model_obj", "model.obj.zip", "written"]]); + assert.ok(readFileSync(k.result.downloads.files[0]!.path).equals(zip), "the bundle bytes are saved untouched"); + assert.equal(k.result.downloads.material_links, null, "a ZIP bundle is not relinked"); + } finally { + await api.close(); + } +}); diff --git a/tests/obj-transform.test.ts b/tests/obj-transform.test.ts new file mode 100644 index 0000000..3460119 --- /dev/null +++ b/tests/obj-transform.test.ts @@ -0,0 +1,251 @@ +/** + * OBJ print preparation (T-083..T-087) against the independent fixture oracle + * tests/fixtures/skill-parity/box-height-80.expected.json. Nothing here derives + * an expected value from the implementation under test. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareObjForPrint, defaultOutputPath, formatObjNumber, textureReferencesInMtl } from "../src/internal/obj-transform.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const FIXTURES = fileURLToPath(new URL("./fixtures/skill-parity/", import.meta.url)); +const ORACLE = JSON.parse(readFileSync(join(FIXTURES, "box-height-80.expected.json"), "utf8")) as { + height_mm: number; + scale: number; + translation: [number, number, number]; + vertices: number[][]; + normals: number[][]; + bbox_min: number[]; + bbox_max: number[]; + face_count: number; + vertex_count: number; + uv_count: number; + material_dependency: string; + tolerance: { absolute_mm: number; relative_to_height: number }; +}; + +function tol(height: number): number { + return ORACLE.tolerance.absolute_mm + ORACLE.tolerance.relative_to_height * height; +} + +function near(actual: number, expected: number, eps: number, label: string): void { + assert.ok(Math.abs(actual - expected) <= eps, `${label}: ${actual} vs ${expected} (eps ${eps})`); +} + +interface ParsedObj { + v: number[][]; + vExtra: string[][]; + vn: number[][]; + vt: string[]; + f: string[]; + other: string[]; + eol: "\n" | "\r\n"; +} + +function parseObj(text: string): ParsedObj { + const out: ParsedObj = { v: [], vExtra: [], vn: [], vt: [], f: [], other: [], eol: text.includes("\r\n") ? "\r\n" : "\n" }; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const t = line.split(/\s+/); + if (t[0] === "v") { + out.v.push([Number(t[1]), Number(t[2]), Number(t[3])]); + out.vExtra.push(t.slice(4)); + } else if (t[0] === "vn") out.vn.push([Number(t[1]), Number(t[2]), Number(t[3])]); + else if (t[0] === "vt") out.vt.push(line); + else if (t[0] === "f") out.f.push(line); + else out.other.push(line); + } + return out; +} + +/** Copy the fixture box (OBJ + MTL) into a fresh temp dir; returns the OBJ path. */ +function fixtureCopy(dir = tmpDir("obj-")): { dir: string; obj: string; mtl: string } { + const obj = join(dir, "box-y-up.obj"); + const mtl = join(dir, "box.mtl"); + copyFileSync(join(FIXTURES, "box-y-up.obj"), obj); + copyFileSync(join(FIXTURES, "box.mtl"), mtl); + return { dir, obj, mtl }; +} + +const ORIGINAL = readFileSync(join(FIXTURES, "box-y-up.obj"), "utf8"); +const ORIGINAL_PARSED = parseObj(ORIGINAL); + +test("T-083 oracle: every vertex, normal, bbox, scale, translation and count match box-height-80.expected.json", async () => { + const { dir, obj } = fixtureCopy(); + const report = await prepareObjForPrint(obj, { heightMm: ORACLE.height_mm }); + const eps = tol(ORACLE.height_mm); + assert.equal(report.output, join(dir, "box-y-up.print.obj")); + near(report.scale, ORACLE.scale, 1e-9, "scale"); + ORACLE.translation.forEach((t, i) => near(report.translation[i]!, t, eps, `translation[${i}]`)); + ORACLE.bbox_min.forEach((b, i) => near(report.after_bbox.min[i]!, b, eps, `bbox_min[${i}]`)); + ORACLE.bbox_max.forEach((b, i) => near(report.after_bbox.max[i]!, b, eps, `bbox_max[${i}]`)); + assert.equal(report.counts.vertices, ORACLE.vertex_count); + assert.equal(report.counts.normals, ORACLE.normals.length); + assert.equal(report.counts.uvs, ORACLE.uv_count); + assert.equal(report.counts.faces, ORACLE.face_count); + assert.deepEqual(report.material.mtllib, [ORACLE.material_dependency]); + assert.deepEqual(report.material.missing, []); + + const written = parseObj(readFileSync(report.output, "utf8")); + assert.equal(written.v.length, ORACLE.vertices.length); + ORACLE.vertices.forEach((exp, i) => exp.forEach((c, j) => near(written.v[i]![j]!, c, eps, `v[${i}][${j}]`))); + ORACLE.normals.forEach((exp, i) => exp.forEach((c, j) => near(written.vn[i]![j]!, c, 1e-9, `vn[${i}][${j}]`))); + // Extra vertex fields (the fixture carries rgb after xyz) and face/UV/normal index triplets are untouched. + written.vExtra.forEach((extra, i) => assert.deepEqual(extra, ORIGINAL_PARSED.vExtra[i], `v[${i}] extra fields`)); + assert.deepEqual(written.f, ORIGINAL_PARSED.f); + assert.deepEqual(written.vt, ORIGINAL_PARSED.vt); + assert.deepEqual(written.other, ORIGINAL_PARSED.other, "mtllib/o/usemtl/comments preserved verbatim"); + // Input untouched. + assert.equal(readFileSync(obj, "utf8"), ORIGINAL); +}); + +test("T-084 default height is 75 mm; CRLF, scientific notation and stray whitespace yield the same geometry", async () => { + const base = fixtureCopy(); + const def = await prepareObjForPrint(base.obj); + assert.equal(def.height_mm, 75); + near(def.after_bbox.max[2]! - def.after_bbox.min[2]!, 75, tol(75), "height"); + near(def.after_bbox.min[2]!, 0, tol(75), "minZ"); + near((def.after_bbox.min[0]! + def.after_bbox.max[0]!) / 2, 0, tol(75), "x centre"); + near((def.after_bbox.min[1]! + def.after_bbox.max[1]!) / 2, 0, tol(75), "y centre"); + const reference = parseObj(readFileSync(def.output, "utf8")); + + const variants: Array<[string, string]> = [ + ["crlf", ORIGINAL.replace(/\n/g, "\r\n")], + ["scientific", ORIGINAL.replace(/^v (\S+) (\S+) (\S+)/gm, (_m, x, y, z) => `v ${Number(x).toExponential(3)} ${Number(y).toExponential(3)} ${Number(z).toExponential(3)}`)], + ["whitespace", ORIGINAL.replace(/^v /gm, " v ").replace(/\n/g, " \n")], + ]; + for (const [name, text] of variants) { + const dir = tmpDir("obj-var-"); + const path = join(dir, `${name}.obj`); + writeFileSync(path, text); + copyFileSync(join(FIXTURES, "box.mtl"), join(dir, "box.mtl")); + const rep = await prepareObjForPrint(path); + const parsed = parseObj(readFileSync(rep.output, "utf8")); + assert.equal(parsed.v.length, reference.v.length, name); + reference.v.forEach((v, i) => v.forEach((c, j) => near(parsed.v[i]![j]!, c, 2e-3, `${name} v[${i}][${j}]`))); + assert.deepEqual(parsed.f, reference.f, `${name} faces`); + if (name === "crlf") assert.equal(parsed.eol, "\r\n", "line-ending style preserved"); + } +}); + +test("T-085 invalid inputs fail with validation and write nothing", async () => { + const cases: Array<[string, string, RegExp]> = [ + ["empty", "", /empty/], + ["no-vertices", "# comment\nvt 0 0\nf 1 2 3\n", /no vertex/], + ["nan", "v 0 NaN 0\nv 1 1 1\n", /unparseable|non-finite/], + ["infinity", "v 0 0 0\nv 1 Infinity 1\n", /unparseable|non-finite/], + ["bad-token", "v 0 0 abc\nv 1 1 1\n", /unparseable|non-finite/], + ["degenerate", "v 0 1 0\nv 1 1 0\nv 0 1 1\n", /degenerate/], + ["short", "v 0 1\nv 1 1 1\n", /expected 3/], + ]; + for (const [name, text, re] of cases) { + const dir = tmpDir("obj-bad-"); + const path = join(dir, `${name}.obj`); + writeFileSync(path, text); + await assert.rejects(prepareObjForPrint(path), (e: unknown) => e instanceof CliError && e.code === "validation" && re.test(e.message), name); + assert.ok(!existsSync(defaultOutputPath(path)), `${name}: no output written`); + assert.equal(readFileSync(path, "utf8"), text, `${name}: input untouched`); + } + const { obj } = fixtureCopy(); + for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { + await assert.rejects(prepareObjForPrint(obj, { heightMm: bad }), (e: unknown) => e instanceof CliError && e.code === "validation", String(bad)); + } + await assert.rejects(prepareObjForPrint(obj, { outputPath: join(tmpDir(), "x.obj"), inPlace: true }), UsageError); + await assert.rejects(prepareObjForPrint(join(tmpDir(), "missing.obj")), (e: unknown) => e instanceof CliError && e.code === "not_found"); +}); + +test("T-086 in-place replaces the input only on success; existing outputs are refused; cross-directory copies materials", async () => { + // In place. + const a = fixtureCopy(); + const before = statSync(a.obj).mode & 0o777; + const rep = await prepareObjForPrint(a.obj, { heightMm: 80, inPlace: true }); + assert.equal(rep.output, a.obj); + assert.equal(rep.in_place, true); + const parsed = parseObj(readFileSync(a.obj, "utf8")); + ORACLE.vertices.forEach((exp, i) => exp.forEach((c, j) => near(parsed.v[i]![j]!, c, tol(80), `in-place v[${i}][${j}]`))); + assert.equal(statSync(a.obj).mode & 0o777, before, "mode preserved"); + assert.ok(!existsSync(join(a.dir, "box-y-up.print.obj"))); + // Failed transform in place leaves the original intact. + const b = tmpDir("obj-"); + const badPath = join(b, "bad.obj"); + writeFileSync(badPath, "v 0 0 0\nv 1 1 NaN\n"); + await assert.rejects(prepareObjForPrint(badPath, { inPlace: true }), CliError); + assert.equal(readFileSync(badPath, "utf8"), "v 0 0 0\nv 1 1 NaN\n"); + + // Existing output refused, untouched. + const c = fixtureCopy(); + const target = join(c.dir, "box-y-up.print.obj"); + writeFileSync(target, "keep"); + await assert.rejects(prepareObjForPrint(c.obj), (e: unknown) => e instanceof CliError && e.code === "local_io" && /refusing to overwrite/.test(e.message)); + assert.equal(readFileSync(target, "utf8"), "keep"); + + // Cross-directory: MTL copied next to the output and recorded. + const d = fixtureCopy(); + const outDir = join(tmpDir("obj-out-"), "nested"); + const rep2 = await prepareObjForPrint(d.obj, { outputPath: join(outDir, "printable.obj") }); + assert.equal(rep2.output, join(outDir, "printable.obj")); + assert.ok(existsSync(join(outDir, "box.mtl"))); + assert.deepEqual(rep2.material.copied, [join(outDir, "box.mtl")]); + assert.equal(readFileSync(join(outDir, "box.mtl"), "utf8"), readFileSync(d.mtl, "utf8")); + // Output directory as target: default name inside it. + const e = fixtureCopy(); + const dirTarget = tmpDir("obj-dirtarget-"); + const rep3 = await prepareObjForPrint(e.obj, { outputPath: dirTarget }); + assert.equal(rep3.output, join(dirTarget, "box-y-up.print.obj")); + + // Missing mtllib + different directory → validation unless geometryOnly. + const f = tmpDir("obj-"); + const missingMtl = join(f, "m.obj"); + writeFileSync(missingMtl, "mtllib nowhere.mtl\nv 0 0 0\nv 1 2 3\nf 1 2\n"); + await assert.rejects(prepareObjForPrint(missingMtl, { outputPath: join(tmpDir("obj-out-"), "m.obj") }), (e: unknown) => e instanceof CliError && e.code === "validation" && /geometry-only/.test(e.message)); + const geo = await prepareObjForPrint(missingMtl, { outputPath: join(tmpDir("obj-out-"), "m.obj"), geometryOnly: true }); + assert.deepEqual(geo.material.missing, ["nowhere.mtl"]); + assert.ok(geo.warnings.some((w) => w.code === "material_dependency_missing")); + // Same directory with a missing mtllib is only a warning (nothing to carry). + const same = await prepareObjForPrint(missingMtl); + assert.ok(same.warnings.some((w) => w.code === "material_dependency_missing")); + + // A mtllib escaping the input directory is never read or copied. + const g = tmpDir("obj-"); + const outside = tmpDir("obj-outside-"); + writeFileSync(join(outside, "secret.mtl"), "newmtl s\nmap_Kd ../../etc/passwd\n"); + const escaping = join(g, "e.obj"); + writeFileSync(escaping, `mtllib ${join(outside, "secret.mtl")}\nmtllib ../${outside.split("/").pop()}/secret.mtl\nv 0 0 0\nv 1 2 3\n`); + const rep4 = await prepareObjForPrint(escaping, { outputPath: join(tmpDir("obj-out-"), "e.obj"), geometryOnly: true }); + assert.equal(rep4.material.copied.length, 0); + assert.ok(rep4.warnings.every((w) => w.code === "material_dependency_outside_input_dir" || w.code === "material_dependencies_not_copied")); +}); + +test("T-087 a large OBJ streams with bounded memory", async () => { + const dir = tmpDir("obj-big-"); + const path = join(dir, "big.obj"); + const n = 200_000; + const chunks: string[] = ["# big\n"]; + for (let i = 0; i < n; i++) chunks.push(`v ${(i % 100) / 10} ${((i * 7) % 1000) / 10} ${(i % 37) / 3}\n`); + for (let i = 1; i + 2 <= n; i += 3) chunks.push(`f ${i} ${i + 1} ${i + 2}\n`); + writeFileSync(path, chunks.join("")); + const size = statSync(path).size; + if (global.gc) global.gc(); + const before = process.memoryUsage().heapUsed; + const rep = await prepareObjForPrint(path, { heightMm: 100 }); + const after = process.memoryUsage().heapUsed; + assert.equal(rep.counts.vertices, n); + assert.ok(existsSync(rep.output)); + assert.ok(statSync(rep.output).size > size * 0.5); + assert.ok(after - before < 200 * 1024 * 1024, `heap grew by ${((after - before) / 1048576).toFixed(1)} MB`); +}); + +test("helpers: number formatting stays inside the oracle tolerance; MTL texture references are parsed", () => { + assert.equal(formatObjNumber(0), "0"); + assert.equal(formatObjNumber(-0), "0"); + assert.equal(formatObjNumber(20), "20"); + assert.equal(formatObjNumber(-60.0000004), "-60"); + assert.equal(formatObjNumber(1.23456789), "1.234568"); + assert.deepEqual(textureReferencesInMtl("newmtl a\nmap_Kd tex.png\nmap_Bump -bm 0.5 bump.png\n# map_Ks ignored.png\nbump ../up.png\n"), ["tex.png", "bump.png", "../up.png"]); +}); diff --git a/tests/operation-store.test.ts b/tests/operation-store.test.ts new file mode 100644 index 0000000..62e9167 --- /dev/null +++ b/tests/operation-store.test.ts @@ -0,0 +1,146 @@ +/** + * Operation journal (T-043..T-046): start → accepted/unknown/rejected, replay + * on the same id, conflict on a different request, identity bound to the + * actual credential and to media content, no secrets on disk, and a real + * multi-process race on one operation id. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + beginOperation, + credentialFingerprint, + dataUriDigest, + listOperations, + payloadFingerprint, + readOperation, + updateOperation, +} from "../src/internal/operation-store.js"; +import { CliError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const KEY_A = "msy_fixture_account_a_0000000000"; +const KEY_B = "msy_fixture_account_b_0000000000"; + +const identity = { + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + apiOrigin: "http://127.0.0.1:1", + credentialFingerprint: credentialFingerprint({ source: "env", origin: "http://127.0.0.1:1", kind: "api_key", secret: KEY_A }), + payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "a", image_url: "data:image/png;base64,AAAA" }), +}; + +test("begin → accepted, then a repeat with the same id replays without conflict", () => { + const root = tmpDir("ops-"); + const a = beginOperation(root, "op-1", identity); + assert.equal(a.outcome, "created"); + assert.equal(a.record.state, "started"); + updateOperation(root, "op-1", { state: "accepted", task_id: "task-9", http_status: 200 }); + const b = beginOperation(root, "op-1", identity); + assert.equal(b.outcome, "existing"); + assert.equal(b.record.state, "accepted"); + assert.equal(b.record.task_id, "task-9"); + assert.equal(listOperations(root).length, 1); +}); + +test("a different payload, credential or origin under the same id is an operation_conflict naming what differs", () => { + const root = tmpDir("ops-"); + beginOperation(root, "op-2", identity); + const variants: Array<[string, typeof identity]> = [ + ["payload", { ...identity, payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "b" }) }], + ["credential", { ...identity, credentialFingerprint: credentialFingerprint({ source: "env", origin: identity.apiOrigin, kind: "api_key", secret: KEY_B }) }], + ["credential", { ...identity, credentialFingerprint: credentialFingerprint({ source: "file", profile: "work", origin: identity.apiOrigin, kind: "api_key", secret: KEY_A }) }], + ["origin", { ...identity, apiOrigin: "http://127.0.0.1:2" }], + ["resource", { ...identity, resource: "image-to-3d" }], + ]; + for (const [what, variant] of variants) { + assert.throws( + () => beginOperation(root, "op-2", variant), + (e: unknown) => e instanceof CliError && e.code === "operation_conflict" && Array.isArray(e.result?.["conflict"]) && (e.result!["conflict"] as string[]).includes(what), + what, + ); + } +}); + +test("F05 credential identity is bound to the key digest / OAuth subject, not to the source alone", () => { + const origin = "http://127.0.0.1:1"; + const envA = credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_A }); + const envB = credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_B }); + assert.notEqual(envA, envB, "two keys from the same env source are two identities"); + assert.equal(envA, credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_A }), "the same key is stable"); + assert.ok(!envA.includes(KEY_A.slice(4, 20)), "the fingerprint does not contain the key"); + // OAuth: the subject binds the account; a rotated token changes nothing, a different user does. + const userA = credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-a" }); + assert.equal(userA, credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-a", secret: "rotated-access-token" }), "token rotation keeps the identity"); + assert.notEqual(userA, credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-b" }), "same profile name, different account → different identity"); + assert.notEqual(credentialFingerprint({ source: "env", origin, kind: "api_key" }), credentialFingerprint({ source: "file", profile: "p", origin, kind: "api_key" })); +}); + +test("F06 media fingerprints hash the decoded content: same file matches, different equal-length files do not", () => { + const red = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 255, 0, 0]).toString("base64"); + const green = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 0, 255, 0]).toString("base64"); + assert.equal(red.length, green.length); + const a = payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${red}` }); + const b = payloadFingerprint({ image_url: `data:image/png;base64,${red}`, b: 1 }); + assert.equal(a, b, "key order does not matter"); + assert.notEqual(a, payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${green}` }), "different bytes of the same length → different fingerprint"); + assert.notEqual(a, payloadFingerprint({ b: 2, image_url: `data:image/png;base64,${red}` })); + // Equivalent encodings of the same bytes (line-wrapped base64) are the same request. + const wrapped = red.replace(/(.{4})/g, "$1\n"); + assert.equal(payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${wrapped}` }), a); + // The digest string carries the MIME and a hash, never the payload. + const digest = dataUriDigest(`data:image/png;base64,${red}`); + assert.match(digest, /^data:image\/png;sha256=[0-9a-f]{64}$/); + assert.ok(!digest.includes(red.slice(0, 8))); +}); + +test("records never contain the key, base64 media or signed URLs; files are private", () => { + const root = tmpDir("ops-"); + beginOperation(root, "op-3", identity); + updateOperation(root, "op-3", { state: "unknown", error: "socket hang up" }); + const files = readdirSync(root).filter((f) => f.endsWith(".json")); + assert.equal(files.length, 1); + const text = readFileSync(join(root, files[0]!), "utf8"); + assert.ok(!text.includes("AAAA"), "no base64 payload"); + assert.ok(!text.includes("msy_"), "no key material"); + assert.ok(!text.includes(KEY_A.slice(4)), "no key fragment"); + assert.equal(readOperation(root, "op-3")?.state, "unknown"); +}); + +test("T-046 three processes released together on the same operation id: exactly one creates, the others see its record", async () => { + const root = tmpDir("ops-"); + const barrier = tmpDir("barrier-"); + const script = fileURLToPath(new URL("./helpers/begin-operation-child.ts", import.meta.url)); + const children = [1, 2, 3].map( + () => + new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, ["--import", "tsx", script, root, "race-1", barrier], { env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c: Buffer) => (stdout += c.toString())); + child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }), + ); + // Release the barrier only once every child is spinning at the gate. + const deadline = Date.now() + 15_000; + while (readdirSync(barrier).filter((f) => f.startsWith("ready-")).length < 3) { + if (Date.now() > deadline) throw new Error("children never reached the barrier"); + await new Promise((r) => setTimeout(r, 5)); + } + const { writeFileSync } = await import("node:fs"); + writeFileSync(join(barrier, "go"), ""); + const runs = await Promise.all(children); + const outcomes = runs.map((r) => { + assert.equal(r.status, 0, r.stderr); + return JSON.parse(r.stdout.trim()) as { outcome: string; state: string }; + }); + const created = outcomes.filter((o) => o.outcome === "created").length; + assert.equal(created, 1, JSON.stringify(outcomes)); + assert.equal(outcomes.filter((o) => o.outcome === "existing" && o.state === "started").length, 2, JSON.stringify(outcomes)); + assert.equal(listOperations(root).length, 1, "one record on disk"); +}); diff --git a/tests/poll.test.ts b/tests/poll.test.ts index 1feb880..49fbd7f 100644 --- a/tests/poll.test.ts +++ b/tests/poll.test.ts @@ -7,15 +7,21 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { TaskEndpoint } from "../src/client/endpoints/base.js"; import { pollUntilTerminal } from "../src/internal/poll.js"; +import { TransportError } from "../src/client/transport.js"; import type { Task } from "../src/client/types.js"; function fakeEndpoint(statuses: string[]): TaskEndpoint { let i = 0; + const retrieve = async (id: string): Promise => { + const status = statuses[Math.min(i, statuses.length - 1)] ?? "PENDING"; + i += 1; + return { id, status, type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + }; return { - async retrieve(id: string): Promise { - const status = statuses[Math.min(i, statuses.length - 1)] ?? "PENDING"; - i += 1; - return { id, status, type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + retrieve, + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + const task = await retrieve(id); + return { task, raw: task }; }, } as unknown as TaskEndpoint; } @@ -26,7 +32,7 @@ test("pollUntilTerminal — returns immediately when already SUCCEEDED", async ( timeoutSeconds: 5, intervalMs: 250, }); - assert.equal(task.status, "SUCCEEDED"); + assert.equal(task?.status, "SUCCEEDED"); assert.equal(timedOut, false); }); @@ -38,7 +44,7 @@ test("pollUntilTerminal — iterates through PENDING → IN_PROGRESS → SUCCEED intervalMs: 10, onTick: (t) => seen.push(t.status), }); - assert.equal(task.status, "SUCCEEDED"); + assert.equal(task?.status, "SUCCEEDED"); assert.deepEqual(seen, ["PENDING", "IN_PROGRESS", "SUCCEEDED"]); }); @@ -47,14 +53,14 @@ test("pollUntilTerminal — treats FAILED and CANCELED as terminal", async () => timeoutSeconds: 5, intervalMs: 10, }); - assert.equal(a.task.status, "FAILED"); + assert.equal(a.task?.status, "FAILED"); assert.equal(a.timedOut, false); const b = await pollUntilTerminal(fakeEndpoint(["CANCELED"]), "x", { timeoutSeconds: 5, intervalMs: 10, }); - assert.equal(b.task.status, "CANCELED"); + assert.equal(b.task?.status, "CANCELED"); assert.equal(b.timedOut, false); }); @@ -67,7 +73,150 @@ test("pollUntilTerminal — times out when a task never terminates", async () => }); const elapsed = Date.now() - started; assert.equal(timedOut, true); - assert.equal(task.status, "PENDING"); + assert.equal(task?.status, "PENDING"); assert.ok(elapsed >= 200, `elapsed=${elapsed}ms should be at least 200`); assert.ok(elapsed < 2000, `elapsed=${elapsed}ms should not blow past the deadline`); }); + +/** Deterministic clock: `now` reads a counter, `sleep` advances it by the requested ms (optionally skewed). */ +function fakeClock(skew: (requested: number) => number = (ms) => ms) { + let t = 0; + return { + now: () => t, + sleep: async (ms: number) => { + t += skew(ms); + }, + advance: (ms: number) => { + t += ms; + }, + }; +} + +function inProgress(id: string): Task { + return { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; +} + +test("pollUntilTerminal — deterministic: the budget runs out during the sleep, no GET starts after the deadline", async () => { + const clock = fakeClock(); + const starts: number[] = []; + const ep = { + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + starts.push(clock.now()); + assert.ok((extras?.timeoutMs ?? Infinity) <= 250 - clock.now() || clock.now() === 0, "each GET is capped by the remaining budget"); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: clock.now, sleep: clock.sleep }); + assert.equal(res.timedOut, true); + assert.equal(res.task?.status, "IN_PROGRESS"); + assert.deepEqual(starts, [0], "one GET at t=0; the sleep consumed the whole budget (min(interval 300, remaining 250)) and no GET followed"); + assert.equal(res.polls, 1); +}); + +test("pollUntilTerminal — deterministic: a timer that wakes early may poll again, but never after the deadline", async () => { + // The sleep wakes half a millisecond early (as real timers may); the second + // GET starts inside the budget, is capped to the 1 ms left, and nothing starts + // after 250 — the next sleep carries the clock to the deadline and the loop stops. + const clock = fakeClock((ms) => ms - 0.5); + const starts: number[] = []; + const caps: Array = []; + const ep = { + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + starts.push(clock.now()); + caps.push(extras?.timeoutMs); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, requestTimeoutMs: 5000, now: clock.now, sleep: clock.sleep }); + assert.equal(res.timedOut, true); + assert.ok(starts.every((t) => t < 250), `every GET started before the deadline: ${JSON.stringify(starts)}`); + assert.deepEqual(starts, [0, 249.5]); + assert.deepEqual(caps, [250, 1], "the request cap is the remaining budget, rounded up to a whole millisecond"); + // The second sleep starts at 249.5 with 0.5 ms left → the loop finds the budget spent and stops. + assert.equal(res.polls, 2); +}); + +test("pollUntilTerminal — deterministic: a timer that wakes late never polls again; a deadline-bound request timeout is the timeout", async () => { + const late = fakeClock((ms) => ms + 20); + const starts: number[] = []; + const ep = { + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + starts.push(late.now()); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: late.now, sleep: late.sleep }); + assert.equal(res.timedOut, true); + assert.deepEqual(starts, [0]); + + // The very first GET takes longer than the whole budget: the transport + // aborts it (phase timeout) and the poll reports a timeout with no task. + const slow = fakeClock(); + const slowEp = { + async retrieveDetailed(_id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + slow.advance(extras?.timeoutMs ?? 0); + throw new TransportError({ message: "request timed out", phase: "timeout", path: "/x" }); + }, + } as unknown as TaskEndpoint; + const out = await pollUntilTerminal(slowEp, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: slow.now, sleep: slow.sleep }); + assert.equal(out.timedOut, true); + assert.equal(out.task, null); + assert.equal(out.polls, 0); + // A read-timeout-bound failure (budget still left) is a network error, not a timeout. + const readCap = fakeClock(); + const readEp = { + async retrieveDetailed(_id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + readCap.advance(extras?.timeoutMs ?? 0); + throw new TransportError({ message: "request timed out", phase: "timeout", path: "/x" }); + }, + } as unknown as TaskEndpoint; + await assert.rejects(pollUntilTerminal(readEp, "abc", { timeoutSeconds: 10, intervalMs: 300, requestTimeoutMs: 50, now: readCap.now, sleep: readCap.sleep }), TransportError); +}); + +test("pollUntilTerminal — real timers (smoke): no GET starts after the deadline, whatever the timer jitter", async () => { + // Real setTimeout may wake a fraction early or late; the only invariant a + // real clock can prove is that no request *starts* past the deadline. The + // decision is judged with the very clock reading the loop used (the last + // value the injected `now` returned before the GET), not with a fresh + // performance.now() taken microseconds later inside the endpoint — that + // would turn call overhead into a false failure. + let origin: number | null = null; + let lastNow = 0; + const now = () => { + const t = performance.now(); + if (origin === null) origin = t; + lastNow = t; + return t; + }; + const starts: number[] = []; + const ep = { + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + starts.push(lastNow); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.12, intervalMs: 300, now }); + assert.equal(res.timedOut, true); + assert.ok(starts.length >= 1); + const deadline = (origin ?? 0) + 120; + assert.ok(starts.every((t) => t < deadline), `GET decisions relative to the deadline: ${JSON.stringify(starts.map((t) => Number((t - deadline).toFixed(3))))}`); +}); + +test("pollUntilTerminal — --timeout 0 is a single query that is not bounded by the (zero) budget", async () => { + const ep = { + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + assert.equal(extras?.timeoutMs, 4321, "the transport read timeout applies, not a 0 ms budget"); + await new Promise((r) => setTimeout(r, 30)); + const task = { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0, intervalMs: 300, requestTimeoutMs: 4321 }); + assert.equal(res.polls, 1); + assert.equal(res.timedOut, true); + assert.equal(res.task?.status, "IN_PROGRESS"); +}); diff --git a/tests/project-store.test.ts b/tests/project-store.test.ts new file mode 100644 index 0000000..180bcdf --- /dev/null +++ b/tests/project-store.test.ts @@ -0,0 +1,270 @@ +/** + * Project store (T-072..T-077): layout, legacy compatibility, de-duplication, + * concurrency, index repair and path safety — plus the `--project` hook on + * task verbs and the `project` subcommands as black boxes. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertSafeRelativeFile, + initProject, + listProjects, + normalizeMetadata, + projectFolderName, + readProject, + rebuildIndex, + recordTask, +} from "../src/internal/project-store.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const fixedNow = () => new Date("2026-09-07T10:20:30.000Z"); + +test("T-072 init → record → show: exact layout, metadata v2, history index", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "Demo Fixture", taskId: "fixture-task-a", now: fixedNow }); + assert.match(init.folder, /^\d{8}_\d{6}_demo-fixture_fixture-$/); + assert.ok(existsSync(join(init.project_dir, "metadata.json"))); + assert.equal(init.index.updated, true); + const history = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { version: number; projects: Array<{ folder: string; task_count: number }> }; + assert.equal(history.version, 1); + assert.equal(history.projects[0]!.folder, init.folder); + assert.equal(history.projects[0]!.task_count, 0); + + const rec = recordTask(init.project_dir, { taskId: "fixture-task-a", stage: "preview", resource: "text-to-3d", taskType: "text-to-3d-preview", endpoint: "/openapi/v2/text-to-3d", status: "SUCCEEDED", files: ["preview.glb"], taskJson: "task_fixture-task-a.json", operationId: "fixture-op-1" }, { root, now: fixedNow }); + assert.equal(rec.action, "added"); + const meta = readProject(init.project_dir).metadata; + assert.equal(meta.schema_version, 2); + assert.deepEqual(meta.tasks[0], { + task_id: "fixture-task-a", + task_type: "text-to-3d-preview", + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + stage: "preview", + parent_task_id: null, + status: "SUCCEEDED", + files: ["preview.glb"], + task_json: "task_fixture-task-a.json", + operation_id: "fixture-op-1", + created_at: "2026-09-07T10:20:30.000Z", + updated_at: "2026-09-07T10:20:30.000Z", + }); + const h2 = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { projects: Array<{ task_count: number; root_task_id: string }> }; + assert.equal(h2.projects[0]!.task_count, 1); + assert.equal(h2.projects[0]!.root_task_id, "fixture-task-a"); +}); + +test("T-074 (task_id, stage) is merged, different stages and tasks are kept", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "dedupe", now: fixedNow }); + recordTask(init.project_dir, { taskId: "t1", stage: "preview", files: ["a.glb"], status: "IN_PROGRESS" }, { root }); + const merged = recordTask(init.project_dir, { taskId: "t1", stage: "preview", files: ["a.glb", "thumb.png"], status: "SUCCEEDED" }, { root }); + assert.equal(merged.action, "merged"); + recordTask(init.project_dir, { taskId: "t2", stage: "refine", files: ["b.glb"] }, { root }); + recordTask(init.project_dir, { taskId: "t1", stage: "rigged", files: ["rig.glb"] }, { root }); + const meta = readProject(init.project_dir).metadata; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.stage, t.files, t.status]), [ + ["t1", "preview", ["a.glb", "thumb.png"], "SUCCEEDED"], + ["t2", "refine", ["b.glb"], null], + ["t1", "rigged", ["rig.glb"], null], + ]); + assert.equal(meta.root_task_id, "t1"); +}); + +test("T-073 legacy metadata.json (no schema_version) is read as v1, migrated on write with a backup, unknown fields kept; download meta.json is never accepted as metadata", () => { + const root = tmpDir("proj-root-"); + const folder = "20260101_120000_legacy_abcdef12"; + const dir = join(root, folder); + mkdirSync(dir); + const legacy = { + project_name: "legacy", + folder, + root_task_id: "abcdef12-legacy", + created_at: "2026-01-01T12:00:00", + updated_at: "2026-01-01T12:00:00", + tasks: [{ task_id: "abcdef12-legacy", task_type: "text-to-3d", stage: "preview", files: ["preview.glb"], created_at: "2026-01-01T12:00:00" }], + custom_note: "keep me", + }; + writeFileSync(join(dir, "metadata.json"), JSON.stringify(legacy, null, 2)); + writeFileSync(join(root, "history.json"), JSON.stringify({ version: 1, projects: [{ folder, prompt: "legacy", task_type: "text-to-3d", root_task_id: "abcdef12-legacy", created_at: legacy.created_at, updated_at: legacy.updated_at, task_count: 1 }] })); + + const read = readProject(dir); + assert.equal(read.legacy, true); + assert.equal(read.metadata.tasks[0]!.resource, null); + assert.equal(read.metadata.tasks[0]!.status, null); + assert.equal(read.metadata["custom_note"], "keep me"); + // show does not rewrite the file + assert.equal(JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")).schema_version, undefined); + + const rec = recordTask(dir, { taskId: "abcdef12-legacy", stage: "refined", files: ["refined.glb"] }, { root, now: fixedNow }); + assert.equal(rec.migrated_from_legacy, true); + const after = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as Record; + assert.equal(after["schema_version"], 2); + assert.equal(after["custom_note"], "keep me"); + assert.equal((after["tasks"] as unknown[]).length, 2); + const backups = readdirSync(dir).filter((f) => f.startsWith("metadata.json.bak-")); + assert.equal(backups.length, 1); + assert.deepEqual(JSON.parse(readFileSync(join(dir, backups[0]!), "utf8")), legacy); + const hist = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { version: number; projects: Array<{ task_count: number }> }; + assert.equal(hist.version, 1); + assert.equal(hist.projects[0]!.task_count, 2); + + // A CLI download meta.json is not project metadata. + const other = join(root, "20260101_120001_dl_deadbeef"); + mkdirSync(other); + writeFileSync(join(other, "metadata.json"), JSON.stringify({ resource: "image-to-3d", task: { id: "x" }, saved_files: [] })); + const view = readProject(other).metadata; + assert.deepEqual(view.tasks, [], "no tasks array → no tasks, nothing invented"); +}); + +test("T-077 unsafe names and paths are refused or skipped; damaged JSON is never overwritten", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "安全 名称 with, comma", now: fixedNow }); + assert.match(init.folder, /^\d{8}_\d{6}_with-comma_[0-9a-f]{4}$/, "only the ASCII part survives in the slug"); + assert.equal(init.metadata.project_name, "安全 名称 with, comma"); + const cjk = initProject(root, { name: "安全名称", now: fixedNow }); + assert.match(cjk.folder, /^\d{8}_\d{6}_project_[0-9a-f]{4}$/, "a name without ASCII falls back to a safe slug"); + assert.equal(cjk.metadata.project_name, "安全名称"); + for (const bad of ["../x.glb", "/abs/x.glb", "C:\\x.glb", "a//b", ""]) { + assert.throws(() => assertSafeRelativeFile(bad, "--file"), UsageError, bad); + } + assert.equal(assertSafeRelativeFile("sub\\dir\\model, with comma.glb", "--file"), "sub/dir/model, with comma.glb"); + // Same second, same name → distinct folders. + const a = projectFolderName("same", null, fixedNow(), () => "aaaa"); + const b = projectFolderName("same", null, fixedNow(), () => "bbbb"); + assert.notEqual(a, b); + const c = initProject(root, { name: "same", now: fixedNow }); + const d = initProject(root, { name: "same", now: fixedNow }); + assert.notEqual(c.folder, d.folder); + // Damaged metadata: readProject throws, file untouched; recordTask throws too. + const broken = join(root, "20260101_120000_broken_00000000"); + mkdirSync(broken); + writeFileSync(join(broken, "metadata.json"), "{ not json"); + assert.throws(() => readProject(broken), (e: unknown) => e instanceof CliError && e.code === "local_io"); + assert.throws(() => recordTask(broken, { taskId: "t", stage: "s" }, { root }), CliError); + assert.equal(readFileSync(join(broken, "metadata.json"), "utf8"), "{ not json"); + // history.json pointing outside the root is reported as absent, and rebuild skips a symlinked folder. + const outside = tmpDir("outside-"); + mkdirSync(join(outside, "victim")); + writeFileSync(join(outside, "victim", "metadata.json"), JSON.stringify({ schema_version: 2, project_name: "v", folder: "victim", root_task_id: null, created_at: "", updated_at: "", tasks: [] })); + symlinkSync(join(outside, "victim"), join(root, "linked")); + const rebuilt = rebuildIndex(root, { now: fixedNow }); + assert.ok(rebuilt.skipped.some((s) => s.folder === "linked")); + assert.ok(rebuilt.backup && existsSync(rebuilt.backup), "previous index backed up"); + writeFileSync(join(root, "history.json"), JSON.stringify({ version: 1, projects: [{ folder: "../../etc", prompt: "x", task_type: "", root_task_id: null, created_at: "", updated_at: "", task_count: 0 }] })); + const listed = listProjects(root); + assert.equal(listed.projects[0]!.present, false); + assert.equal(listed.index_dirty, true); + assert.throws(() => normalizeMetadata([1, 2], "f"), CliError); +}); + +test("T-075 concurrent records from separate processes lose nothing and keep valid JSON", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "race", now: fixedNow }); + const script = fileURLToPath(new URL("./helpers/record-task-child.ts", import.meta.url)); + const children = ["t1", "t2", "t3", "t4", "t5", "t6"].map((id) => + spawnSync(process.execPath, ["--import", "tsx", script, init.project_dir, id, "preview", root], { encoding: "utf8", env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }), + ); + for (const c of children) assert.equal(c.status, 0, c.stderr); + const meta = readProject(init.project_dir).metadata; + assert.deepEqual(meta.tasks.map((t) => t.task_id).sort(), ["t1", "t2", "t3", "t4", "t5", "t6"]); + const hist = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { projects: Array<{ task_count: number }> }; + assert.equal(hist.projects.length, 1); + assert.equal(hist.projects[0]!.task_count, 6); +}); + +test("T-076 metadata committed but index update failing is reported as index_dirty and repaired by rebuild-index", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "dirty", now: fixedNow }); + // Corrupt the history index so the second phase fails. + writeFileSync(join(root, "history.json"), "[]"); + const rec = recordTask(init.project_dir, { taskId: "t1", stage: "preview" }, { root }); + assert.equal(rec.action, "added"); + assert.equal(rec.index.updated, false); + assert.match(rec.index.error ?? "", /not a history index/); + assert.equal(readProject(init.project_dir).metadata.tasks.length, 1, "metadata is never rolled back"); + assert.equal(readFileSync(join(root, "history.json"), "utf8"), "[]", "damaged index untouched"); + assert.throws(() => listProjects(root), CliError); + const rebuilt = rebuildIndex(root, { now: fixedNow }); + assert.equal(rebuilt.indexed, 1); + assert.ok(rebuilt.backup); + const listed = listProjects(root); + assert.equal(listed.index_dirty, false); + assert.equal(listed.projects[0]!.task_count, 1); +}); + +test("T-072 CLI: project init/record/show/list/rebuild-index run without a key or network", async () => { + const cwd = tmpDir("proj-cli-"); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + const init = await runCli(["project", "init", "--name", "demo", "--task-id", "fixture-task-a"], { cwd, env }); + assert.equal(init.code, 0, init.stderr); + const initEnv = parseSingleJson(init.stdout) as { command: string; result: { project_dir: string; folder: string } }; + assert.equal(initEnv.command, "project.init"); + assert.ok(existsSync(join(cwd, "meshy_output", initEnv.result.folder, "metadata.json"))); + writeFileSync(join(initEnv.result.project_dir, "preview.glb"), "glb"); + const rec = await runCli(["project", "record", "--project", initEnv.result.project_dir, "--task-id", "fixture-task-a", "--resource", "text-to-3d", "--stage", "preview", "--file", "preview.glb", "--file", "thumb, with comma.png"], { cwd, env }); + assert.equal(rec.code, 0, rec.stderr); + const recEnv = parseSingleJson(rec.stdout) as { result: { entry: { files: string[] }; action: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(recEnv.result.entry.files, ["preview.glb", "thumb, with comma.png"]); + assert.ok(recEnv.warnings.some((w) => w.code === "recorded_file_missing")); + const show = await runCli(["project", "show", "--project", initEnv.result.project_dir], { cwd, env }); + assert.equal(show.code, 0, show.stderr); + const showEnv = parseSingleJson(show.stdout) as { result: { legacy_format: boolean; files: Array<{ file: string; present: boolean }> } }; + assert.equal(showEnv.result.legacy_format, false); + assert.deepEqual(showEnv.result.files.map((f) => [f.file, f.present]), [["preview.glb", true], ["thumb, with comma.png", false]]); + const list = await runCli(["project", "list"], { cwd, env }); + assert.equal(list.code, 0, list.stderr); + assert.equal((parseSingleJson(list.stdout) as { result: { projects: unknown[]; index_dirty: boolean } }).result.index_dirty, false); + const rebuild = await runCli(["project", "rebuild-index"], { cwd, env }); + assert.equal(rebuild.code, 0, rebuild.stderr); + const missing = await runCli(["project", "record", "--project", join(cwd, "nope"), "--task-id", "x", "--stage", "s"], { cwd, env }); + assert.equal(missing.code, 2); +}); + +test("--project on task verbs: async create records the id immediately; wait snapshots the final task", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-p" }); + gets += 1; + return jsonReply(res, 200, { id: "task-p", type: "text-to-3d-preview", status: gets < 2 ? "IN_PROGRESS" : "SUCCEEDED", progress: 100 }); + }); + try { + const cwd = tmpDir("proj-cli-"); + const env = api.env(); + const init = await runCli(["project", "init", "--name", "chain"], { cwd, env }); + assert.equal(init.code, 0, init.stderr); + const dir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const created = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "p", "--async", "--project", dir, "--output-schema", "v1"], { cwd, env }); + assert.equal(created.code, 0, created.stderr); + const cEnv = parseSingleJson(created.stdout) as { result: { project: { stage: string; action: string; snapshot: string | null } } }; + assert.equal(cEnv.result.project.stage, "preview"); + assert.equal(cEnv.result.project.snapshot, null); + let meta = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string; status: string | null; operation_id: string | null; task_json: string | null }> }; + assert.equal(meta.tasks[0]!.task_id, "task-p"); + assert.equal(meta.tasks[0]!.status, null); + assert.ok(meta.tasks[0]!.operation_id); + + const waited = await runCli(["text-to-3d", "wait", "task-p", "--project", dir, "--output-schema", "v1"], { cwd, env }); + assert.equal(waited.code, 0, waited.stderr); + const wEnv = parseSingleJson(waited.stdout) as { result: { project: { action: string; snapshot: string } } }; + assert.equal(wEnv.result.project.action, "merged"); + assert.ok(existsSync(join(dir, "task_task-p.json"))); + meta = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as typeof meta; + assert.equal(meta.tasks.length, 1, "same task+stage merged, not duplicated"); + assert.equal(meta.tasks[0]!.status, "SUCCEEDED"); + assert.equal(meta.tasks[0]!.task_json, "task_task-p.json"); + + // Not an initialised project → local_io with the task id kept. + const bad = await runCli(["text-to-3d", "get", "task-p", "--project", join(cwd, "nope"), "--output-schema", "v1"], { cwd, env }); + assert.equal(bad.code, 11, bad.stderr); + const bEnv = parseSingleJson(bad.stdout) as { result: { task_id: string } }; + assert.equal(bEnv.result.task_id, "task-p"); + } finally { + await api.close(); + } +}); diff --git a/tests/resource-registry.test.ts b/tests/resource-registry.test.ts new file mode 100644 index 0000000..9c9715b --- /dev/null +++ b/tests/resource-registry.test.ts @@ -0,0 +1,104 @@ +/** + * The registry is the executable contract; docs/skill-parity/endpoint-contracts.json + * is its documentation. They must agree field by field (T-020, T-022). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + CREATIVE_LAB_PRODUCTS, + creativeLabResource, + QUERY_RESOURCES, + resourceIndex, + TASK_RESOURCES, + taskResourceByCommandPath, +} from "../src/client/resource-registry.js"; + +interface DocResource { + id: string; + commandPath: string[]; + base: string; + relativePath: string; + legacyEndpoint: string; + supports: Record; + mediaFields: Array<{ path: string; kind: string; many: boolean; formats?: string[] }>; + taskTypes: string[]; + billing: { create: string }; +} + +const docs = JSON.parse(readFileSync(new URL("../docs/skill-parity/endpoint-contracts.json", import.meta.url), "utf8")) as { + task_resources: DocResource[]; + query_resources: Array<{ id: string; commandPath: string[]; base: string; relativePath: string; auth: string }>; +}; + +test("every documented task resource exists in the registry with the same routing and media fields", () => { + assert.equal(TASK_RESOURCES.length, docs.task_resources.length, "resource count drifted"); + for (const d of docs.task_resources) { + const r = TASK_RESOURCES.find((x) => x.id === d.id); + assert.ok(r, `registry is missing ${d.id}`); + assert.deepEqual([...r.commandPath], d.commandPath, `${d.id} commandPath`); + assert.equal(r.base, d.base, `${d.id} base`); + assert.equal(r.relativePath, d.relativePath, `${d.id} relativePath`); + assert.equal(r.legacyEndpoint, d.legacyEndpoint, `${d.id} legacyEndpoint`); + assert.deepEqual({ ...r.supports }, d.supports, `${d.id} supports`); + assert.deepEqual( + r.mediaFields.map((m) => ({ path: m.path, kind: m.kind, many: m.many, ...(m.formats ? { formats: [...m.formats] } : {}) })), + d.mediaFields, + `${d.id} mediaFields`, + ); + assert.deepEqual([...r.taskTypes], d.taskTypes, `${d.id} taskTypes`); + assert.equal(r.billing.create, d.billing.create, `${d.id} billing`); + assert.equal(r.automaticRetry, false); + } +}); + +test("query resources agree with the documentation and the catalog carries no credential", () => { + for (const d of docs.query_resources) { + const q = QUERY_RESOURCES.find((x) => x.id === d.id); + assert.ok(q, `registry is missing query ${d.id}`); + assert.deepEqual([...q.commandPath], d.commandPath); + assert.equal(q.base, d.base); + assert.equal(q.relativePath, d.relativePath); + assert.equal(q.auth, d.auth); + } + assert.equal(QUERY_RESOURCES.find((q) => q.id === "animation-catalog")?.auth, "none"); + assert.equal(QUERY_RESOURCES.find((q) => q.id === "showcases")?.billing, "may-charge"); +}); + +test("Creative Lab: 4 products × 2 stages, exact paths, no path built from user strings (T-022, T-023)", () => { + for (const product of CREATIVE_LAB_PRODUCTS) { + for (const stage of ["prototype", "build"] as const) { + const r = creativeLabResource(product, stage); + assert.ok(r, `${product}/${stage}`); + assert.equal(r.relativePath, `/${product}/v1/${stage}`); + assert.equal(r.legacyEndpoint, `/openapi/creative-lab/${product}/v1/${stage}`); + assert.equal(r.creativeLab?.product, product); + assert.equal(r.creativeLab?.stage, stage); + assert.equal(r.mediaFields.length, stage === "prototype" ? 1 : 0); + } + } + for (const [p, s] of [["../figure", "prototype"], ["figure", "../build"], ["https://evil.example/", "build"], ["figure prototype", "build"], ["Figure", "prototype"], ["", "prototype"], ["keycap", "build"]]) { + assert.equal(creativeLabResource(p!, s!), undefined, `${p}/${s} must not resolve`); + } + assert.equal(taskResourceByCommandPath(["creative-lab", "lamp", "build"])?.id, "creative-lab.lamp.build"); + assert.equal(taskResourceByCommandPath(["creative-lab", "lamp"]), undefined); +}); + +test("legacy routing facts: animate → /animations, text-to-3d on v2, rigging list enabled", () => { + assert.equal(TASK_RESOURCES.find((r) => r.id === "animate")?.relativePath, "/animations"); + assert.equal(TASK_RESOURCES.find((r) => r.id === "text-to-3d")?.base, "v2"); + assert.equal(TASK_RESOURCES.find((r) => r.id === "rigging")?.supports.list, true); + assert.equal(TASK_RESOURCES.find((r) => r.id === "analyze-printability")?.billing.create, "none"); +}); + +test("resourceIndex classifies every entry and never invents a verb", () => { + const idx = resourceIndex(); + const kinds = new Set(idx.map((e) => e.kind)); + assert.deepEqual([...kinds].sort(), ["local", "query", "task"]); + for (const e of idx.filter((x) => x.kind === "task")) { + assert.deepEqual(e.verbs, ["create", "get", "list", "wait", "stream", "delete"]); + assert.match(e.command, /^meshy /); + } + for (const e of idx.filter((x) => x.kind !== "task")) assert.equal(e.verbs, null); +}); diff --git a/tests/result.test.ts b/tests/result.test.ts new file mode 100644 index 0000000..47ba370 --- /dev/null +++ b/tests/result.test.ts @@ -0,0 +1,96 @@ +/** + * v1 envelope construction and the error classification behind it (T-002, T-011). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { CommanderError } from "commander"; +import { MeshyApiError } from "../src/client/errors.js"; +import { + CliError, + classifyError, + exitCodeFor, + EXIT_CODES, + HintedError, + UsageError, + authRequiredError, +} from "../src/internal/errors.js"; +import { errorEnvelope, okEnvelope, SCHEMA_VERSION } from "../src/internal/result.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +test("okEnvelope — exactly the six fixed keys", () => { + const env = okEnvelope("balance", { balance: 1 }); + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env.schema_version, SCHEMA_VERSION); + assert.equal(env.ok, true); + assert.equal(env.error, null); + assert.deepEqual(env.warnings, []); +}); + +test("errorEnvelope — CliError carries code, exit, http status, recovery and partial result", () => { + const err = new CliError({ + code: "submission_unknown", + message: "request sent, outcome unknown", + recovery: { action: "reconcile", automatic: false }, + result: { submission: { state: "unknown", operation_id: "op-1" }, task: null }, + }); + const { envelope, exitCode } = errorEnvelope("text-to-3d.create", err); + assert.deepEqual(Object.keys(envelope), SIX); + assert.equal(exitCode, EXIT_CODES.SUBMISSION_UNKNOWN); + assert.equal(envelope.ok, false); + assert.equal(envelope.error?.code, "submission_unknown"); + assert.equal(envelope.error?.http_status, null); + assert.equal(envelope.error?.retryable, false); + assert.deepEqual(envelope.error?.recovery, { action: "reconcile", automatic: false }); + assert.deepEqual(envelope.result, { submission: { state: "unknown", operation_id: "op-1" }, task: null }); +}); + +test("classifyError — API errors map status to code/exit without leaking the credential", async () => { + const cases: Array<[number, string, number]> = [ + [400, "validation", 4], + [401, "auth", 3], + [402, "credit", 9], + [404, "not_found", 5], + [429, "rate_limit", 6], + [500, "server", 1], + ]; + for (const [status, code, exit] of cases) { + const err = new MeshyApiError({ message: `meshy api ${status} on /x: nope`, status, code: code === "server" ? "server" : (code as never), path: "/x" }); + const c = classifyError(err); + assert.equal(c.code, code, String(status)); + assert.equal(c.exitCode, exit, String(status)); + assert.equal(c.httpStatus, status); + assert.ok(!JSON.stringify(c).includes("Bearer")); + } + const net = classifyError(new MeshyApiError({ message: "network error", status: 0, code: "network", path: "/x" })); + assert.equal(net.code, "network"); + assert.equal(net.exitCode, 7); + assert.equal(net.httpStatus, null); +}); + +test("classifyError — usage, commander, hinted and unknown errors", () => { + assert.equal(classifyError(new UsageError("bad")).exitCode, 2); + const cmdErr = new CommanderError(1, "commander.unknownOption", "error: unknown option '--bogus'"); + const c = classifyError(cmdErr); + assert.equal(c.code, "usage"); + assert.equal(c.exitCode, 2); + assert.equal(c.message, "unknown option '--bogus'"); + assert.equal(classifyError(authRequiredError()).code, "auth"); + assert.equal(classifyError(authRequiredError()).exitCode, 3); + const timeout = new HintedError({ message: "t", code: "step_timeout", exitCode: 8 }); + assert.equal(classifyError(timeout).code, "timed_out"); + assert.equal(classifyError(timeout).exitCode, 8); + assert.equal(classifyError(new Error("boom")).code, "internal"); + assert.equal(classifyError("str").exitCode, 1); +}); + +test("exitCodeFor — legacy mapping keeps 0.2.0 codes and adds the new ones", () => { + assert.equal(exitCodeFor(new CommanderError(1, "commander.unknownOption", "x")), 2); + assert.equal(exitCodeFor(new CommanderError(0, "commander.helpDisplayed", "")), 0); + assert.equal(exitCodeFor(new CliError({ code: "local_io", message: "x" })), 11); + assert.equal(exitCodeFor(new CliError({ code: "check_failed", message: "x" })), 12); + assert.equal(exitCodeFor(new CliError({ code: "check_unknown", message: "x" })), 13); + assert.equal(exitCodeFor(new CliError({ code: "interrupted", message: "x" })), 130); + assert.equal(exitCodeFor(new UsageError("x")), 2); +}); diff --git a/tests/slicers.test.ts b/tests/slicers.test.ts new file mode 100644 index 0000000..8327f2b --- /dev/null +++ b/tests/slicers.test.ts @@ -0,0 +1,174 @@ +/** + * Slicer registry, detection and launch (T-088..T-091) with simulated + * platforms and a fake spawn. No real application is started. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { detectSlicers, findSlicer, openInSlicer, SLICERS, type DetectionEnv, type SpawnedChild, type SpawnFn } from "../src/internal/slicers.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const LEGACY_NAMES = ["OrcaSlicer", "Bambu Studio", "Creality Print", "Elegoo Slicer", "Anycubic Slicer Next", "PrusaSlicer", "UltiMaker Cura"]; +const MULTICOLOR = new Set(["OrcaSlicer", "Bambu Studio", "Creality Print", "Elegoo Slicer", "Anycubic Slicer Next"]); + +function fakeEnv(platform: string, existing: Set, extra: Partial = {}): Partial { + return { + platform, + env: {}, + home: platform === "win32" ? "C:\\Users\\me" : "/Users/me", + exists: (p) => existing.has(p), + readdir: (dir) => [...existing].filter((p) => p.startsWith(dir + (platform === "win32" ? "\\" : "/"))).map((p) => p.slice(dir.length + 1).split(/[\\/]/)[0]!), + which: () => null, + ...extra, + }; +} + +test("T-088 registry: the seven legacy names, ids and multicolor flags", () => { + assert.deepEqual(SLICERS.map((s) => s.name), LEGACY_NAMES); + for (const s of SLICERS) assert.equal(s.multicolor, MULTICOLOR.has(s.name), s.name); + assert.equal(findSlicer("orcaslicer")?.id, "orca-slicer"); + assert.equal(findSlicer("BAMBU-STUDIO")?.name, "Bambu Studio"); + assert.equal(findSlicer("Cura"), undefined); +}); + +test("T-088 macOS: /Applications and ~/Applications bundles", () => { + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app", "/Users/me/Applications/PrusaSlicer.app"])); + const d = detectSlicers(env); + assert.equal(d.platform, "darwin"); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path, s.multicolor]), [ + ["OrcaSlicer", "/Applications/OrcaSlicer.app", true], + ["PrusaSlicer", "/Users/me/Applications/PrusaSlicer.app", false], + ]); + assert.deepEqual(d.unsupported, []); + assert.deepEqual(detectSlicers(fakeEnv("darwin", new Set())).slicers, [], "empty is a valid answer"); +}); + +test("T-088 Windows: Program Files bases, versioned glob directories and spaces", () => { + const existing = new Set([ + "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe", + "C:\\Program Files\\UltiMaker Cura 5.7\\UltiMaker-Cura.exe", + "C:\\Program Files (x86)\\BambuStudio\\bambu-studio.exe", + "C:\\Program Files\\OrcaSlicer\\orca-slicer.exe", + ]); + const env = fakeEnv("win32", existing, { + env: { ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": "C:\\Program Files (x86)" }, + readdir: (dir) => (dir === "C:\\Program Files" ? ["Creality Print 5.1", "Creality Print 4.0", "UltiMaker Cura 5.7", "OrcaSlicer"] : dir === "C:\\Program Files (x86)" ? ["BambuStudio"] : []), + exists: (p) => existing.has(p), + }); + const d = detectSlicers(env); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path]), [ + ["OrcaSlicer", "C:\\Program Files\\OrcaSlicer\\orca-slicer.exe"], + ["Bambu Studio", "C:\\Program Files (x86)\\BambuStudio\\bambu-studio.exe"], + ["Creality Print", "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe"], + ["UltiMaker Cura", "C:\\Program Files\\UltiMaker Cura 5.7\\UltiMaker-Cura.exe"], + ]); + assert.equal(d.slicers.find((s) => s.name === "Creality Print")?.multicolor, true); +}); + +test("T-088 Linux: PATH lookup for the three registered executables; the rest are explicitly unsupported", () => { + const env = fakeEnv("linux", new Set(), { which: (name) => (name === "prusa-slicer" ? "/usr/bin/prusa-slicer" : name === "orca-slicer" ? "/opt/orca/orca-slicer" : null) }); + const d = detectSlicers(env); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path]), [ + ["OrcaSlicer", "/opt/orca/orca-slicer"], + ["PrusaSlicer", "/usr/bin/prusa-slicer"], + ]); + assert.deepEqual(d.unsupported.map((u) => u.name).sort(), ["Anycubic Slicer Next", "Creality Print", "Elegoo Slicer", "UltiMaker Cura"]); + for (const u of d.unsupported) assert.match(u.reason, /no registered Linux executable/); +}); + +interface FakeSpawn { + spawn: SpawnFn; + calls: Array<{ command: string; args: string[]; options: unknown }>; +} + +function fakeSpawn(behaviour: { fail?: boolean; pid?: number; exitCode?: number | null } = {}): FakeSpawn { + const calls: FakeSpawn["calls"] = []; + const spawn: SpawnFn = (command, args, options) => { + calls.push({ command, args: [...args], options }); + const child = new EventEmitter() as SpawnedChild & { unrefCalled?: boolean }; + child.pid = behaviour.pid ?? 4242; + child.unref = () => { + child.unrefCalled = true; + }; + setImmediate(() => { + if (behaviour.fail) child.emit("error", new Error("spawn ENOENT")); + else { + child.emit("spawn"); + if (behaviour.exitCode !== undefined) setImmediate(() => child.emit("exit", behaviour.exitCode)); + } + }); + return child; + }; + return { spawn, calls }; +} + +test("T-089 Windows launches the detected absolute exe even when PATH has none", async () => { + const dir = tmpDir("slicer-"); + const file = join(dir, "model.obj"); + writeFileSync(file, "v 0 0 0\n"); + const exe = "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe"; + const env = fakeEnv("win32", new Set([exe]), { + env: { ProgramFiles: "C:\\Program Files" }, + readdir: (d) => (d === "C:\\Program Files" ? ["Creality Print 5.1"] : []), + exists: (p) => p === exe, + which: () => null, + }); + const fs = fakeSpawn(); + const launch = await openInSlicer(file, "Creality Print", { env, spawn: fs.spawn }); + assert.equal(launch.launch_requested, true); + assert.equal(fs.calls.length, 1); + assert.equal(fs.calls[0]!.command, exe); + assert.deepEqual(fs.calls[0]!.args, [file]); + assert.equal(launch.pid, 4242); + assert.equal(launch.launcher, null); +}); + +test("T-090 the file path is one argv element; shell is false; macOS uses open -a ", async () => { + const dir = tmpDir("slicer-"); + const odd = join(dir, `$(rm -rf x); it's "odd" & weird.obj`); + writeFileSync(odd, "v 0 0 0\n"); + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app"])); + const fs = fakeSpawn({ exitCode: 0 }); + const launch = await openInSlicer(odd, "orca-slicer", { env, spawn: fs.spawn, launcherTimeoutMs: 500 }); + assert.equal(fs.calls.length, 1); + assert.equal(fs.calls[0]!.command, "open"); + assert.deepEqual(fs.calls[0]!.args, ["-a", "/Applications/OrcaSlicer.app", odd]); + assert.deepEqual(fs.calls[0]!.options, { detached: true, stdio: "ignore", shell: false }); + assert.deepEqual(launch.launcher, { command: "open", exit_code: 0 }); + assert.equal(launch.slicer.name, "OrcaSlicer"); + // Linux: detected path + file, no launcher wait. + const lin = fakeEnv("linux", new Set(), { which: (n) => (n === "prusa-slicer" ? "/usr/bin/prusa-slicer" : null) }); + const fs2 = fakeSpawn(); + const l2 = await openInSlicer(odd, "PrusaSlicer", { env: lin, spawn: fs2.spawn }); + assert.deepEqual(fs2.calls[0]!.args, [odd]); + assert.equal(fs2.calls[0]!.command, "/usr/bin/prusa-slicer"); + assert.equal(l2.launcher, null); +}); + +test("T-091 unknown / not detected / missing file / spawn failure / bad extension / open failure", async () => { + const dir = tmpDir("slicer-"); + const file = join(dir, "m.stl"); + writeFileSync(file, "solid\nendsolid\n"); + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app"])); + await assert.rejects(openInSlicer(file, "SuperSlicer", { env, spawn: fakeSpawn().spawn }), UsageError); + await assert.rejects(openInSlicer(file, "PrusaSlicer", { env, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found" && /not installed/.test(e.message)); + const linux = fakeEnv("linux", new Set()); + await assert.rejects(openInSlicer(file, "Elegoo Slicer", { env: linux, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found" && /no registered Linux executable/.test(e.message)); + await assert.rejects(openInSlicer(join(dir, "missing.stl"), "OrcaSlicer", { env, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found"); + writeFileSync(join(dir, "notes.txt"), "x"); + await assert.rejects(openInSlicer(join(dir, "notes.txt"), "OrcaSlicer", { env, spawn: fakeSpawn().spawn }), UsageError); + await assert.rejects(openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn({ fail: true }).spawn }), (e: unknown) => e instanceof CliError && e.code === "local_io"); + await assert.rejects(openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn({ exitCode: 1 }).spawn, launcherTimeoutMs: 500 }), (e: unknown) => e instanceof CliError && e.code === "local_io" && /exited with code 1/.test(e.message)); + // A GUI that never exits: resolves as soon as the launcher deadline passes with exit_code null (macOS) and never waits on other platforms. + const started = Date.now(); + const l = await openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn().spawn, launcherTimeoutMs: 100 }); + assert.deepEqual(l.launcher, { command: "open", exit_code: null }); + assert.ok(Date.now() - started < 2000); + const nothingRan = fakeSpawn(); + await assert.rejects(openInSlicer(file, "PrusaSlicer", { env, spawn: nothingRan.spawn }), CliError); + assert.equal(nothingRan.calls.length, 0, "no process is started for an undetected slicer"); +}); diff --git a/tests/sse.test.ts b/tests/sse.test.ts new file mode 100644 index 0000000..33c8aa1 --- /dev/null +++ b/tests/sse.test.ts @@ -0,0 +1,232 @@ +/** + * SSE parsing and stream semantics (T-049..T-053). The parser is exercised + * with arbitrary byte splits, CRLF, UTF-8 boundaries, comments and multi-line + * data; streamTask is exercised against a loopback server. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { createSseParser, streamTask, type SseEvent } from "../src/internal/stream.js"; +import { createTransport } from "../src/client/transport.js"; +import { TaskEndpoint } from "../src/client/endpoints/base.js"; +import { MeshyApiError } from "../src/client/errors.js"; +import { CliError } from "../src/internal/errors.js"; + +function parseAll(text: string, chunkSize: number): SseEvent[] { + const bytes = Buffer.from(text, "utf8"); + const parser = createSseParser(); + const out: SseEvent[] = []; + for (let i = 0; i < bytes.length; i += chunkSize) out.push(...parser.feed(bytes.subarray(i, i + chunkSize))); + out.push(...parser.end()); + return out; +} + +const STREAM = [ + ": heartbeat comment", + "", + "event: message", + 'data: {"id":"t","status":"IN_PROGRESS",', + 'data: "progress":10,"name":"naïve ☃ 模型"}', + "id: 7", + "", + ":keep-alive", + "event: message", + 'data: {"id":"t","status":"SUCCEEDED","progress":100}', + "", +].join("\n"); + +test("T-049 parser: identical events for every chunk size, LF/CR/CRLF, multi-line data, comments", () => { + const reference = parseAll(STREAM, STREAM.length); + assert.equal(reference.length, 2); + assert.equal(reference[0]!.event, "message"); + assert.equal(JSON.parse(reference[0]!.data).name, "naïve ☃ 模型"); + assert.equal(reference[0]!.id, "7"); + assert.equal(JSON.parse(reference[1]!.data).status, "SUCCEEDED"); + for (const size of [1, 2, 3, 5, 7, 11, 64]) { + assert.deepEqual(parseAll(STREAM, size), reference, `chunk size ${size}`); + } + assert.deepEqual(parseAll(STREAM.replace(/\n/g, "\r\n"), 3), reference, "CRLF"); + assert.deepEqual(parseAll(STREAM.replace(/\n/g, "\r"), 4), reference, "CR"); + // A final event without a trailing blank line is dispatched at end(). + const noTrailer = parseAll('data: {"a":1}', 2); + assert.equal(noTrailer.length, 1); + assert.equal(noTrailer[0]!.event, "message"); +}); + +test("parser: the synthetic error fixture yields one error event with status_code 404", () => { + const fixture = readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url)); + for (const size of [1, 4, 16, fixture.length]) { + const parser = createSseParser(); + const out: SseEvent[] = []; + for (let i = 0; i < fixture.length; i += size) out.push(...parser.feed(fixture.subarray(i, i + size))); + out.push(...parser.end()); + assert.equal(out.length, 1, `chunk ${size}`); + assert.equal(out[0]!.event, "error"); + assert.deepEqual(JSON.parse(out[0]!.data), { message: "Synthetic task not found", status_code: 404 }); + } +}); + +test("parser: oversized events are a protocol error, not a truncation", () => { + const parser = createSseParser({ maxEventBytes: 64 }); + assert.throws(() => parser.feed(Buffer.from(`data: ${"x".repeat(100)}\n\n`)), (e: unknown) => e instanceof CliError && e.code === "protocol"); +}); + +// --------------------------------------------------------------------------- +// streamTask against a loopback server +// --------------------------------------------------------------------------- + +interface Scenario { + headers?: Record; + status?: number; + /** Chunks written with the given delays (ms). A null chunk closes the response. */ + script: Array<{ delay: number; chunk: string | null }>; + keepOpen?: boolean; +} + +async function serve(scenario: Scenario): Promise<{ server: Server; endpoint: TaskEndpoint; url: string; opened: number }> { + const state = { opened: 0 }; + const server = createServer((req, res) => { + state.opened += 1; + res.writeHead(scenario.status ?? 200, { "content-type": "text/event-stream", "cache-control": "no-cache", ...(scenario.headers ?? {}) }); + res.flushHeaders(); + let t = 0; + for (const step of scenario.script) { + t += step.delay; + setTimeout(() => { + if (req.destroyed) return; + if (step.chunk === null) res.end(); + else res.write(step.chunk); + }, t); + } + req.on("close", () => undefined); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const addr = server.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}`; + const transport = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + return { server, endpoint: new TaskEndpoint(transport, "/image-to-3d"), url, get opened() { return state.opened; } } as never; +} + +function closeServer(server: Server): Promise { + return new Promise((r) => { + server.closeAllConnections(); + server.close(() => r()); + }); +} + +const msg = (obj: unknown) => `event: message\ndata: ${JSON.stringify(obj)}\n\n`; + +test("T-050 terminal message closes the connection promptly and yields exactly one outcome", async () => { + const s = await serve({ + script: [ + { delay: 10, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 30 }) }, + { delay: 30, chunk: msg({ id: "t", status: "SUCCEEDED", progress: 100, model_urls: { glb: "https://assets.example.invalid/m.glb" } }) }, + // Server keeps the socket open afterwards (misbehaving server); client must not wait for it. + ], + keepOpen: true, + }); + try { + const seen: string[] = []; + const started = Date.now(); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000, onTask: (task) => { seen.push(task.status); } }); + assert.equal(out.reason, "terminal"); + assert.equal(out.task?.status, "SUCCEEDED"); + assert.deepEqual(seen, ["IN_PROGRESS", "SUCCEEDED"]); + assert.equal(out.events, 2); + assert.ok(Date.now() - started < 1500, "did not wait for the idle timeout after the terminal event"); + } finally { + await closeServer(s.server); + } +}); + +test("T-051 error event after HTTP 200 maps to not_found; non-JSON and wrong content-type are protocol errors", async () => { + const errFixture = readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url), "utf8"); + const s1 = await serve({ script: [{ delay: 5, chunk: errFixture.slice(0, 40) }, { delay: 20, chunk: errFixture.slice(40) }] }); + try { + const out = await streamTask(s1.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "error"); + assert.ok(out.error instanceof MeshyApiError); + assert.equal((out.error as MeshyApiError).status, 404); + assert.equal((out.error as MeshyApiError).code, "not_found"); + assert.equal(out.task, null); + } finally { + await closeServer(s1.server); + } + const s2 = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }, { delay: 10, chunk: "event: message\ndata: {not json\n\n" }] }); + try { + const out = await streamTask(s2.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.equal(out.task?.status, "IN_PROGRESS", "last good task is kept"); + } finally { + await closeServer(s2.server); + } + const s3 = await serve({ headers: { "content-type": "application/json" }, script: [{ delay: 5, chunk: '{"id":"t","status":"SUCCEEDED"}' }, { delay: 10, chunk: null }] }); + try { + const out = await streamTask(s3.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.equal(out.task, null, "an application/json body is never treated as a successful task"); + } finally { + await closeServer(s3.server); + } + const s4 = await serve({ script: [{ delay: 5, chunk: ": hello\n\n" }, { delay: 10, chunk: null }] }); + try { + const out = await streamTask(s4.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.match(out.error?.message ?? "", /without any task event/); + } finally { + await closeServer(s4.server); + } +}); + +test("T-051 disconnect before a terminal status keeps the last task and reports disconnected", async () => { + const s = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 55 }) }, { delay: 15, chunk: null }] }); + try { + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "disconnected"); + assert.equal(out.task?.progress, 55); + } finally { + await closeServer(s.server); + } +}); + +test("T-052 heartbeats reset the idle timer but not the total deadline; timers are cleaned up", async () => { + const s = await serve({ + script: [ + { delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 1 }) }, + ...Array.from({ length: 20 }, (_, i) => ({ delay: 60, chunk: i % 2 ? ": hb\n\n" : msg({ id: "t", status: "IN_PROGRESS", progress: 1 }) })), + ], + keepOpen: true, + }); + try { + const started = Date.now(); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 400, idleTimeoutMs: 200 }); + assert.equal(out.reason, "timeout", "heartbeats every 60ms kept idle alive; the total deadline still fired"); + const elapsed = Date.now() - started; + assert.ok(elapsed >= 350 && elapsed < 2000, `elapsed ${elapsed}`); + const idle = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }], keepOpen: true }); + try { + const out2 = await streamTask(idle.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 150 }); + assert.equal(out2.reason, "idle_timeout"); + assert.equal(out2.task?.status, "IN_PROGRESS"); + } finally { + await closeServer(idle.server); + } + } finally { + await closeServer(s.server); + } +}); + +test("an external abort mid-stream is reported as interrupted and closes the connection", async () => { + const s = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }], keepOpen: true }); + try { + const ac = new AbortController(); + setTimeout(() => ac.abort(), 60); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 5000, signal: ac.signal }); + assert.equal(out.reason, "interrupted"); + assert.equal(out.task?.status, "IN_PROGRESS"); + } finally { + await closeServer(s.server); + } +}); diff --git a/tests/task-lifecycle.test.ts b/tests/task-lifecycle.test.ts new file mode 100644 index 0000000..084312d --- /dev/null +++ b/tests/task-lifecycle.test.ts @@ -0,0 +1,293 @@ +/** + * Task verbs end to end (T-006, T-007, T-009, T-040, T-043..T-045, T-047, + * T-048, T-053): real subprocesses against a loopback API that records every + * request, so "exactly one POST" is asserted, not assumed. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +function taskBody(status: string, extra: Record = {}): Record { + return { id: "task-1", type: "text-to-3d-preview", status, progress: status === "SUCCEEDED" ? 100 : 40, created_at: 1, started_at: 2, finished_at: 0, expires_at: 0, task_error: null, ...extra }; +} + +function envOf(api: MockApi, extra: Record = {}) { + return api.env(extra); +} + +test("T-006 get: every valid status is a successful query (exit 0) in v1 and legacy; FAILED stays exit 1 in legacy only", async () => { + const statuses = ["PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "CANCELED", "SOMETHING_NEW"]; + let current = "PENDING"; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v2/text-to-3d/task-1") return jsonReply(res, 200, taskBody(current, { face_count: 0, consumed_credits: 0, novel: { x: 1 } })); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + for (const status of statuses) { + current = status; + const v1 = await runCli(["text-to-3d", "get", "task-1", "--output-schema", "v1", "--include-raw"], { env: envOf(api) }); + assert.equal(v1.code, 0, `${status}: ${v1.stderr}`); + const env = parseSingleJson(v1.stdout) as { ok: boolean; command: string; result: { task: Record; submission: unknown; downloads: { state: string } } }; + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env.ok, true); + assert.equal(env.command, "text-to-3d.get"); + assert.equal(env.result.task["status"], status, "status is preserved verbatim"); + assert.equal(env.result.task["face_count"], 0, "0 stays 0"); + assert.equal(env.result.task["consumed_credits"], 0); + assert.deepEqual((env.result.task["raw"] as Record)["novel"], { x: 1 }); + assert.equal(env.result.downloads.state, "not_requested"); + + const legacy = await runCli(["text-to-3d", "get", "task-1"], { env: envOf(api) }); + const expected = status === "FAILED" || status === "CANCELED" ? 1 : 0; + assert.equal(legacy.code, expected, `legacy ${status}: ${legacy.stderr}`); + const payload = parseSingleJson(legacy.stdout) as Record; + assert.equal(payload["status"], status); + assert.equal(payload["resource"], "text-to-3d"); + } + // Missing face_count is null, never 0. + current = "SUCCEEDED"; + const api2 = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("SUCCEEDED"))); + try { + const r = await runCli(["text-to-3d", "get", "task-1", "--output-schema", "v1"], { env: envOf(api2) }); + const env = parseSingleJson(r.stdout) as { result: { task: Record } }; + assert.equal(env.result.task["face_count"], null); + assert.equal(env.result.task["consumed_credits"], null); + } finally { + await api2.close(); + } + } finally { + await api.close(); + } +}); + +test("T-040 create --async: exactly one POST, zero GETs, accepted + id + operation id, journal written", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/v2/text-to-3d") return jsonReply(res, 200, { result: "task-new" }); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const env = envOf(api); + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a cactus", "--async", "--output-schema", "v1"], { env }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; result: { task: unknown; submission: { state: string; operation_id: string; task_id: string }; task_id: string; next: { get: string; wait: string; stream: string } } }; + assert.equal(out.ok, true); + assert.equal(out.result.task, null, "accepted but not queried: no fabricated PENDING task"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "task-new"); + assert.match(out.result.submission.operation_id, /^[0-9a-f-]{36}$/); + assert.match(out.result.next.wait, /text-to-3d wait task-new/); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + assert.deepEqual(api.requests[0]!.json, { mode: "preview", prompt: "a cactus", target_formats: ["glb"] }); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const ops = readdirSync(join(configDir, "operations")).filter((f) => f.endsWith(".json")); + assert.equal(ops.length, 1); + const rec = JSON.parse(readFileSync(join(configDir, "operations", ops[0] ?? ""), "utf8")) as { state: string; task_id: string }; + assert.equal(rec.state, "accepted"); + assert.equal(rec.task_id, "task-new"); + + // Legacy async keeps the 0.2.0 shape (plus operation_id) and also makes exactly one request. + const legacy = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a cactus", "--async"], { env: envOf(api) }); + assert.equal(legacy.code, 0, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as Record; + assert.equal(lp["task_id"], "task-new"); + assert.equal(lp["status"], "PENDING"); + assert.match(String(lp["hint"]), /text-to-3d wait task-new/); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "POST"]); + } finally { + await api.close(); + } +}); + +test("T-043/T-044 create: 5xx and malformed 2xx are submission_unknown (exit 10) after exactly one POST; 4xx is a definite rejection", async () => { + let mode: "500" | "malformed" | "400" | "hang" = "500"; + const api = await startMockApi((req, res) => { + if (req.method !== "POST") return jsonReply(res, 404, { message: "nope" }); + if (mode === "500") return jsonReply(res, 500, { message: "boom" }); + if (mode === "malformed") return jsonReply(res, 200, { unexpected: true }); + if (mode === "400") return jsonReply(res, 400, { message: "prompt too long" }); + // hang: accept the request, never answer → client read timeout + }); + try { + for (const m of ["500", "malformed", "hang"] as const) { + mode = m; + api.requests.length = 0; + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--async", "--output-schema", "v1"], { env: envOf(api, { MESHY_READ_TIMEOUT_MS: "500" }), timeoutMs: 15000 }); + assert.equal(r.code, 10, `${m}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; recovery: { action: string; automatic: boolean; command: string } }; result: { submission: { state: string; operation_id: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "submission_unknown"); + assert.equal(out.error.recovery.action, "reconcile"); + assert.equal(out.error.recovery.automatic, false); + assert.ok(!/create again|re-run|retry the create/i.test(r.stdout + r.stderr), "no advice to resubmit"); + assert.equal(out.result.submission.state, "unknown"); + assert.ok(out.result.submission.operation_id); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, `${m}: exactly one POST`); + } + mode = "400"; + api.requests.length = 0; + const rejected = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--async", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(rejected.code, 4, rejected.stderr); + const out = parseSingleJson(rejected.stdout) as { error: { code: string; http_status: number } }; + assert.equal(out.error.code, "validation"); + assert.equal(out.error.http_status, 400); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-046 --operation-id: the second run with the same request replays the journal and sends nothing", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-once" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const env = envOf(api); + const args = ["text-to-3d", "create", "--mode", "preview", "--prompt", "same", "--async", "--operation-id", "op-fixed", "--output-schema", "v1"]; + const first = await runCli(args, { env }); + assert.equal(first.code, 0, first.stderr); + const second = await runCli(args, { env }); + assert.equal(second.code, 0, second.stderr); + const out = parseSingleJson(second.stdout) as { result: { submission: { task_id: string } }; warnings: Array<{ code: string }> }; + assert.equal(out.result.submission.task_id, "task-once"); + assert.equal(out.warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.length, 1, "no second POST"); + // Same id, different payload → conflict, still no POST. + const conflict = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "different", "--async", "--operation-id", "op-fixed", "--output-schema", "v1"], { env }); + assert.equal(conflict.code, 2, conflict.stderr); + assert.equal((parseSingleJson(conflict.stdout) as { error: { code: string } }).error.code, "operation_conflict"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-007 wait: FAILED with null task_error exits 1 with the task preserved; SUCCEEDED exits 0", async () => { + let n = 0; + const api = await startMockApi((req, res) => { + if (req.method !== "GET") return jsonReply(res, 404, { message: "nope" }); + n += 1; + return jsonReply(res, 200, taskBody(n < 3 ? "IN_PROGRESS" : "FAILED", { task_error: null })); + }); + try { + const r = await runCli(["text-to-3d", "wait", "task-1", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(r.code, 1, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string }; result: { task: { task_id: string; status: string }; wait: { polls: number } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "task_failed"); + assert.equal(out.result.task.task_id, "task-1"); + assert.equal(out.result.task.status, "FAILED"); + assert.equal(out.result.wait.polls, 3); + } finally { + await api.close(); + } +}); + +test("T-047 wait --timeout: 0 = one query then exit 8; negative/NaN/Infinity make no request", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("IN_PROGRESS"))); + try { + const zero = await runCli(["text-to-3d", "wait", "task-1", "--timeout", "0", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(zero.code, 8, zero.stderr); + const out = parseSingleJson(zero.stdout) as { error: { code: string; recovery: { command: string } }; result: { task: { status: string }; wait: { timed_out: boolean; polls: number } } }; + assert.equal(out.error.code, "timed_out"); + assert.equal(out.result.task.status, "IN_PROGRESS"); + assert.equal(out.result.wait.timed_out, true); + assert.equal(out.result.wait.polls, 1); + assert.match(out.error.recovery.command, /wait task-1/); + assert.equal(api.requests.length, 1); + for (const bad of ["-1", "NaN", "Infinity", "abc", ""]) { + const r = await runCli(["text-to-3d", "wait", "task-1", "--timeout", bad, "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(r.code, 2, `${bad}: ${r.stderr}`); + } + assert.equal(api.requests.length, 1, "invalid timeouts never reach the API"); + } finally { + await api.close(); + } +}); + +test("T-009 get -o: non-terminal → downloads.not_ready, exit 0, no asset request; --save-json stores the raw task", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("IN_PROGRESS", { model_urls: { glb: "http://127.0.0.1:9/never.glb" } }))); + try { + const dir = tmpDir(); + const r = await runCli(["text-to-3d", "get", "task-1", "-o", join(dir, "out"), "--save-json", join(dir, "task.json"), "--output-schema", "v1"], { env: envOf(api), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { result: { downloads: { state: string }; saved_json: { path: string } } }; + assert.equal(out.result.downloads.state, "not_ready"); + assert.deepEqual(JSON.parse(readFileSync(join(dir, "task.json"), "utf8")).id, "task-1"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-048 SIGINT during wait exits 130, sends no DELETE and no second POST, keeps the task id", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-slow" }); + return jsonReply(res, 200, { ...taskBody("IN_PROGRESS"), id: "task-slow" }); + }); + try { + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "slow", "--output-schema", "v1"], { env: envOf(api, { MESHY_POLL_INTERVAL_MS: "300" }), sigintAfterMs: 1200, timeoutMs: 15000 }); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { error: { code: string; recovery: { command: string } }; result: { task_id: string; submission: { state: string; task_id: string } } }; + assert.equal(out.error.code, "interrupted"); + assert.equal(out.result.task_id, "task-slow"); + assert.equal(out.result.submission.state, "accepted"); + assert.match(out.error.recovery.command, /wait task-slow/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + } finally { + await api.close(); + } +}); + +test("T-053 stream: ndjson emits task events + one outcome; json emits one envelope; error event → exit 5", async () => { + const sse = (obj: unknown) => `event: message\ndata: ${JSON.stringify(obj)}\n\n`; + const api = await startMockApi((req, res, raw) => { + if (req.path === "/openapi/v2/text-to-3d/task-1/stream") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(": hello\n\n"); + setTimeout(() => res.write(sse(taskBody("IN_PROGRESS"))), 20); + setTimeout(() => res.write(sse(taskBody("SUCCEEDED", { model_urls: { glb: "https://assets.example.invalid/m.glb" } }))), 60); + raw.on("close", () => res.end()); + return; + } + if (req.path === "/openapi/v2/text-to-3d/missing/stream") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url))); + return; + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const nd = await runCli(["text-to-3d", "stream", "task-1", "--format", "ndjson", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(nd.code, 0, nd.stderr); + const lines = parseNdjson(nd.stdout) as Array<{ event: string; sequence: number; ok: boolean; result: { task: { status: string } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "task", "outcome"]); + assert.deepEqual(lines.map((l) => l.sequence), [1, 2, 3]); + assert.equal(lines[2]!.ok, true); + assert.equal(lines[2]!.result.task.status, "SUCCEEDED"); + for (const l of lines) assert.deepEqual(Object.keys(l).slice(0, 6), SIX); + assert.equal(nd.stderr.trim(), "", "ndjson keeps stderr quiet"); + + const js = await runCli(["text-to-3d", "stream", "task-1", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(js.code, 0, js.stderr); + const env = parseSingleJson(js.stdout) as { result: { stream: { events: number; ended: string } } }; + assert.equal(env.result.stream.events, 2); + assert.equal(env.result.stream.ended, "terminal"); + assert.match(js.stderr, /IN_PROGRESS/, "progress goes to stderr in json mode"); + + const missing = await runCli(["text-to-3d", "stream", "missing", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(missing.code, 5, missing.stderr); + const errEnv = parseSingleJson(missing.stdout) as { ok: boolean; error: { code: string; http_status: number } }; + assert.equal(errEnv.ok, false); + assert.equal(errEnv.error.code, "not_found"); + assert.equal(errEnv.error.http_status, 404); + } finally { + await api.close(); + } +}); diff --git a/tests/task-view.test.ts b/tests/task-view.test.ts new file mode 100644 index 0000000..8ccbe37 --- /dev/null +++ b/tests/task-view.test.ts @@ -0,0 +1,69 @@ +/** + * TaskView normalisation (T-008): 0 stays 0, missing stays null, extra fields + * survive in raw, and every saved-JSON shape yields the same task. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { extractTaskObject, toTaskView } from "../src/internal/task-view.js"; +import { requireTaskResource } from "../src/client/resource-registry.js"; + +const rigFixture = JSON.parse(readFileSync(new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url), "utf8")) as Record; + +test("face_count and consumed_credits: 0 is 0, null is null, missing is null", () => { + const base = { id: "t", status: "SUCCEEDED" }; + assert.equal(toTaskView({ ...base, face_count: 0, consumed_credits: 0 }).face_count, 0); + assert.equal(toTaskView({ ...base, face_count: 0, consumed_credits: 0 }).consumed_credits, 0); + assert.equal(toTaskView({ ...base, face_count: null }).face_count, null); + assert.equal(toTaskView(base).face_count, null); + assert.equal(toTaskView(base).consumed_credits, null); + assert.equal(toTaskView({ ...base, face_count: "1234" }).face_count, null, "strings are not silently parsed"); + assert.equal(toTaskView({ ...base, face_count: Number.NaN }).face_count, null); +}); + +test("missing optional fields are null/empty, never defaulted to 0 or ''", () => { + const v = toTaskView({ id: "t" }); + assert.equal(v.status, null); + assert.equal(v.progress, null); + assert.equal(v.created_at, null); + assert.equal(v.type, null); + assert.deepEqual(v.model_urls, {}); + assert.deepEqual(v.image_urls, []); + assert.equal(v.thumbnail_urls, null); + assert.equal(v.task_error, null); +}); + +test("descriptor supplies resource/endpoint; include-raw keeps every extra field and the original shape", () => { + const raw = { ...rigFixture, novel_field: { deep: [1, 2] } }; + const v = toTaskView(raw, { descriptor: requireTaskResource("rigging"), includeRaw: true }); + assert.equal(v.resource, "rigging"); + assert.equal(v.endpoint, "/openapi/v1/rigging"); + assert.equal(v.face_count, 250000); + assert.equal(v.consumed_credits, 5); + assert.deepEqual(v.result?.["basic_animations"], (rigFixture["result"] as Record)["basic_animations"]); + assert.deepEqual((v.raw as Record)["novel_field"], { deep: [1, 2] }); + assert.equal("raw" in toTaskView(raw), false); +}); + +test("thumbnail_urls object keyed by view is preserved; non-string entries become null", () => { + const v = toTaskView({ id: "t", thumbnail_urls: { front: "https://a/f.png", back: 3 } }); + assert.deepEqual(v.thumbnail_urls, { front: "https://a/f.png", back: null }); +}); + +test("extractTaskObject accepts API task, meta.json, v1 envelope with raw and plain v1 result", () => { + const api = extractTaskObject(rigFixture); + assert.equal(api?.source, "api"); + const meta = extractTaskObject({ resource: "rigging", task: rigFixture, saved_files: [] }); + assert.equal(meta?.source, "meta.json"); + assert.equal(meta?.task["id"], "fixture-rig-1"); + const view = toTaskView(rigFixture, { includeRaw: true }); + const env = extractTaskObject({ schema_version: "meshy.cli/v1", command: "rigging.get", ok: true, result: { task: view }, error: null, warnings: [] }); + assert.equal(env?.source, "v1-envelope"); + assert.equal(env?.task["id"], "fixture-rig-1"); + const plain = extractTaskObject({ result: { task: toTaskView(rigFixture) } }); + assert.equal(plain?.source, "v1-result"); + assert.equal(plain?.task["id"], "fixture-rig-1"); + assert.equal(extractTaskObject({ hello: 1 }), null); + assert.equal(extractTaskObject([1]), null); +}); diff --git a/tests/transport.test.ts b/tests/transport.test.ts new file mode 100644 index 0000000..6d8a4af --- /dev/null +++ b/tests/transport.test.ts @@ -0,0 +1,140 @@ +/** + * Transport boundaries (T-031, T-032): credential scope, redirect refusal, + * body-inclusive deadlines, body caps, and the not-sent vs unknown phase. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { createTransport, resolveApiUrl, TransportError } from "../src/client/transport.js"; +import { MeshyApiError } from "../src/client/errors.js"; + +const BASE = "https://api.example.com/openapi/v1"; + +test("resolveApiUrl — relative paths stay inside the base; escapes are refused before any request", () => { + assert.equal(resolveApiUrl(BASE, "/balance").href, "https://api.example.com/openapi/v1/balance"); + assert.equal(resolveApiUrl(BASE, "text-to-3d/abc%2Fslash").href, "https://api.example.com/openapi/v1/text-to-3d/abc%2Fslash"); + assert.equal(resolveApiUrl(BASE, "https://api.example.com/openapi/v1/rigging/x").href, "https://api.example.com/openapi/v1/rigging/x"); + for (const bad of [ + "//evil.example/openapi/v1/balance", + "https://evil.example/openapi/v1/balance", + "https://api.example.com/openapi/v2/text-to-3d", + "https://user:pw@api.example.com/openapi/v1/balance", + "ftp://api.example.com/openapi/v1/balance", + "/../v2/text-to-3d", + "/text-to-3d/../../admin", + ]) { + assert.throws(() => resolveApiUrl(BASE, bad), (e: unknown) => e instanceof TransportError && e.phase === "validate", bad); + } +}); + +async function listen(handler: Parameters[1]): Promise<{ server: Server; url: string }> { + const server = createServer(handler); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const addr = server.address(); + if (!addr || typeof addr !== "object") throw new Error("no addr"); + return { server, url: `http://127.0.0.1:${addr.port}` }; +} + +function close(server: Server): Promise { + return new Promise((r) => { + server.closeAllConnections(); + server.close(() => r()); + }); +} + +test("authenticated transport sends the bearer, public transport never does", async () => { + const seen: Array = []; + const { server, url } = await listen((req, res) => { + seen.push(req.headers["authorization"]); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + try { + const auth = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + const pub = createTransport({ baseUrl: `${url}/web/public`, readTimeoutMs: 5000 }); + await auth.requestJson("GET", "/balance"); + await pub.requestJson("GET", "/animations/resources", { headers: { Authorization: "Bearer leaked", Cookie: "a=b" } }); + assert.deepEqual(seen, ["Bearer msy_k", undefined]); + } finally { + await close(server); + } +}); + +test("redirects are refused rather than followed with a credential", async () => { + const hits: string[] = []; + const { server, url } = await listen((req, res) => { + hits.push(req.url ?? ""); + res.writeHead(302, { location: "https://evil.example/steal" }); + res.end(); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/balance"), (e: unknown) => e instanceof TransportError && e.phase === "response" && /redirect/.test(e.message)); + assert.deepEqual(hits, ["/openapi/v1/balance"]); + } finally { + await close(server); + } +}); + +test("the deadline covers the body: headers then a stalled body time out and report phase timeout", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"partial":'); + // never end + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 300 }); + const started = Date.now(); + await assert.rejects(t.requestJson("GET", "/slow"), (e: unknown) => e instanceof TransportError && e.phase === "timeout"); + assert.ok(Date.now() - started < 5000); + } finally { + await close(server); + } +}); + +test("oversized bodies fail explicitly instead of being truncated", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ blob: "x".repeat(5000) })); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/big", { maxBodyBytes: 1000 }), (e: unknown) => e instanceof MeshyApiError && /exceeds 1000 bytes/.test(e.message)); + const ok = await t.requestJson("GET", "/big"); + assert.equal((ok.json as { blob: string }).blob.length, 5000); + } finally { + await close(server); + } +}); + +test("connection refused is classified as never sent; an abort mid-flight is 'aborted'", async () => { + const { server, url } = await listen(() => undefined); + await close(server); // port is now closed + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 2000 }); + await assert.rejects(t.requestJson("POST", "/text-to-3d", { body: {} }), (e: unknown) => e instanceof TransportError && e.phase === "connect" && e.neverSent); + + const stalled = await listen(() => undefined); + try { + const t2 = createTransport({ baseUrl: `${stalled.url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + const ac = new AbortController(); + const p = t2.requestJson("POST", "/text-to-3d", { body: {}, signal: ac.signal }); + setTimeout(() => ac.abort(), 50); + await assert.rejects(p, (e: unknown) => e instanceof TransportError && e.phase === "aborted" && !e.neverSent); + } finally { + await close(stalled.server); + } +}); + +test("non-2xx bodies map to MeshyApiError with the status and message", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Task not found" })); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/uv-unwrap/x"), (e: unknown) => e instanceof MeshyApiError && e.status === 404 && e.code === "not_found" && /Task not found/.test(e.message)); + } finally { + await close(server); + } +}); diff --git a/tests/uv-creative-lab.test.ts b/tests/uv-creative-lab.test.ts new file mode 100644 index 0000000..7850f29 --- /dev/null +++ b/tests/uv-creative-lab.test.ts @@ -0,0 +1,287 @@ +/** + * UV Unwrap (T-021), Creative Lab routing/validation (T-022..T-025), payload + * merge semantics (T-028) and make async/stop-after-first (T-041, T-042). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +function glbBytes(): Buffer { + const buf = Buffer.alloc(12); + buf.write("glTF", 0, "ascii"); + buf.writeUInt32LE(2, 4); + buf.writeUInt32LE(12, 8); + return buf; +} + +test("T-021 uv-unwrap: one source accepted, both/none/non-GLB refused before any request; exact v1 route", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/v1/uv-unwrap") return jsonReply(res, 200, { result: "uv-1" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + const glb = join(dir, "m.glb"); + writeFileSync(glb, glbBytes()); + const obj = join(dir, "m.obj"); + writeFileSync(obj, "v 0 0 0\n"); + + const byTask = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--async"], { env: api.env(), cwd: dir }); + assert.equal(byTask.code, 0, byTask.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "src-1" }); + const out = parseSingleJson(byTask.stdout) as { command: string; result: { submission: { task_id: string } } }; + assert.equal(out.command, "uv-unwrap.create"); + assert.equal(out.result.submission.task_id, "uv-1"); + + const byFile = await runCli(["uv-unwrap", "create", "--model-url", glb, "--async"], { env: api.env(), cwd: dir }); + assert.equal(byFile.code, 0, byFile.stderr); + assert.match(String((api.requests.at(-1)!.json as { model_url: string }).model_url), /^data:model\/gltf-binary;base64,/); + + const before = api.requests.length; + const both = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--model-url", glb, "--async"], { env: api.env(), cwd: dir }); + assert.equal(both.code, 2, both.stderr); + const bothData = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--data", '{"model_url":"https://x.example/m.glb"}', "--async"], { env: api.env(), cwd: dir }); + assert.equal(bothData.code, 2, bothData.stderr); + const none = await runCli(["uv-unwrap", "create", "--async"], { env: api.env(), cwd: dir }); + assert.equal(none.code, 2); + const notGlb = await runCli(["uv-unwrap", "create", "--model-url", obj, "--async"], { env: api.env(), cwd: dir }); + assert.equal(notGlb.code, 2, notGlb.stderr); + assert.match((parseSingleJson(notGlb.stdout) as { error: { message: string } }).error.message, /glb only/); + assert.equal(api.requests.length, before, "invalid combinations never reach the API"); + + // 404 is reported as not_found without guessing why. + const gated = await startMockApi((_req, res) => jsonReply(res, 404, { message: "Not Found" })); + try { + const r = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--async"], { env: gated.env() }); + assert.equal(r.code, 5, r.stderr); + const e = parseSingleJson(r.stdout) as { error: { code: string; message: string } }; + assert.equal(e.error.code, "not_found"); + assert.ok(!/rollout|unauthori|not enabled/i.test(e.error.message)); + } finally { + await gated.close(); + } + // list/get/stream/delete routes exist. + const list = await runCli(["uv-unwrap", "list"], { env: api.env() }); + assert.equal(api.requests.at(-1)!.path, "/openapi/v1/uv-unwrap"); + assert.equal(list.code, 5); // mock answers 404 for GET list; the route is what matters here + } finally { + await api.close(); + } +}); + +test("T-022/T-025 Creative Lab: exact product/v1/stage paths and per-product payloads", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && /^\/openapi\/creative-lab\/(figure|lamp|keychain|fridge-magnet)\/v1\/(prototype|build)$/.test(req.path)) return jsonReply(res, 200, { result: "cl-1" }); + if (req.method === "GET" && /\/v1\/(prototype|build)\/cl-1$/.test(req.path)) return jsonReply(res, 200, { id: "cl-1", status: "SUCCEEDED", progress: 100, type: "creative-lab-x" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + const png = join(dir, "p.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + + for (const product of ["figure", "lamp", "keychain", "fridge-magnet"]) { + const r = await runCli(["creative-lab", product, "prototype", "create", "--image-url", png, "--name", "demo", "--async"], { env: api.env(), cwd: dir }); + assert.equal(r.code, 0, `${product}: ${r.stderr}`); + const req = api.requests.at(-1)!; + assert.equal(req.path, `/openapi/creative-lab/${product}/v1/prototype`); + const body = req.json as Record; + assert.match(String(body.image_url), /^data:image\/png;base64,/); + assert.equal(body.name, "demo"); + assert.equal("remove_background" in body, false, "not sent unless asked"); + const out = parseSingleJson(r.stdout) as { command: string }; + assert.equal(out.command, `creative-lab.${product}.prototype.create`); + + const g = await runCli(["creative-lab", product, "build", "get", "cl-1"], { env: api.env() }); + assert.equal(g.code, 0, g.stderr); + assert.equal(api.requests.at(-1)!.path, `/openapi/creative-lab/${product}/v1/build/cl-1`); + } + + // lamp prototype: image_subject; deprecated text refused; remove-background flag sent as true. + const lamp = await runCli(["creative-lab", "lamp", "prototype", "create", "--image-url", png, "--image-subject", "landscape", "--remove-background", "--async"], { env: api.env(), cwd: dir }); + assert.equal(lamp.code, 0, lamp.stderr); + assert.equal((api.requests.at(-1)!.json as Record).image_subject, "landscape"); + assert.equal((api.requests.at(-1)!.json as Record).remove_background, true); + const n = api.requests.length; + const lampText = await runCli(["creative-lab", "lamp", "prototype", "create", "--data", '{"text":"a lamp"}', "--async"], { env: api.env(), cwd: dir }); + assert.equal(lampText.code, 2, lampText.stderr); + assert.equal(api.requests.length, n); + + // lamp build: options + output.format; include_result_json needs zip. + const lampBuild = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--model-format", "zip", "--include-result-json", "--options", '{"diameter_mm":180,"light_source_preset":"none"}', "--async"], { env: api.env() }); + assert.equal(lampBuild.code, 0, lampBuild.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "cl-1", options: { diameter_mm: 180, light_source_preset: "none", include_result_json: true }, output: { format: "zip" } }); + const lampBad = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--include-result-json", "--async"], { env: api.env() }); + assert.equal(lampBad.code, 2, lampBad.stderr); + const lampRange = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--options", '{"diameter_mm":10}', "--async"], { env: api.env() }); + assert.equal(lampRange.code, 2, lampRange.stderr); + assert.match((parseSingleJson(lampRange.stdout) as { error: { message: string } }).error.message, /diameter_mm/); + + // keychain build: relief options with explicit false preserved; obj format allowed; figure has no options. + const kc = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "cl-1", "--model-format", "obj", "--data", '{"options":{"has_closed_back":false,"remove_background":false,"badge_shape":"star"}}', "--async"], { env: api.env() }); + assert.equal(kc.code, 0, kc.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "cl-1", options: { has_closed_back: false, remove_background: false, badge_shape: "star" }, output: { format: "obj" } }); + const kcBad = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "cl-1", "--data", '{"options":{"badge_shape":"triangle"}}', "--async"], { env: api.env() }); + assert.equal(kcBad.code, 2, kcBad.stderr); + const fig = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "cl-1", "--data", '{"options":{"x":1}}', "--async"], { env: api.env() }); + assert.equal(fig.code, 2, fig.stderr); + const figFmt = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "cl-1", "--model-format", "glb", "--async"], { env: api.env() }); + assert.equal(figFmt.code, 2, "figure build has no --model-format"); + const noStage = await runCli(["creative-lab", "figure", "get", "cl-1"], { env: api.env() }); + assert.equal(noStage.code, 2, "there is no stage-less get"); + const longName = await runCli(["creative-lab", "figure", "prototype", "create", "--image-url", png, "--name", "x".repeat(101), "--async"], { env: api.env(), cwd: dir }); + assert.equal(longName.code, 2); + } finally { + await api.close(); + } +}); + +test("T-023 Creative Lab: injected product/stage tokens never build a path", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { result: "x" })); + try { + for (const args of [ + ["creative-lab", "../figure", "prototype", "create", "--image-url", "https://x.example/a.png", "--async"], + ["creative-lab", "figure", "../build", "create", "--input-task-id", "a", "--async"], + ["creative-lab", "keycap", "prototype", "create", "--image-url", "https://x.example/a.png", "--async"], + ["creative-lab", "figure prototype", "create"], + ]) { + const r = await runCli(args, { env: api.env() }); + assert.equal(r.code, 2, args.join(" ")); + } + assert.equal(api.requests.length, 0); + } finally { + await api.close(); + } +}); + +test("T-024 Creative Lab build: server rejections are preserved, no retry and no prototype re-creation", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/creative-lab/figure/v1/build") return jsonReply(res, 404, { message: "prototype task not found" }); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const r = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "webapp-proto", "--async"], { env: api.env() }); + assert.equal(r.code, 5, r.stderr); + const out = parseSingleJson(r.stdout) as { error: { code: string; message: string } }; + assert.equal(out.error.code, "not_found"); + assert.match(out.error.message, /prototype task not found/); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/creative-lab/figure/v1/build"]); + } finally { + await api.close(); + } +}); + +test("T-028 --data merge: flags beat JSON, JSON beats defaults, explicit false/0 survive, arrays replace, non-object refused", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "t" }) : jsonReply(res, 404, {}))); + try { + const r = await runCli( + ["text-to-3d", "create", "--mode", "refine", "--preview-task-id", "p1", "--texture-resolution", "2k", "--data", '{"enable_pbr":false,"texture_resolution":"8k","target_formats":["obj","fbx"],"seed":0}', "--async"], + { env: api.env() }, + ); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(api.requests[0]!.json, { + mode: "refine", + preview_task_id: "p1", + enable_pbr: false, // --data switches a default off + texture_resolution: "2k", // typed flag wins over --data + remove_lighting: true, // untouched default + target_formats: ["obj", "fbx"], // array replaced wholesale + seed: 0, // explicit 0 kept + }); + const arr = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--data", "[1]", "--async"], { env: api.env() }); + assert.equal(arr.code, 2); + const bad = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--data", "{oops", "--async"], { env: api.env() }); + assert.equal(bad.code, 2); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-041/T-042 make: --async is one POST and zero polls with pending_steps; --stop-after-first polls step 1 only; both flags refused", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "prev-1" }); + gets += 1; + return jsonReply(res, 200, { id: "prev-1", status: "SUCCEEDED", progress: 100, type: "text-to-3d-preview" }); + }); + try { + const a = await runCli(["make", "a red sports car", "--async", "--output-schema", "v1"], { env: api.env() }); + assert.equal(a.code, 0, a.stderr); + const out = parseSingleJson(a.stdout) as { result: { submitted: { task_id: string }; pending_steps: Array<{ step: number; action: string; command: string | null }>; task: unknown } }; + assert.equal(out.result.submitted.task_id, "prev-1"); + assert.equal(out.result.task, null); + assert.equal(out.result.pending_steps.length, 1); + assert.equal(out.result.pending_steps[0]!.action, "refine"); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + assert.equal(gets, 0); + + api.requests.length = 0; + const legacyAsync = await runCli(["make", "a red sports car", "--async"], { env: api.env() }); + assert.equal(legacyAsync.code, 0, legacyAsync.stderr); + const lp = parseSingleJson(legacyAsync.stdout) as Record; + assert.equal(lp["task_id"], "prev-1"); + assert.equal(lp["submitted"], "preview"); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + + api.requests.length = 0; + const stop = await runCli(["make", "a red sports car", "--stop-after-first", "--output-schema", "v1"], { env: api.env() }); + assert.equal(stop.code, 0, stop.stderr); + const so = parseSingleJson(stop.stdout) as { result: { stopped_after: { action: string; status: string }; resume: string } }; + assert.equal(so.result.stopped_after.action, "preview"); + assert.equal(so.result.stopped_after.status, "SUCCEEDED"); + assert.match(so.result.resume, /--preview-task-id prev-1/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "refine was not started"); + assert.ok(api.requests.some((q) => q.method === "GET")); + + const both = await runCli(["make", "a red sports car", "--async", "--stop-after-first"], { env: api.env() }); + assert.equal(both.code, 2); + + // Image route with a URL: async is one POST, no preflight of the mock is needed for data URIs. + api.requests.length = 0; + const dir = tmpDir(); + const png = join(dir, "cat.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + const img = await runCli(["make", png, "--async", "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(img.code, 0, img.stderr); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/v1/image-to-3d"]); + assert.equal((parseSingleJson(img.stdout) as { result: { pending_steps: unknown[] } }).result.pending_steps.length, 0); + } finally { + await api.close(); + } +}); + +test("T-105 a stored profile is never sent to a Creative Lab base on another origin; an explicit key may be", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "cl-x" }) : jsonReply(res, 404, {}))); + const other = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "cl-y" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + const credFile = join(dir, "credentials.json"); + writeFileSync(credFile, JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "api_key", api_key: "msy_stored_profile_key", created_at: 1 } } })); + const env = api.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: credFile, MESHY_BASE_URL_CREATIVE_LAB: `${other.url}/openapi/creative-lab` }); + const refused = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async"], { env, cwd: dir }); + assert.equal(refused.code, 3, refused.stderr); + const out = parseSingleJson(refused.stdout) as { error: { code: string; message: string } }; + assert.equal(out.error.code, "auth"); + assert.match(out.error.message, /different origin/); + assert.equal(other.requests.length, 0, "the stored profile never left for the other origin"); + assert.equal(api.requests.length, 0); + // Same-origin derived base with the stored profile works. + const same = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async"], { env: api.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: credFile }), cwd: dir }); + assert.equal(same.code, 0, same.stderr); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_stored_profile_key"); + // An explicit key is the user's choice and may go to the explicit origin. + const explicit = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async", "--api-key", "msy_explicit"], { env, cwd: dir }); + assert.equal(explicit.code, 0, explicit.stderr); + assert.equal(other.requests.length, 1); + assert.equal(other.requests[0]!.headers["authorization"], "Bearer msy_explicit"); + assert.equal(other.requests[0]!.path, "/openapi/creative-lab/figure/v1/build"); + } finally { + await api.close(); + await other.close(); + } +});