Skip to content

Add unity-sync, a mirroring CLI for the Unity Asset Store - #1

Merged
curbol merged 28 commits into
mainfrom
curbol/longrun-unity-sync-v1
Aug 23, 2026
Merged

Add unity-sync, a mirroring CLI for the Unity Asset Store#1
curbol merged 28 commits into
mainfrom
curbol/longrun-unity-sync-v1

Conversation

@curbol

@curbol curbol commented Aug 23, 2026

Copy link
Copy Markdown
Owner

What

A Go CLI that mirrors the assets you own on the Unity Asset Store into a local library,
downloading only what changed since the last run. select picks what to mirror through a
local page, status says what a sync would change, sync fetches the delta, list prints
the lockfile, update replaces the binary from a release.

Three files hold the state, deliberately apart: user config and the pasted session live
outside any project, the unity-sync.toml allowlist and its unity-sync.lock.json live in
the project that consumes the assets and get committed, and the packages themselves live in
an expendable cache at <library>/<publisher>/<asset>/<asset>.unitypackage.

51 files, all new; the repo was empty. Commits are narrative and read in dependency order,
with one deliberate exception: the fixture scrubber lands first, because everything after it
consumes fixtures and committing the raw captures even briefly would put entitlement ids in
git history where an edit cannot remove them.

Why

The Asset Store gives you no way to pull the packages you have bought outside the Editor,
and an account of a few hundred assets is tens of gigabytes with individual packages
reaching 23 GB. Re-downloading the world to find the one thing that changed is not viable,
so the whole design is about answering "what changed" cheaply and never trusting a file it
did not verify.

The store makes that harder than it sounds, and most of the code is a response to something
measured rather than assumed:

  • The LS cookie is the entire credential. Not the NextAuth session token, which neither
    endpoint consults. LS is a session cookie no browser writes to disk, which is why the
    tool asks for a paste instead of reading your cookie database. See DECISIONS.md item 1 in
    the run notes: whether the zero-paste UX is worth chasing is a real open question.
  • A missing LS returns an HTTP 500 that reads like a server fault, so its absence is
    asserted locally before any request goes out.
  • An expired session 302s to Unity's OAuth page, which is why no store client follows a
    redirect. selfupdate is the one deliberate exception, since GitHub's asset API 302s to
    a signed CDN URL by design.
  • Asking for gzip makes the download endpoint gzip the already-gzipped package, so
    downloads ask for identity.
  • The store carries two version identities that routinely disagree: the id it advertises
    and the id it actually serves. Both are recorded, and the advertised one is the diff key,
    because pairing a refreshed id with an unresolved entry would mark that entry current
    forever.

Testing

The suite is fully offline: go test -race ./... across 14 packages, against httptest
servers and scrubbed fixtures. go vet ./... and gofmt -l . are clean, and CI runs the
same three.

It was also run live against a real 176-asset account: select through save, first sync,
the two-version-id warning, an unchanged no-op run, a stale diff key forcing a re-download,
truncation caught by the cheap check, a mid-file flip caught only by --verify, adoption
of an orphaned file with and without a lockfile entry, the version gate refusing to adopt a
mismatched one, a forced rename relocating the directory, a delisted asset reported without
failing the run, four concurrent downloads, and a corrupted LS reported as an expired
session with the lockfile untouched. All passed. The one finding it produced, a delisted
asset counted but never named, is fixed, and the live checks were re-run against the
finished binary.

The diff then went through five review rounds, which raised 38 findings; 34 were fixed and
4 judged not worth acting on. The ones worth knowing while reading it:

  • Adoption could wedge an asset permanently. When a cached file failed verification and a
    good copy of the same package existed elsewhere, the good copy had nowhere to land: the
    damaged file still held the derived path, and Relocate refuses an occupied destination.
    Nothing resolved, the entry carried forward unchanged, and every later run failed the
    same way.
  • The store client raced on the credential itself, because a mid-flight CSRF re-bootstrap
    writes cookie and csrf while the download pool reads them.
  • Under --verify, every out-of-date asset was fully re-hashed immediately before being
    replaced by its own re-download.
  • The lockfile and the manifest, the two files this design most wants committed, were
    landing as 0600.
  • Every diagnostic in install.sh was unreachable: the script runs under set -e with
    pipefail, and each guard sits after an assignment from a pipeline, so the first failure
    a new user hits printed nothing at all.

Each has a test that fails against the previous code, except the --verify one, which is a
performance property with no behavioural observable and is verified by reading.

Worth knowing: the review stopped at its round cap, not because a round came back clean.
Findings per round were 10, 7, 7, 9, 5. What changed across rounds is where they were. The
Go code stopped yielding anything that would hold a merge by round 4, and round 5's only
significant finding was in install.sh, which no earlier round had read closely, so the
shell and workflow files have had one careful reading rather than five.

Notes

  • Private repo, so install.sh and update both authenticate with gh or GITHUB_TOKEN.
  • No account data reaches the repo: sessions and raw captures are git-excluded and a guard
    test fails the build if PII lands in testdata/.
  • Browsing the mirrored library is deliberately out of scope; that is
    quarry, and the three-level cache layout exists so
    its vendor and pack filters work against this tree.

curbol and others added 28 commits August 22, 2026 11:26
The offline suite runs against real SearchMyAssets responses, and the store
returns a per-row entitlement id alongside every product. Nothing in this tool
reads it, so the scrubber deletes the field rather than substituting a value --
which also leaves each fixture shaped exactly like what the pinned query asks
for. Raw captures stay out of the repo; only the scrubbed output is committed.

The guard test walks whatever is in testdata rather than a fixed list, so it
also covers fixtures added later by someone who never read the scrubber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The API returns id, productId and itemId for every product. Only the first is
the one /api/downloads/{id} takes and the one the package's own gzip metadata
carries; the 12-digit productId is accepted by no endpoint here. Asset omits it
entirely so it cannot be reached for by mistake.

Slugs are derived, not identifying: a rename changes them, which is why they key
the lockfile and the cache tree for readability while classification matches on
the id. Both slug forms fall back when a name folds to nothing under ASCII
folding, because an empty path segment would collapse the cache layout to two
components and empty quarry's pack facet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
session_source has no default value. Every other tool of this shape defaults to
reading a browser cookie store, but the Asset Store's credential is a session
cookie that no cookie database holds, so a browser default would make the
out-of-box run fail with a diagnostic pointing at the wrong thing.

Concurrency defaults to 2 rather than the template's 4: packages here reach
23 GB and the store is someone else's infrastructure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
An expired Asset Store session arrives as HTTP 500 with an empty GraphqlError.
On status alone that is indistinguishable from a server hiccup, so the single
most common failure this tool will see would be retried through the whole
backoff schedule before the user is finally told to re-paste a session.

retry.Permanent lets the caller settle it from the body and stop at once, while
Retryable keeps the ordinary status policy for everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Every .unitypackage carries a JSON descriptor with the product id and version id
in the gzip FEXTRA field. It is not the comment field, which is empty on every
real package, and gzip.Header.Comment would therefore read blank forever while
looking correct.

The subfield walk is driven by each subfield's own length rather than a fixed
prefix. Every package sampled ends its descriptor by byte 338, but XLEN is a
uint16, and a prefix-limited reader would report "no metadata" for a package
that has some -- quietly downgrading the hard "the store served a different
asset" check into the tolerated warning path. The test puts a 40 KB subfield in
front of the descriptor to keep that honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Measured against the live store: _csrf plus LS alone returns the full owned-asset
list, a junk or absent NextAuth session token changes nothing, and removing LS
turns any user-scoped query into an opaque HTTP 500 that reads like a server
fault. So LS is the credential, and its absence is reported before a request
goes out rather than left to that 500.

Two parsing details are load-bearing. A cookies.txt marks HttpOnly rows with a
#HttpOnly_ prefix on the record itself, and LS is HttpOnly, so treating every
'#' line as a comment drops precisely the credential. And the storefront mixes
cookie scopes within a single response, so the whole unity.com family is
accepted rather than one host guessed -- which scope LS uses is not something
any reachable endpoint reveals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
…kage

The response-level guards live here because they can all be decided before any
bytes are kept: no redirect is followed, any Content-Encoding is refused, and
the content type must be an octet-stream. Each of these has a specific failure
behind it. An unauthenticated download 302s to Unity's OAuth page, so following
redirects would write a sign-in page into the cache under a .unitypackage name.
The endpoint honours Accept-Encoding: gzip by gzipping the already-gzipped
package, and Go does not transparently decode an encoding the caller asked for,
so the cache would receive a double-gzipped blob with no readable metadata.

x-requested-with is what makes a failed API call answer with the JSON error that
carries the diagnosis rather than a 302 to an HTML error page. It, the identity
encoding, the User-Agent and the query document are all invisible when missing --
a permissive server answers fine -- so the tests assert what the client sends,
not just what it does with the reply.

The enumeration compares raw rows against the store's own total before
deduplicating, so a duplicate entitlement row cannot masquerade as a short walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Store leaves the bytes in a temp file beside their destination and hands back the
digest and exact size; the caller runs its checks and then commits or discards.
A single-phase write would leave a rejected body at the real cache path for a
window, and an interrupt there strands it where the next run's adopt scan takes
it for genuine.

The layout is three segments because quarry reads its vendor facet from the first
path segment and its pack facet from the second, filling the latter only when a
path has at least three parts -- a flat tree indexes every package with both
facets empty. That is also why a rename moves the directory rather than the file,
and why an emptied directory is pruned.

Adoption scans by the descriptor inside each file rather than probing a derived
path, since the case it exists for is a file that is not where the layout would
put it. It skips dotfiles, so an abandoned partial cannot be adopted: a partial
can be large enough to clear a size floor while still carrying an intact
descriptor. Relocation refuses an occupied destination, because the caller
records the digest of whatever ends up there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Each lockfile entry has two halves. The advertised half refreshes every run for
every owned asset, so a deprecation or a rename shows up in the diff even for
assets the run never downloaded. The resolution half describes the file on disk
and is rewritten only when a run resolves that asset.

resolvedVersionId is the diff key, not version.id. Keeping them apart is what
stops a refreshed advertised id from being written beside an older file: that
pairing would mark every unresolved asset as already current and it would never
re-download. sizeBytes is likewise always the received count and never the
advertised one, which runs 0-16 bytes higher.

There is no run timestamp. Stamping one would dirty a committed file on every
no-op run, burying the changelog the file exists to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The manifest is the one file in the system a user curates by hand, so every
destructive edge is closed here. Entries key on the store product id, not the
name, because a publisher rename would otherwise silently disable an asset. An
unknown TOML key is an error rather than a shrug, since Save re-encodes from the
struct and would delete it on the next write. Reconcile reports the entries it
drops instead of removing them quietly, and refuses outright to rewrite a
non-empty manifest against an empty owned set -- a session with a different
active org looks exactly like that.

UnknownIDs exists so status and sync can mention a refunded or mistyped id.
Neither writes the manifest; only select does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The semantic guards live here because they need both the stored bytes and the
enumeration metadata: the store layer sees only responses, the cache layer only
bytes. Each runs against the temp file, and a failure discards it, so a rejected
body never reaches a real cache path.

A short body is ambiguous -- truncation or a republish that moved the advertised
size -- so one re-read of that product settles it. The discriminator is
deliberately not "the delivered version id differs from the advertised one":
for some products that difference is a steady state, and keying on it would
switch the floor off permanently for exactly the assets most likely to hide a
truncation.

Failures are per-asset. The pool cancels early only for a run-fatal error,
because otherwise one delisted package aborts a 75 GB mirror on every future
run. A permanently pulled asset is reported but does not make the run exit
non-zero; a corrupt body or a transport error does, since re-running may fix it.

The lockfile is written after each successful download, and entries the run did
not resolve keep both their prior resolution and their prior key -- re-keying
one while leaving its file in place would put the two permanently out of step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
select is the only command that writes the manifest, so the two ways a save can
destroy a curated allowlist are refused here rather than absorbed. A POST
without this run's token comes from a page an earlier run served, against a
different library. A save that would deselect everything at once is refused
unless nothing was selected to begin with, which is the legitimate first-run
case.

Thumbnail URLs arrive protocol-relative and are made absolute: on a page served
from http://localhost they would otherwise resolve to http:// and break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The repository is private, so the release binary comes from the GitHub asset
API, which answers with a 302 to a signed CDN URL. The Asset Store clients ban
redirects because following one there writes a sign-in page into the cache;
inheriting that ban here would break updates outright, and a stub server that
never redirects would not notice. The test therefore redirects for real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
flag.Parse stops at the first non-flag argument, so an unchecked positional
swallows every flag after it: `sync some-asset --dry-run` would download the
delta and rewrite the lockfile. Every subcommand rejects positionals and points
at --only instead; update is the sole exception, since it takes a version.

list reads only the lockfile, so it works with no session and no network. The
two ways a run can fail before touching the network -- no session configured, or
a session with no LS cookie -- are named with the remedy rather than surfacing as
an opaque 500 later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
release.yml calls ci.yml rather than restating its steps, so the two cannot
drift into a tag being cut on a weaker bar than a merge. The suite runs under
-race because the syncer fans downloads out under a semaphore and persists the
lockfile from each goroutine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
docs/design.md records the measured behaviour rather than the expected: which
cookie is the credential, which route issues the CSRF token, that an
unauthenticated download 302s to an OAuth page, that the endpoint gzips an
already-gzipped package if asked, and that advertised and delivered version ids
disagree for some products. Each of those is the reason a specific guard is
shaped the way it is, so the reasoning stays attached to the fact.

The README leads with the paste-a-session workflow instead of apologising for
it: no browser cookie database holds the cookie the store checks, so it is the
only thing that can work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Three were substantive. Downloads had no retry at all, though the design
specifies one and depends on each attempt starting from a fresh temp file and
hasher; retry now wraps the fetch and the write together. The
advertised-versus-delivered version mismatch was recorded but never mentioned,
so the one case where the store serves a build it does not advertise passed in
silence. And a republish discovered mid-download failed the asset instead of
warning, inverting the outcome for the single legitimate way to fall below the
size floor -- it now stores nothing, warns, and leaves the new build for the
next run.

The rest are alignment: the download request was missing x-requested-with; a
stale CSRF token got zero retries where one re-bootstrap is the obvious remedy;
the pool did not stop on an expired session, so every remaining asset failed
individually; `unity-sync version foo` exited 0; the PII guard skipped non-JSON
fixtures; and the pinned query asks for one image size while the fixtures
carried four, so the scrubber now drops the rest.

The failure-model tests move into audit_test.go files, the convention the
template uses to mark which tests exist because something went wrong once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Six verify-column gaps, all cases where the code existed but nothing proved it.

The two that matter are on the adopt path, which the plan calls the design's
load-bearing case. A candidate stamped with a different build than the store
advertises must be refused -- a wrong adopt is silent and permanent, where a
redundant download is loud -- and a candidate found off its derived path must be
relocated before its cachePath is recorded, or quarry's facets stay on the old
name and the file is stranded at the next version bump. Live QA cannot reach the
second one: its subject already sits at the derived path, where relocation is
deliberately a no-op.

The pinned query document is now compared whole against a golden file rather
than by substring. Its field set is a privacy boundary as much as a correctness
one, and a substring check would notice neither a lost field nor a regained one.

`select`, `status` and `sync` gain command-layer tests by narrowing what they
take to the interface they use, so their happy paths are exercised instead of
only their failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The serious one. When cheap verification failed, classify fell through to the
adopt scan, which rescanned the tree and matched the very file that had just
failed: a truncation or a mid-file flip leaves the descriptor intact and a small
truncation clears the size floor, so the damaged bytes were re-hashed and their
digest recorded as the asset's truth. That is exactly the outcome the two-phase
write, the floor and the metadata gate exist to prevent, reached through the one
door that skips all three. The failing path is now excluded from the scan, so
the asset re-downloads.

Three smaller ones. A rename that also bumped the version downloaded to the new
derived path and left the old directory holding a superseded copy. An expired
session and a pulled asset each burned a retry with a two-second backoff, though
neither improves on a second attempt. And `status` with nothing enabled reported
"0 assets considered" rather than the owned count, which is the number the user
needs before running select.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
A 23 GB download previously printed one line before the transfer began and
nothing until it ended. Progress is now counted off the body as it streams,
rate-limited to a line a second, from the layer that knows the asset's name and
advertised size.

Cancelling the pool now reaches an in-flight transfer too, not only queued ones:
once a session is known dead, continuing to pull gigabytes under it is waste.

Two documentation claims were stale rather than wrong-by-omission. The README
said the tool never deletes a package it mirrored, which stopped being true when
the rename fix started removing an asset's own superseded copy; and the design
doc never described adoption at all, including the exclusion that keeps a
damaged file from being adopted back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Only a 500 whose GraphqlError carries an empty message means the session died;
that shape short-circuits the backoff so a stale paste is named at once. A 5xx
that says what went wrong is an ordinary server error, and returning it as
permanent turned a transient upstream outage into an immediate failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The CSRF token can expire between the bootstrap and the call that spends it, so
the client re-bootstraps and retries once. Nothing asserted that, only that a
permanently mismatching server surfaces ErrCSRF, which the retry path also
satisfies.

The memoized verification probe becomes a named function so the property that
matters can be tested directly. Counting hashes through Run was not observable
without a seam that only a test would use, and a test that cannot observe the
thing it names is worse than none.

Slugs fold runs of non-ASCII to one separator rather than transliterating, which
is what the store does too; that is now pinned rather than described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The class tally said "undownloadable 1" against 176 owned assets and stopped
there, so the one fact the user can act on — which package — was missing. The
summary already names the other two exceptional cases per asset, and the README
already promised delisted assets are reported.

Found running the live QA against the real account, where a genuinely disabled
asset produced a count and no name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Two ways a run could get permanently stuck:

A cached file that failed verification stayed at the derived path while
adoption tried to move a good copy of the same package onto it. Relocate
refuses an occupied destination, so nothing resolved, the prior entry carried
forward unchanged, and every later run refused identically. A download would
have renamed straight over that file, so an adoption may too.

The store client's cookie and CSRF token were written by a mid-flight
re-bootstrap while the download pool read them, a data race on the credential
itself. A torn Cookie value would surface as an unreproducible "session
expired" partway through a sync.

Both are pinned by tests that fail against the previous code: the adopt test
reproduces the refusal, the concurrency test reports eight data races.

And seven smaller ones:

install.sh died silently with no credential, because a function ending in a
failing test returns non-zero and set -e aborts the caller's assignment, so
the diagnostic written for exactly that case could never print.

The advisory size warning overwrote the version-mismatch warning, losing the
only line that explains why the lockfile holds two different version ids.

A refused save killed the select page, so its own "reload and choose again"
was impossible to follow, and any open tab could end a selection with one
cross-origin POST.

The incremental lockfile save ran after releasing the lock, so two goroutines
could reach the rename in the order opposite to how they built their
snapshots, losing the record the write exists to keep.

A non-404 4xx on the download endpoint was retried, against both the stated
policy and the GraphQL path's own behaviour.

Seven comments justified an absence or argued against a rejected alternative,
which a reader of the file cannot check.

The README undercounted enumeration by the terminator page, and described the
cache as removing only one kind of file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The adopt fix added a second reason a run deletes a cached file, and only the
README said so. docs/design.md still opened "Nothing deletes a package the tool
mirrored, with one exception", and CLAUDE.md points readers at that file as the
authoritative one before they touch the cache. main.go and RemoveStale's own doc
were wrong the same way.

There is now a third reason, because adoption did not remove the entry's own
superseded copy the way the download branch does. When the current build already
sits at the derived path and the lockfile points somewhere else — a run killed
between the commit and the incremental save leaves exactly that — the old file
was orphaned: unreferenced by the new lockfile and named by nothing, since the
summary only names assets that left the account.

So the rule is stated once, as it now stands: a run removes a file only when it
is replacing that same asset's own copy.

Also from the review: a comment of mine that argued against a design nobody
proposed, the same shape the previous commit deleted seven of; dropped assets
printed in map order, so two runs finding the same thing disagreed; and a
branch in SweepTemps that could not be reached, since the walk function returns
nil for every error including the missing root. That case is now a test instead
of an unreachable guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
An asset whose advertised version moved is Changed before the cache is asked
anything, but the decision about which file to exclude from the adopt scan ran
first and forced the probe anyway. Under --verify that probe is a full re-hash,
so every out-of-date asset had its whole file read immediately before being
replaced by the download. The decision now happens inside the adopt closure,
which is only reached once that short-circuit has not fired.

The adopt scan's exclude list was also compared as a raw string while every
other cache entry point resolves its paths, so a lockfile spelling the same
file differently would skip nothing and re-offer a file that just failed
verification as something to adopt.

Two error values still documented themselves as returned from Serve, which
stopped being true when the refused-save path stopped tearing the server down.
They are rendered into a 409 body now, and say so.

Also: a tiebreak on the dropped-asset ordering, since two publishers can ship
the same product name; a run that fails at the final lockfile write now still
prints what the download pass did; build no longer takes an Options it never
reads; and Progress says that it is called from every download goroutine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Pruning compared the library root as the user spelled it against paths that
had been cleaned, so `--library ./lib` or a trailing slash left every emptied
directory behind and nothing said so. quarry reads its pack facet from those
directories, which is the reason they get pruned at all.

The lockfile and the manifest are written through a temp file and renamed, so
both landed as 0600 — owner-only, on the two files this design most wants
committed and hand-edited. A fresh one is 0644 now and an existing one keeps
whatever mode it has.

An adopt-scan exclusion that could not be resolved was dropped, turning "do not
adopt this file" into no exclusion at all, on the one path into the cache that
skips the download guards. It now refuses every candidate instead, which falls
back to a re-download where those guards apply.

SweepTemps could not return a non-nil error, because its walk swallows all of
them, so the fatal check on it was unreachable and a real walk failure would
report zero reclaimed. Swallowing is right — one unreadable directory must not
stop a 75 GB mirror — so the signature now says so. Nothing asserted the sweep
happens either: deleting the call left the suite green.

Also: the select page's URL went to stdout, against the rule that stdout is
output and diagnostics are stderr; two exported error values that nothing
returns are messages now; and Report.Freed was written and never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Every diagnostic in install.sh was unreachable. The script runs under `set -e`
with `pipefail`, and each guard sits after an assignment from a pipeline, so a
failing curl aborted the script before its own error message could print. A
new user without a credential got exit 1 and not one word, on a private repo
where that is the expected first outcome. Verified against a 404: silent
before, named and exit 1 now.

Nothing cleaned the temp directory on those paths either, since there was no
trap, so each failure left a mktemp -d behind.

The final `unity-sync version` check could not fail the install: it fell
through to `err`, which is a printf and returns 0, so a broken binary exited 0
as the script's last command.

The token also travelled in argv, where `ps` shows it to every local user for
the length of both requests. It goes through a curl config on stdin now, which
is what the Go client already does by setting the header in-process.

This came in from synty-sync's installer, which the plan leaves alone, so the
fix is here only.

Also: the CI concurrency comment justified itself with a double-run that the
triggers make impossible — push is restricted to main, so a PR branch fires
one workflow, and the two would carry different refs anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
@curbol
curbol merged commit 23878f8 into main Aug 23, 2026
1 check passed
@curbol
curbol deleted the curbol/longrun-unity-sync-v1 branch August 23, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant