PCLI2

1.21.0 - 2026-09-04

v1.21.0 Sep 4 2026 at 20:44 UTC

Fixed

  • The active tenant follows the environment - One tenant selection was shared by every environment, so env use staging left production's tenant UUID active and every command failed with "Tenant not found" until tenant use was run again. Each environment now remembers its own tenant (older versions still read the top-level value). Folder and metadata caches are keyed by environment too, so a staging tenant cloned from production with the same UUID can no longer be served production's folder tree.

  • Per-asset failure messages survive --progress - During a folder match the reasons for individual failures, and the "stopping after repeated authentication failures" explanation, were written underneath the redrawing progress display and painted over (the same defect fixed for the scan spinner in 1.18.3). They are now printed with the display lifted.

  • env reset, env remove and overwriting with env add ask first - env reset wiped every environment with no prompt; env add silently replaced an existing one. All three honour the global --yes, and refuse rather than assume when no prompt can be shown.

  • env add rejects a URL that is not one - --api-url 'not a url' was saved and surfaced later as an opaque request error.

  • env remove --name nope says the environment does not exist (exit 67) instead of "missing value for property".

  • --format JSON works - The value is matched case-insensitively, as PCLI2_FORMAT already was.

  • asset list --folder-uuid - Every other folder command accepted a UUID; this one only took a path.

  • user list shows its ls alias in help like every other list command.

  • --verbose shows pcli2's own debug output without the HTTP stack's; RUST_LOG still opens everything.

  • Folder trees and lists sort case-insensitively ("alpha" before "Zeta").

  • Configuration and formatting errors show the cause as a sentence rather than a Rust debug dump.

  • asset delete and folder delete work again - The request-path rewrite in this release cycle sent the relative API path as the whole URL for DELETE; caught before release, with a regression test.

  • asset metadata create-batch fetches the field registry once and lists each parent folder once - Both happened per row: a 5,000-row batch into a 10,000-asset folder was about a quarter of a million requests. A registry fetch that fails is now an error rather than "no fields exist", which used to turn every value into a new text field and report the API's rejection as a type conflict in the user's CSV.

  • Cache files are written atomically and carry a schema version - Two pcli2 processes (a script under xargs -P) could read a half-written cache and refetch the whole hierarchy; a cache written by another version is now discarded instead of decoded wrongly. A cache timestamp from the future no longer panics a debug build.

  • Asset listing stops if the server echoes a stale page number - The page loop had no guard against being asked for the same page forever.

  • Listings that hit the safety page cap say so - The truncation of very large user and asset listings was logged at debug level only.

  • --files a,b,c rejects a path that does not exist - It was dropped silently, so a typo uploaded one file fewer. Directories matched by a glob are skipped and counted instead of becoming per-file failures.

  • asset metadata create --type number no longer saturates large values - 1e20 was stored as 9223372036854775807. --type boolean with a value that is not a boolean is sent as-is so the API rejects it, instead of silently writing false.

  • asset metadata get prints {} (or CSV headers) for an asset without metadata - stdout used to be empty for a successful command, and JSON output was wrapped in an internal {"meta": ...} object.

  • folder download --folder-uuid and folder thumbnail --folder-uuid no longer write into a directory named untitled - The folder's name is used.

  • folder upload no longer copies every file through the temp directory - The copy used a non-unique name, so two uploads of files called part.stl from different directories could clobber each other mid-upload.

  • asset create --override treats only "absent" as absent - A network or session failure while checking for the existing asset used to be read as "does not exist".

  • A batch row whose folder does not exist is counted as missing, not as a permission problem.

  • A configured API URL with a trailing slash no longer produces //tenants URLs; user IDs are URL-encoded.

  • tenant use interactive selection no longer panics on a tenant name containing a colon.

  • Excel report sorting parses each cell once instead of once per comparison (tens of millions of parses on a million-row report).

  • --tenant accepts a tenant UUID, as its help text promised - Only the short name worked. A tenant missing from the cached list is also looked up once more from the API before it is reported as not found, so a tenant granted since the cache was written is usable without cache clear.

  • PCLI2_FORMAT is honoured by env list, env get and config get - --format has a default value, so the branch that consulted the variable was unreachable.

  • In-app advice no longer refers to a context command that does not exist - "Run 'pcli2 context set tenant'" is now "Run 'pcli2 tenant use'".

  • Uploads go to the folder that was asked for - The upload endpoint places a file by the path string it is sent and creates any folder it does not know, and three callers sent it the wrong string: folder upload --folder-uuid sent a placeholder / (every file landed at the tenant root), asset create --folder-uuid fell back to the bare folder name when the hierarchy fetch failed (a nested folder A/B/C became a new top-level C), and --folder-path was sent as typed, so /Home/Parts could create a literal Home folder and /parts a second folder beside Parts. The destination is now the folder's canonical path from the hierarchy, whichever way the folder was named.

  • Uploading to the tenant root works - asset create --folder-path /, asset create-batch --folder-path / and folder upload --folder-path / failed with "Folder '/' not found" after fetching the whole hierarchy to suggest alternatives. The root has no UUID and needs none.

  • Typed metadata compares equal to itself in match reports - A numeric or boolean field was rendered as text on the reference side and blanked on the candidate side, so the Excel diff painted every such field as "missing on one side" and CSV rows showed 42 against an empty cell. Both sides now render values the same way (JSON text for non-strings, empty for null).

  • Folder match JSON no longer invents reference asset fields - referenceAsset reported state: "active" (not a Physna state), type: "asset", isAssembly: false and empty timestamps regardless of the real asset. The real values are used.

  • Single-asset text match CSV no longer doubles the asset URL - The stored URL was already an asset URL, and the CSV writer appended /asset/<uuid> to it again.

  • Excel reports stop at the worksheet hyperlink limit - Excel allows 65,530 hyperlinks per sheet; a report between that and the 1,048,574-row cap produced a workbook Excel "repairs" by stripping every link. Comparison URLs past the limit are written as plain text, with one warning.

  • Comma-separated values work where the help text promised them - --name a,b on asset metadata delete and asset metadata inference, and --folder-path a,b on folder geometric-match, part-match, visual-match and asset dependencies, were sent as one value containing a comma; a delete of Material,Weight deleted nothing and exited 0.

  • Metadata batch CSV: the classic header is checked and short rows are reported - A file with columns in another order was applied positionally (paths and names swapped); metadata get --format csv output (NAME,VALUE) was accepted and silently did nothing because every row was too short. The header must now read ASSET_PATH,NAME,VALUE[,TYPE]; rows with fewer than three columns are counted and reported with line numbers; an empty NAME is an error.

  • Downloaded names cannot escape the output directory - A folder or asset named .., or a name containing a path separator, is refused instead of written outside the directory the user chose.

  • One missing field no longer fails a whole page of assets - type, createdAt, updatedAt, state, isAssembly and metadata are optional when deserializing, as are the page index fields.

  • Ambiguous folder paths are reported - Two sibling folders that differ only by case (or not at all) both match a path; the first was used silently. A warning now names both UUIDs.

  • Batch commands exit non-zero when any item failed - asset create-batch logged failed files at debug level only and printed the successes with exit 0; asset metadata create-batch --continue-on-error exited 0 with every row failed; folder upload, folder download and folder thumbnail printed "Operation completed successfully!" whatever had happened, and a missing thumbnail counted as a success. Every failure is now named on stderr and the command exits 69 (temporary failure) with a one-line count. --continue-on-error still decides whether the rest of the batch is attempted; it no longer decides whether the exit code tells the truth.

  • folder download without --continue-on-error stops at the first failure - It used to attempt every remaining asset anyway (each with its own 5-10 second retry sleep) and only then report an error; an expired session on file 1 of 5,000 cost 5,000 failing attempts. Remaining downloads are now cancelled and counted as "not attempted".

  • Concurrent commands share one API client - folder download, folder thumbnail, folder upload and asset create-batch built a fresh client inside every task: one credential-file read, one connection pool and one private token slot per asset, so a token expiring mid-run was renewed once per remaining asset (up to 22,000 auth-server calls in the largest known run) instead of once. Tasks now clone the caller's client, which shares the token, the renewal lock and the connection pool.

  • folder upload lists the destination folder once instead of once per file - Each task listed the entire folder (every page) to check whether its file already existed: 2,000 files into a folder of 10,000 assets was about 100,000 requests before the first upload. A failed listing was also treated as "does not exist", so --skip-existing could re-upload on a transient error; it is now a hard error.

  • folder download and folder thumbnail no longer fetch every subfolder a second time - The subfolder listing already carries the name the walk needed; one request per subfolder is gone.

  • Network and session failures while loading the folder hierarchy are no longer reported as "Folder not found" - The lookup swallowed them and every caller turned the empty answer into a path problem, sending users to check a path that was right. They now surface as what they are, and --recursive no longer suggests cache clear for a failure that clearing the cache cannot fix.

  • asset list and folder list use the folder cache - Both rebuilt the tenant's whole hierarchy from the API on every call (asset list --recursive twice), and folder list --reload refreshed nothing because the listing bypassed the cache. A path missing from the cached hierarchy triggers one refresh before it is reported absent, so a folder created since the cache was written is still found.

  • Downloads are streamed to disk through a temporary file - asset download and folder download held the whole file in memory (ten concurrent multi-gigabyte assemblies meant ten files in RAM), wrote directly to the final name so an interrupted transfer left a truncated file that looked complete, and accepted an empty body as success. The file is now streamed into <name>.part and renamed into place only when whole; an empty response is an error. The blind "sleep and retry any error, including 404" wrappers are gone: the client retries transient failures itself.

  • Folder match runs count every failed task - A task that failed outside the search itself was dropped from the tally, so "Searched X of Y" and the 10% threshold used a smaller denominator. A run stopped by repeated authentication failures now always exits non-zero, even when the stop happened past the 90% mark.

  • Match runs read the configuration once - The UI base URL was loaded and parsed from disk for every match row: 1.29 million times on the largest known report.

  • "No assets to download" is a warning, not an error with exit 0 - asset download-folder and folder download printed a red error and exited 0 for an empty folder.

  • A server outage mid-run no longer produces a "complete" report with exit 0 - Every HTTP status other than 401/403/404 was wrapped in the same Conflict error the search endpoint uses for an asset that is not indexed, so a 500, 503 or 429 during a folder match was counted as "not searchable" - a property of the tenant, excluded from the failure threshold - and the run finished with a short report and a clean exit. Errors now carry their status: only a real 409 is "not searchable", and everything else counts as a failure that can fail the run.

  • Transient failures are retried on every request, not only on DELETE - The API client built its requests on the raw HTTP client and skipped the retry helper entirely, so PCLI2_MAX_RETRIES, the backoff, and Retry-After applied to nothing the user cared about; one 503 in a 5,000-page match run aborted it. Every request now goes through one path with proactive token renewal, transient retry (connection errors, 408/429/502/503/504, Retry-After in seconds or HTTP-date form), and a single renew-and-retry on 401/403. Uploads re-open the file for each attempt; timed-out non-idempotent requests are still never resent.

  • Exit codes follow the documented contract - Authentication failures exit 100, network failures 101, API errors 102, not-found 67, missing input files 66, an incomplete report 69, and usage errors from the argument parser 64 (previously 2). Every one of these used to exit 70 "internal software error" (or 0, see below), which made the case $? examples in the README impossible to hit.

  • Failures that exited 0 now exit non-zero - auth get and auth expiration with no token, env use with no selection, env get with no active or an unknown environment, tenant use with no or an unknown tenant, config validate when validation fails, and config import, which was an unimplemented stub that reported success having done nothing and now says so.

  • Error messages are no longer rewritten by substring - A folder named 401-series was reported as an authentication failure, an unknown environment name was replaced by "Resource not found", a file called connection-bracket.stl became a network error. Messages are now printed as the code produced them, with one hint chosen from what the error actually is (a login hint for authentication failures, a role hint for a 403 that survived token renewal, a connectivity hint for network errors). The OAuth error codes from the auth server keep their explanations, appended rather than substituted.

  • Errors are printed once - Login and token failures were reported with remediation steps and then printed a second time on exit as a bare "Security error"; the incomplete-report error was printed twice with different counts.

  • A 404 no longer triggers a token renewal - Every not-found answer (a deleted asset, a wrong UUID) cost an auth-server call, a credential-file write and a retried request, and with a token-only login was then reported as an expired token. A 404 is now a not-found.

  • Automatic re-authentication failures say why - A rotated secret, a rate-limited auth endpoint and a DNS failure all printed the same "log in again"; the cause is now included.

  • Concurrent 401s collapse into one renewal - The check for "someone else already renewed" compared against the token current at the moment of the check rather than the token the failed request had used, so a task that read the shared slot after another task's renewal renewed again anyway.

  • Timeouts - A stalled connection used to wait out the full 30-minute request timeout even for a small JSON call, and the auth client had no timeout at all while holding the renewal lock, which hung every concurrent task with it. Connections now time out after 15 seconds and a read after 5 minutes of silence; the total timeout (PCLI2_TIMEOUT, still 30 minutes by default) only bounds genuine long transfers.

  • Every request identifies itself - The User-Agent header (now PCLI2/<version>) was only sent on DELETE.

  • Unsupported file types are no longer retried five times by --override - The "Invalid path extension" upload rejection was classified as a conflict, which the override path treats as "the previous asset may still be deleting" and retries with backoff. It is now an invalid-parameter error, reported the same way whether or not the first attempt also hit an expired token.

  • Thumbnail downloads on a Viewer account no longer make three auth-server calls per asset - The per-asset retry helper renewed the token up to three more times on any 403; the client's own renew-and-retry is enough.

Documentation

  • The install guide had pointed at the wrong repository and listed a Rust toolchain as a prerequisite for the binary install; it now covers the shell and PowerShell installers, the Homebrew tap, the MSI, pcli2-update, and says plainly that the crate is not on crates.io. The README no longer claims credentials are kept in the OS keychain: they live in dev_credentials.json under the configuration directory, owner-only on macOS and Linux. pcli2 config environment ... became pcli2 env ... some releases ago and the README and quick start now say so; asset geometric-match-folder is folder geometric-match; the context command is tenant use/get/clear; --path on asset list and folder download is --folder-path; examples use /Home/..., the name Physna shows for the root, instead of /Root/.... Three environment variables (PCLI2_API_BASE_URL, PCLI2_UI_BASE_URL, PCLI2_AUTH_BASE_URL) that the code never read are gone from the docs, and every variable that is read is now listed in one table (README and Cross-Platform Configuration). The exit-code tables drop the code that is never emitted (68) and explain 64 for parser errors and 69 for partial batches. The documented multi-level inference propagation and the config import example are removed with the features that did not exist.

Removed

  • Dead dependencies - bincode, exitcode, url, mime, tokio-util and async-recursion (recursion now uses Box::pin, stable since Rust 1.77); tempfile moves to the test dependencies and tokio-test is dropped. Two integration test files that compiled to no tests (one was a single line of escaped text; the other asserted nothing) are removed and the test README describes what actually exists. The committed mdBook output under docs/book/ and the stale top-level docs/*.md copies are removed; the published site builds from docs/src/. The site no longer advertises cargo install pcli2.

Changed

  • CI runs cargo audit and a minimum-supported-Rust check (1.88, set by transitive dependencies); rust-version is declared in Cargo.toml. Cargo.lock is no longer listed in .gitignore (it was tracked all along).

  • asset metadata inference --recursive - The flag was accepted and never read; the documented multi-level propagation did not exist.

Changed

  • Batch status lines go to stderr - The statistics reports of folder upload, folder download and folder thumbnail, and the per-file "Successfully uploaded" / "Skipping existing asset" lines, no longer share stdout with command output.