Skip to content

feat: rosebud pull, file-by-file uploads, and scoped login errors (0.2.0) - #5

Merged
kory-stiger merged 30 commits into
mainfrom
feature/eng-4805-cli-sync
Oct 28, 2026
Merged

kory-stiger merged 30 commits into
mainfrom
feature/eng-4805-cli-sync

Conversation

@kory-stiger

Copy link
Copy Markdown
Contributor

Summary

This lets an agent work on any game a person made in Rosebud, not just games the agent published, and removes the 10 MiB ZIP limit. It also adds clearer errors for scoped and expiring logins.

  • rosebud pull --directory <folder> [--force] brings a game's current files, including edits made in the Rosebud editor, into a folder.
    • It compares three ways: Rosebud's copy, the folder, and the last sync.
      • Local edits are kept.
      • Files changed on both sides are reported as conflicts. Rosebud's copy is saved beside the state file (.rosebud/project.remote/), never inside the folder.
      • After a conflicted pull, update stays refused until you merge and pass --expected-version.
    • Paths are matched the way the disk does. On case-insensitive or Unicode-normalising disks, a case-only rename or a name collision is reported, never written over.
    • Files missing locally are reported, and --force restores them. --force only settles conflicts; it never discards changes made only locally.
    • A pull never writes outside the folder, never follows links, and skips unsafe remote paths.
    • Reading files back needs the game owner's plan to include code download. Publishing and updating stay free.
  • File-by-file uploads in publish and update.
    • Text files go in one ZIP. Media is declared in an upload session, and only files Rosebud doesn't already hold are uploaded, with signed PUTs four at a time.
    • Limits: 19 MiB per file, and 2,000 files / 1 GiB per game. A single publish or update can add up to 128 MiB of new media.
    • Against a server without upload sessions, the CLI falls back to 0.1.0's single ZIP. The bytes are pinned by a golden test.
  • Safer reruns. Rerunning an identical update after a lost response replays the same operation without uploading again. A "busy" answer is treated as not applied. While Rosebud is still checking a game's existing files, the CLI keeps retrying for as long as the count of files left to check keeps falling.
  • Errors explain what to do next. Each error carries a next_action. A conflict also gives changed_paths, and a size error gives limit as a number. An expired login (key_expired) says to run rosebud login. A missing scope (insufficient_scope, with required_scope) says to log out and back in. whoami shows the login's scopes and expires_at.
  • File rules match Rosebud's.
    • Any name the Rosebud editor allows is accepted, including Markdown notes and accented names.
    • Hidden files, .env, credentials and dependency folders stay refused.
    • OS junk (.DS_Store, Thumbs.db, desktop.ini) is skipped.
    • .svg and .gltf files that Rosebud holds as text stay text.
  • Robustness.
    • A lock left by a killed process is taken over, and Ctrl-C or SIGTERM releases the lock.
    • Credentials never go to storage. A signed upload carries only the headers the server returned, minus credential-bearing ones.
  • README, help text and CHANGELOG (Unreleased) are updated. package.json stays at 0.1.0; the version bump belongs in the release PR.

Depends on

These Gateway PRs must be live before a release that uses them:

  • rosebudai/PlaygroundGatewayV2#2391 (merged to develop): release and sync API, upload sessions, reading files back.
  • rosebudai/PlaygroundGatewayV2#2406: games made in Rosie round-trip (Markdown notes, editor media), numeric limit, upload_retry with remaining.
  • rosebudai/PlaygroundGatewayV2#2400: hardening before sync is switched on.
  • rosebudai/PlaygroundGatewayV2#2410: scoped, expiring login keys.

Don't tag 0.2.0 until those are live and sync is turned on in live. Until then, pull answers sync_unavailable and uploads fall back to one ZIP.

How to test

  • npm test on macOS: 146 tests, 144 passing, 2 skipped (case-sensitive-disk tests).
  • npm test in Linux containers (Node 22 and 24): 146 tests, 140 passing, 6 skipped (case-insensitive-disk tests and one root-only skip).
  • tsc --noEmit and Prettier are clean. This PR's CI runs the suite on Linux and Windows.
  • Most new tests run against an in-memory fake Rosebud (test/fake-rosebud.mjs): sessions, signed PUTs, pull, conflicts, collisions, busy, upload_retry and scopes.
  • End to end against a local Gateway with #2406, all steps passed:
    1. Published a 56 MB game (40 PUTs, 8 s).
    2. Simulated editor edits, and update reported version_conflict with changed_paths.
    3. Pulled, edited one image, and updated with exactly 1 PUT.
    4. Merged a conflict and updated with --expected-version.
  • Every task was reviewed for spec and quality. The whole branch then had a final review, two fix rounds and re-reviews.

🤖 Generated with Claude Code

kory-stiger and others added 29 commits October 22, 2026 12:18
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…uploading

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed its upload

The handle's files and media now have to be plain data, or the CLI answers
state_format. The lost-response test also asserts the interrupted update
staged exactly one PUT, so it proves the rerun reused it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pull reads the game's current files, keeps local edits, takes Rosebud's changes
and lists files changed on both sides as conflicts, with Rosebud's copy saved
beside the state file. A conflicted pull leaves content_version alone, so update
still needs --expected-version. Update conflicts now pass changed_paths through.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
--force now settles only what Rosebud changed; files changed or deleted only
here stay as they are. next_action commands carry --state and quote paths with
spaces, name copies only when there are some, and mention skipped files in both
outcomes. Pull stages every write outside the folder, removes before it writes,
reports a file/folder clash as path_conflict, reports a repeated remote path as
duplicate_path, and ignores hidden entries and leftover .part files in the
folder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e uploads

Update conflicts carry changed_paths, limits always show as numbers (also
from servers that send a digit string), and errors add a next_action with
its commands in backticks. A 503 busy answer is a rejection that changed
nothing: it exits 3 with retry_after_seconds, keeps an update's saved
operation for an identical rerun, and never drops a resumed asset create's
saved request. An upload session that answers 409 upload_retry is asked
for again after Retry-After, up to 20 times. The README and help describe
rosebud pull, the media limits and the per-upload timeout allowance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A busy answer while assets create polls its job is waited out like a
throttle. A busy refusal of an update is an ordinary known rejection, so
its saved operation is cleared (a retry of an earlier uncertain attempt
still keeps it) and a changed rerun, or pull then update, works. The
pending_operation error now names --expected-version as the way out. Busy
text says "shortly" without a usable Retry-After, upload_retry is only
repeated for HTTP 409, and the README states the per-update media limit,
the full name rules and the automatic retries that exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Skip OS junk (.DS_Store, Thumbs.db, desktop.ini, any case) when packing and
  pulling instead of refusing it or shipping it; other hidden names still
  refuse with unsafe_path.
- Refuse whitespace-only names, exclude names ending in .env, and compare
  excluded names trimmed.
- Treat .svg/.gltf outside assets/ as text, and send paths Rosebud holds as
  text (present in the saved manifest but not in its media list) as text even
  with a media extension, through update and the one-ZIP fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- On a case-insensitive disk, a path pull would write that differs only by
  letter case from a local file that stays is a case_conflict: the local file is
  never overwritten, Rosebud's copy goes to the .remote folder, and next_action
  names both files. A file the same pull removes is no rival, so a plain
  Rosebud-side rename by case still goes through. Case-sensitive disks are
  untouched.
- Correct the localFiles comment about hidden entries and OS junk, tighten the
  vacuous "a is a folder" assertion, give the ten-second upload_retry cap test
  an explicit timeout, rename the misleading busy429 helper, and say in the
  README and CHANGELOG that a busy refusal allows changing files unless an
  earlier update is still uncertain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… junk rules

- Keep repeating an identical upload-session request while Rosebud's
  `remaining` count falls; stop and surface the error when it fails to fall
  twice in a row, after 500 attempts, or (when `remaining` is missing or not a
  number) after the old 20 attempts.
- Hidden means a raw leading dot, as on the server, so " .env" is an excluded
  credential file rather than a hidden one.
- Only a regular file named like OS junk is skipped; a folder or link with such
  a name is walked or refused as usual, in the packer and in pull.
- Name the local file in pull's JSON as `clashes_with` on a case_conflict, say in
  the README that --force does not overwrite a case rival that stays, and state
  what pull does with a junk-named file Rosebud holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ami (ENG-4834)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ollide there

Pull compared paths byte for byte, but macOS and Windows disks fold letter
case and macOS folds Unicode form, so files were silently overwritten.

- Probe the disk once per pull (a scratch file in the .remote staging folder,
  never in the game folder; when that sits on another disk, fold both) and
  key every remote, local and last-sync path the way the disk does.
- A local file spelled differently from Rosebud's is the same file: the
  three-way rules apply to it, so a locally edited NFD-named file is kept,
  and is a conflict once Rosebud changes it too. Rosebud's text lands on it
  under its local name. A difference in letter case counts as a rename,
  compared with the last sync like any other change.
- Two Rosebud names that would land on one file or folder here (Foo.js and
  foo.js, Scripts/ and scripts/) are each a name_collision: neither is
  written, even with --force, Rosebud's copies are saved apart in .remote,
  the saved version stays put and next_action says what to rename.
- A Rosebud file whose folder exists here under another case is a
  case_conflict naming that folder, so it can't land under the old spelling.
- pullInto now returns the next last-sync manifest itself, keeping a
  conflicted file's old entry by disk key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c to its folder

A file deleted here and unchanged in Rosebud was silently kept deleted, so a
pull into an emptied or different folder wrote nothing, said the folder held
Rosebud's version, and the next update deleted those files from the game.

- Pull lists such files under missing_locally, and next_action says the
  next update removes them from the game unless they are restored, naming
  the `rosebud pull ... --force` that restores them. --force now writes them.
- The handle records the folder its manifest describes (absolute); a pull
  into another folder ignores that manifest, and an empty or missing folder
  counts as having no last sync, so every file is written.
- A junk-named file Rosebud holds is ignored after the first pull, not
  reported as missing.
- README: the update section pulls into the same ./dist folder the
  Quickstart publishes, documents missing_locally, and says pull empties
  the .remote folder each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…T/SIGTERM

Agent harnesses kill commands that outlive their timeouts, and big
publishes, updates, pulls and upload_retry waits now run for minutes, so a
killed CLI left a lock that refused every rerun with state_locked.

- A lock whose process id names no running process (kill(pid, 0) answers
  ESRCH) is taken over: it is moved aside, checked and removed before a
  new lock is created, so of two commands taking it over only one wins. A
  lock held by a live process, or naming none, still refuses; the message
  names the live process.
- SIGINT and SIGTERM release every lock the process holds, print an
  `interrupted` JSON error saying what is safe to rerun, and exit 130 or
  143.
- README, help and CHANGELOG say long runs can take minutes, that rerunning
  an interrupted update, pull or assets create is safe, and that an
  interrupted publish may have created the game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A 503 sync_unavailable is a known refusal: outcome rejected, exit 3.
- A failure while creating the upload session no longer says the operation
  "may have completed": nothing was released, so a lost or garbled answer is
  not_sent and a server error is rejected, and an update's saved operation is
  cleared.
- Signed uploads send any header the session lists except credentials and
  connection headers (authorization, cookie, proxy-authorization, host,
  connection, content-length, transfer-encoding and the other hop-by-hop
  ones), with names checked as tokens and values without line breaks.
- changed_paths lists of up to 4,000 paths (before and after, two per game
  file) are kept; the README says so.
- release_upload_limit on a first publish says to publish part of the media
  and update with the rest.
- key_expired and insufficient_scope during assets create say to rerun the
  identical create after the new login is approved; required_scope is
  bounded to 32 letters a side; the malformed-scope test checks the full
  stderr and the publishing shape.
- Messages name --timeout-ms and --api-base instead of library options.
- On Windows, pull skips names Windows would trim or send to a device; pull
  warns about hidden entries, which update refuses.
- README: "The login works with…", .oga in the media list, the long
  upload_retry wait, sync_unavailable, and the exit table's not_sent case.
  The name-rule message gives the 255-character limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e disk

When the folder legitimately held both Foo.js and foo.js and Rosebud changed
one of them, the last-resort case check found the other name as a rival
and refused the write, even with --force. A write whose target is itself a
local file lands on that file, so it is never a rival's.

Adds a test that runs only on case-sensitive disks. The planPull unit test
now spells its NFD and NFC names as escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pull with conflicts leaves the saved version where it was, but for a game
just connected with --project that version is already Rosebud's, so a plain
update went through and, after a name_collision, deleted files from the game.

- Pull records its unresolved conflicts (paths and the version it read) in
  the handle; update refuses with pull_conflicts (exit 2) until a pull
  reports none or --expected-version names the version, and next_action
  gives the exact command. A successful update clears the record.
- Update from another folder than the last sync's (handle.directory) is
  refused with directory_mismatch unless --expected-version is given, since
  releasing it as it is would undo what that sync brought in; next_action
  says to pull there first.
- Without a last sync for the folder, pull's conflict sentence says the
  files differ and no last sync can tell which side changed them, naming
  where the last sync was. The restore hint beside conflicts says --force
  also settles them for Rosebud, discarding those local edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d probe edges

- Takeovers of a stale lock run under a `.lock.takeover` guard created with
  wx; under it the lock is read again, so two commands can never both
  replace it (8 and 16 contenders, 90 rounds: never two holders). A guard
  left by a killed takeover is stale the same way as a lock. Locks are
  recorded as held the moment they are created.
- A lock naming this process's own id that it doesn't hold is stale (an
  earlier process with the same id).
- The `interrupted` error says a lock was released only when one was held.
- The disk probe names its NFD and NFC spellings as escapes, and if it can't
  write its scratch file the pull folds both case and form instead of
  failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… takeover unwedgeable

- The handle saves the folder's real path, and update and pull compare
  folders by real path, or by identity on disk for another letter case, so
  `dist`, `./dist/`, its absolute spelling through /var on macOS or a linked
  parent folder are the same folder and never refused with
  directory_mismatch.
- Locks are written aside and hard-linked into place with the process id and
  a fresh token, so they are never empty (an empty one older than 10 s, from a
  disk without hard links, counts as stale). A stale lock is removed only by
  the command that creates its own claim file, `<lock>.<token>.claim`; no
  other command can remove it, so it is still there when that command looks
  again, and a claim left by a takeover that was itself killed is stale and
  taken over the same way. The single takeover guard, which an empty file
  could wedge, is gone. Releasing a lock that was removed meanwhile is not
  an error.
- pull_conflicts' next_action names `--zip <file>` for a ZIP update.
- With no last sync at all, pull says the files differ and no last sync can
  tell which side changed them, instead of "changed both here and in
  Rosebud".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hile writing the site docs

- Tests that check an absolute path the CLI reports (rosebud_copy, the
  copies folder, the handle's folder) compare both sides with
  realpathSync.native, so Windows' 8.3 temporary paths (RUNNER~1) match the
  long form the CLI reports. This fixes the Windows CI failure of the
  case-only rename test.
- The pathList comment says the bound is twice the per-game file limit:
  Rosebud names each changed path once, from both versions' files.
- README documents `rosebud login --name <label>` and that empty media
  files are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock edges

Any link failure but EEXIST now falls back to the in-place exclusive create
(Windows exFAT/SMB report EISDIR/EINVAL), so claim files get it too. An
unreadable lock is held, never an empty one to take over. The lock race test
tolerates scanner leftovers on Windows, sameFolder ignores a zero inode, and
two doc nits are fixed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…match held paths in either Unicode form

- Overlap between --directory, the state file and Rosebud's copies is checked after
  resolving links, so a folder reached through a linked parent is never the copies'
  folder that pull empties.
- Pull downloads and checks every file it writes before removing anything, so a
  failed download during a rename by case leaves the old name in place and a rerun
  finishes the rename instead of reporting local_deleted.
- Update matches the paths Rosebud holds as text or media in either Unicode form,
  as pull leaves local names, so an unchanged editor SVG stays text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nswers by file count, and send Rosebud's spelling of held names

- Pull rehashes every file it would replace or remove, and checks that no file
  appeared where it writes, after the downloads and before touching the folder.
  A change made meanwhile stops it with folder_changed, leaving the folder as it was.
- An upload session's answer may exceed 1 MiB by 8 KiB per declared file, since
  each upload carries a signed URL; 1,200 files no longer fail as unexpected_response.
- A local file matching a path Rosebud holds only by Unicode form goes up under
  Rosebud's spelling, read from the local name, so an unchanged NFD media file is
  linked rather than refused as noncanonical_path. New media goes up in NFC, and
  two local names that are one name in NFC are refused as duplicate_path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed, and retry an update through its saved upload session

- A takeover removes a stale lock only while it is the same file (text and
  modification time) and still stale, so on a disk without hard links it never
  removes the empty lock another takeover has just created.
- Media is read again for upload without following links, and sent only when its
  size and MD5 match what was packed; otherwise file_changed, with nothing sent.
- update saves its upload session with the pending operation, and an identical
  rerun sends that session again, so Rosebud matches the earlier attempt even
  when a new session would be refused. A session Rosebud no longer takes is
  replaced by a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t sent them

An update whose media went in one ZIP, from a Rosebud that took no upload
session, could not be replayed once Rosebud took sessions: the retry declared
its media in a new session, so Rosebud saw a different request under the same
operation and answered operation_reused. The pending operation now keeps how
its media went, one ZIP or an upload session, and an identical rerun sends them
the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kory-stiger
kory-stiger requested a review from a team as a code owner October 28, 2026 14:48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NanoMAI review

Reviewed commit: db17024c75206b444cc06170d61b7aa40453706e

nanomai review — two actionable findings, not an approval

Reviewed the complete 13-file diff for rosebudai/rosebud-cli#5 at db17024, against real merge base 7a5d793. The requested intake SHA, initially selected SHA, final fetched head and final PR metadata head all match. The base branch head is 7a5d793a91da226cfa9caa73ed36ce98081856ef. No source was edited, committed or pushed; the tracked worktree is clean.

Findings

  1. Unicode-equivalent filenames do not round-trip on Linux. Pull can manufacture duplicate copies of a single server path and prevent further updates, even after the user follows the recovery command. The first inline comment includes text and media reproductions.
  2. Downloads without stored checksums ignore their known size. A shorter-than-advertised successful download can replace an asset and become the saved sync baseline instead of being rejected. See the second inline comment.

Scope and before/after behavior

This adds pull for editor-made games, with three-way comparisons, separate remote conflict copies, a guard on unresolved conflicts and a folder-specific sync baseline. Reading files back requires the owner’s code-download entitlement; publishing and updating remain free. Directory media now uses upload sessions and up to four signed PUTs at once instead of having to fit in one 10 MiB ZIP; unsupported servers retain the one-ZIP fallback. New limits are 19 MiB per file, 2,000 files/1 GiB per game, a text ZIP of 10 MiB compressed/50 MiB expanded/500 entries, and the server’s current 128 MiB ceiling on newly staged media. Login scopes and inactivity expiry diagnostics, replay recovery and stale-lock/signal handling are also included. The package remains 0.1.0; this PR is not itself an npm release.

Independently executed verification

  • Environment: Linux, case-sensitive and Unicode-form-sensitive filesystem; Node v22.23.3, npm 10.9.9.
  • npm ci passed (5 packages installed; audit reported 0 vulnerabilities).
  • npm run check passed, and npm test passed including its TypeScript build: 166 tests, 159 passed, 7 skipped, 0 failed. The seven skips are five case-insensitive-disk and two Unicode-form-insensitive-disk integration cases. The same typecheck, formatting check, build and suite also passed when invoked directly through Node. Initial unmodified npm script execution hit the executor’s /workspace noexec mount; temporary executable shims outside the repository invoked the same installed TypeScript/Prettier scripts through Node, after which the actual npm gates passed. No source or package scripts were changed for that workaround.
  • git diff --check 7a5d793a91da226cfa9caa73ed36ce98081856ef db17024c75206b444cc06170d61b7aa40453706e passed.
  • Three additional isolated regression probes against the built CLI and bundled fake server failed their expected-correct-behavior assertions, reproducing the two findings: NFC/NFD media matching (including the advised force/update sequence), a locally edited NFD text file receiving an NFC duplicate, and a known-size/null-MD5 download accepting 15 bytes for an advertised 16. The committed suite does not catch these. Probe source and logs are preserved under /tmp/nanomai-pr5-review/; temporary game folders and local fixture servers were cleaned up.

GitHub evidence and access

mai-github-checks rosebudai/rosebud-cli db17024c75206b444cc06170d61b7aa40453706e --pr 5 was run after selecting the head and refreshed immediately before this proposal. Final observation: 2026-10-28T15:02:34.658448+00:00; reader identity rosebud-agent-cerulean-fox; aggregate state passed; exact reviewed SHA db17024. Evidence: checks for the reviewed commit.

  • checks: repos/rosebudai/rosebud-cli/commits/db17024c75206b444cc06170d61b7aa40453706e/check-runs, HTTP 200, credential github_app_installation (rosebud-agent-checks; app 5113875, installation 165937284), state passed.
  • statuses: repos/rosebudai/rosebud-cli/commits/db17024c75206b444cc06170d61b7aa40453706e/status, HTTP 200, credential reviewer_pat, state no_checks.
    • Empty result; this surface has no checks, not a passing check.

No check/status endpoint was unavailable or access-denied; both were readable. The startup receipt concerns a different revision and was not used as reviewed-head evidence. These CI results were read from GitHub, not independently executed Windows checks.

The PR metadata endpoint (repos/rosebudai/rosebud-cli/pulls/5) returned HTTP 200 on both initial and final reads; the PR is open, non-draft, and its head did not move. Its requested-reviewer and requested-team arrays are both empty. The reviews endpoint returned HTTP 200 and an empty list. This proposal does not establish that a reviewer request still exists: the coordinator must verify publication eligibility and existing approval rules; no GitHub review write was performed by this executor.

Validation limits and author-reported evidence

I did not independently run a real Gateway integration, macOS/Windows filesystem tests, real-user gameplay or production traffic. The PR description’s macOS/Node 22/24 test results and local-Gateway 56 MB publish/conflict/pull/one-PUT/merge flow are author-reported, not independently reproduced receipts. Passing repository/CI checks does not establish release readiness, dependent Gateway deployment, live sync enablement, merge, deployment or npm publication. Preserve the release prerequisite: do not tag 0.2.0 until the dependent Gateway changes are live and sync is enabled in Live.

Comment thread src/sync.ts Outdated
foldsCase = foldsForm = true;
}
return (path) => {
const form = foldsForm ? path.normalize("NFC") : path;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Match the server’s Unicode-equivalent paths on Linux too

On a form-sensitive disk this returns the raw spelling, but the upload/server path identity is NFC. Reproduced on Linux with the bundled fake (nfc: true): publish assets/cafe\u0301.png, change the remote assets/caf\u00e9.png, then pull. The unchanged local asset is reported as local_deleted and local_only; following the suggested pull --force creates both spellings, reports synced, and the next update fails with duplicate_path. A locally edited NFD text file similarly gets a second NFC copy on a plain pull, so even the advised --expected-version update is refused. Match the server’s NFC-equivalent logical paths to the existing local spelling separately from disk case rules, while still detecting genuinely duplicated local names. Add Linux text/media round-trip tests that go through the subsequent update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0b1225. Names are now matched regardless of Unicode form on every disk, because Rosebud names files in NFC. Letter case still follows the disk. Rosebud's change lands on the local file under its local spelling, so no second copy is created. Two local names that differ only in form are still refused with duplicate_path on update. The Unicode-form pull tests now run on every platform, including Linux, instead of skipping where the disk keeps forms apart. Each goes through a later update. A new test covers your media case: publish an NFD name, change it remotely, pull, then update.

Comment thread src/sync.ts Outdated
const to = join(incoming, randomUUID());
await api.downloadAsset({ url: file.url, path: to, overwrite: true });
const md5 = await md5File(to);
if (file.md5 !== null && md5 !== file.md5) throw corrupt(path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate the known size when a download has no checksum

GameFiles.media can provide md5: null with a known byte size, but the download count is discarded and this check then verifies nothing. With the existing fake configured as { hideMd5: true, damageMedia: true }, a manifest advertising a 16-byte replacement returns a 15-byte HTTP-success body. Pull exits successfully, replaces the local asset with those incomplete bytes, records their digest, and reports synced. Check every known size against the staged download before changing the game folder, and reject a mismatch as download_corrupt without advancing the handle or replacing the existing file. Please cover the known-size/null-MD5 case, not only corrupt downloads with an MD5.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0b1225. Every download is now checked against its known size as well as its MD5, before the folder changes. A mismatch fails with download_corrupt, leaving the file and the handle unchanged. Added the known-size/null-MD5 test ({ hideMd5: true, damageMedia: true }).

…a download's size when it has no checksum

On a disk that keeps Unicode forms apart, as on Linux, pull compared names as
the disk spells them, while Rosebud names every file in NFC. A file published
from a decomposed name then looked deleted locally and new remotely; --force
wrote a second spelling, and the next update failed with duplicate_path. Names
are now matched regardless of form everywhere, and Rosebud's version lands on
the local file under its local name. Letter case still follows the disk.

A media file Rosebud lists without an MD5 was checked against nothing, so a
short download could replace the local file and become the sync baseline. Its
known size is now checked, and a mismatch fails with download_corrupt before
the folder changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NanoMAI review

Reviewed commit: d0b1225e58f8ac7c1b4bb100d81e952322dd6311

nanomai rereview — both findings resolved

Reviewed d0b1225 for rosebud-cli PR #5. This is a focused rereview of the entire three-file follow-up diff from db17024, with the affected pull planner, upload path matching, download implementation and regression tests traced in context. The PR merge base and current main head remain 7a5d793. I found no remaining actionable issues in these follow-up changes and recommend approval of the code.

Earlier findings

  • Unicode-equivalent filenames: resolved. Matching always uses NFC identity while preserving disk-specific letter-case rules. Remote changes reach the existing local spelling rather than manufacturing a duplicate. Independently verified local text edits, a later editor conflict and merged update using --expected-version; media pull, force rerun and an edited upload; and new remote files entering an existing Unicode-equivalent parent directory. The committed suite also verifies that genuinely duplicated local names remain refused by update.
  • Known-size downloads without an MD5: resolved. Every available size is checked against the staged download's byte count, in addition to any available checksum, before modifying game files. Independent shorter- and longer-than-advertised null-MD5 cases returned download_corrupt, preserved the original media, text, directory contents and state file, and left no staging leftovers. Clean retries then succeeded.

Independently executed validation

  • Linux, case-sensitive and Unicode-form-sensitive disk; Node v22.23.3, npm 10.9.9. npm ci passed with 0 reported vulnerabilities.
  • npm run check passed (typecheck and Prettier); npm test passed, including its TypeScript build: 168 tests, 163 passed, 5 skipped, 0 failed. All five skips require a case-insensitive filesystem. The Unicode regressions now execute on Linux instead of skipping.
  • Executor limitation: ordinary npm script attempts could not execute tsc from the /workspace noexec mount. The actual npm check/test gates were then run with a temporary script-shell wrapper and executable shims outside the repository, invoking the same installed TypeScript/Prettier entrypoints through Node. No package scripts or source files were changed for this workaround.
  • Six additional isolated regression probes all passed on this head. The same probes all failed their expected-correct-behavior assertions on an independently compiled archive of db17024c75206b444cc06170d61b7aa40453706e; these were behavioral failures, not setup failures. Probe source and before/after logs are retained under /tmp/nanomai-rereview-5/ for this executor session.
  • git diff --check 7a5d793a91da226cfa9caa73ed36ce98081856ef d0b1225e58f8ac7c1b4bb100d81e952322dd6311 passed. No tracked files were edited, committed or pushed. Temporary fixture servers and game folders were cleaned up; the tracked worktree is clean.

Refreshed GitHub evidence

mai-github-checks rosebudai/rosebud-cli d0b1225e58f8ac7c1b4bb100d81e952322dd6311 --pr 5 was run after selecting the head and refreshed immediately before preparing this proposal. Final observation: 2026-10-28T15:20:13.312897+00:00, reader identity rosebud-agent-cerulean-fox, aggregate state passed. Reviewed-commit checks.

  • test (ubuntu-latest): passed, status completed, conclusion success.

  • test (windows-latest): passed, status completed, conclusion success.

  • Checks endpoint repos/rosebudai/rosebud-cli/commits/d0b1225e58f8ac7c1b4bb100d81e952322dd6311/check-runs: HTTP 200, state passed, credential github_app_installation (rosebud-agent-checks, app 5113875, installation 165937284).

  • Statuses endpoint repos/rosebudai/rosebud-cli/commits/d0b1225e58f8ac7c1b4bb100d81e952322dd6311/status: HTTP 200, state no_checks, credential reviewer_pat. Its list is empty; this is not a separate passing check.

Neither evidence endpoint was unavailable or denied. The startup capability receipt was not used as reviewed-head evidence. The final PR metadata read succeeded (HTTP 200): open, non-draft, unchanged head d0b1225. Requested reviewers and teams are empty. This proposal is in response to the current human rereview request, not the older automatic request. No GitHub review write was performed by this executor; publication remains for the coordinator to verify and perform.

Limits and release prerequisites

I did not independently rerun a real Gateway integration or macOS/Windows filesystem behavior. Windows success above is GitHub CI evidence, not a local Windows run. Approval of this code does not verify dependent Gateway deployments, live sync enablement, merge, deployment or npm publication. The existing release prerequisite remains: do not tag 0.2.0 until the dependent Gateway changes are live and sync is enabled in Live.

@kory-stiger
kory-stiger merged commit 1bea8e3 into main Oct 28, 2026
2 checks passed
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.

2 participants