feat: --save-catalog, workspace:* parsing, and sharedWorkspaceLockfile=false#418
feat: --save-catalog, workspace:* parsing, and sharedWorkspaceLockfile=false#418
Conversation
Ports all 8 tests from pnpm/test/saveCatalog.ts as full-body skip stubs. aube has no `--save-catalog` / `--save-catalog-name` flags on `aube add` — its catalog surface is the `catalogMode` config setting, which rewrites manifest specs to `catalog:` when an entry already exists, but doesn't write NEW entries INTO the catalog. These stubs document the desired pnpm behavior precisely so removing the `skip` line on each test should validate the eventual flag implementation. Updated test/PNPM_TEST_IMPORT.md to mark this row as blocked on the missing flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR lands four pnpm-parity features in Confidence Score: 5/5Safe to merge; only P2 documentation and comment nits found. No P0 or P1 issues found. Both findings are P2: a stale count in the test file header and a sharedWorkspaceLockfile description that describes the non-default (false) behavior. test/pnpm_savecatalog.bats (stale header comment) and crates/aube-settings/settings.toml (inverted description wording) Important Files Changed
Reviews (4): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 45f158e. Configure here.
Benchmark changesVersions:
Public ratios: warm installs vs Bun 3x -> 6x; warm installs vs pnpm 9x -> 8x.
03b10d2 vs 113eb8b | aube/bun/pnpm | 3 scenarios | 3 runs | 500mbit/50ms | generated by Codex. |
Implements pnpm's `--save-catalog` / `--save-catalog-name=<name>` flags for `aube add`. Writes `catalog:` (or `catalog:<name>`) into package.json and upserts the resolved range into the workspace yaml's `catalog:` / `catalogs.<name>` section in a single edit_workspace_yaml pass. Existing catalog entries are never overwritten — when the package already lives in the target catalog the manifest gets `catalog:` only if the existing entry is range-compatible with the user's spec; otherwise the user's explicit spec is written verbatim and the catalog stays untouched. Workspace, npm:, jsr:, and pre- catalog: specs are excluded from catalogization. catalogMode is short-circuited when --save-catalog is set so the user's explicit intent always wins. Tests: ports 6 of 8 cases from pnpm/test/saveCatalog.ts; the remaining two skips are blocked on `sharedWorkspaceLockfile=false` (per-project lockfiles not implemented) and `<pkg>@workspace:*` spec parsing in `aube add`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `aube add foo@latest --save-catalog` queued the catalog entry, the original code wrote the literal "latest" tag into the workspace yaml because `has_explicit_range` is true for `foo@latest`. pnpm resolves the dist-tag to a concrete version with the save-prefix — e.g. `^1.2.3` — so subsequent installs don't drift to a new latest. `manual_specifier` already encodes the same shape we want for the catalog (explicit ranges pass through, dist-tags get `<save-prefix><resolved-version>`, save-exact drops the prefix), so just route through it. The `npm:` / `jsr:` paths are unreachable here because they hit the `exclude_from_catalog` early return. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bots flagged a `run grep -F "project-0:" pnpm-workspace.yaml` with no follow-up `assert_*` (and a comment about `packages:` that didn't match YAML's actual list shape). The negative invariant is already covered by the next `^catalog:` grep, so just delete the orphan. Test stays `skip`'d on the workspace:* parsing gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`aube add foo@workspace:*` (and `workspace:^`, `workspace:~`, `workspace:1.2.0`, etc.) used to fail because the parser correctly extracted `range = "workspace:*"` and then `node_semver::Range::parse` choked on the protocol prefix. Add an early branch in the per-package loop: when the spec range is a workspace protocol, skip packument fetching, look the package up in the local workspace by manifest `name`, and write the user's literal `workspace:<…>` spec into the manifest (the install pipeline already turns it into a `link:../foo` symlink). Errors clearly when the workspace package is missing. Unblocks pnpm/test/saveCatalog.ts:333 (the workspace-deps-not- catalogized test) — moving 7/8 of the saveCatalog ports green. The one remaining skip is sharedWorkspaceLockfile=false. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--no-save` snapshots and restores package.json + the lockfile, but not the workspace yaml. Combining it with `--save-catalog` / `--save-catalog-name` would silently leave an orphaned catalog entry behind: the catalog mutation persists, the manifest entry that referenced it gets reverted. Cheapest correct fix is to declare the conflict at the clap level so users see "the argument '--no-save' cannot be used with '--save-catalog'" up front instead of discovering the corruption later. Snapshotting the workspace yaml under --no-save would also work but is meaningfully more plumbing for a flag combination nobody asked for. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default `true` matches pnpm. When set to `false` in the workspace yaml, `aube install` writes one lockfile per workspace member next to its `package.json` (importer remapped to `.`, transitive closure preserved) and skips the workspace-root lockfile entirely. The resolver still runs once over the whole workspace so `workspace:*` deps resolve correctly — only the lockfile *write* phase splits. Implementation reuses the existing `LockfileGraph::subset_to_importer` helper (same machinery `aube deploy` uses) in a new `write_per_project_lockfiles` pass that runs at both write sites (main install + `--lockfile-only`). Caveats documented at the setting site: - Auto-install state (`node_modules/.aube-state`) and the frozen-lockfile fast path stay anchored at the workspace root, so installs under this layout re-resolve more eagerly than shared installs do. Unblocks pnpm/test/saveCatalog.ts:213, finishing all 8 saveCatalog.ts ports green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Summary
Lands all 8 of the pnpm/test/saveCatalog.ts ports green by implementing four pieces of pnpm parity in
aube add/aube install:--save-catalog— writecatalog:to package.json and seed/upsert the resolved range undercatalog:in the workspace yaml.--save-catalog-name=<name>— same for a named catalog (catalogs.<name>); writescatalog:<name>to package.json.<pkg>@workspace:*(andworkspace:^,workspace:~,workspace:1.2.0) —aube addno longer chokes when the user passes a workspace-protocol spec.sharedWorkspaceLockfile=false— workspace-yaml setting that flips the lockfile layout to one lockfile per workspace member (importer remapped to.) instead of a single root lockfile.--save-catalog behavior
catalog:(orcatalog:<name>) intopackage.json.catalog:(orcatalogs.<name>). Range follows the same rule as a normalaube addwrite — explicit user range as-is, dist-tags / no-range get<save-prefix><resolved-version>.catalog:only if the existing entry is range-compatible; otherwise the user's explicit spec is written verbatim and the catalog stays untouched.workspace:,npm:,jsr:, and pre-catalog:specs are never catalogized.--save-catalogis set, the user's explicit intent overridescatalogMode={prefer,strict,manual}.edit_workspace_yamlcall so the file is rewritten at most once per command.--no-saveconflict: clap rejects--save-catalog/--save-catalog-namecombined with--no-savebecause the workspace-yaml mutation isn't covered by the--no-savesnapshot/restore path.workspace:* parsing
parse_pkg_specalready extractsrange="workspace:*"; the per-package loop now branches on it before the registry path. Workspace-resolution path:nameinaube_workspace::find_workspace_packageswalking up from cwd.workspace:<…>spec to the manifest (install resolves it tolink:../foo).sharedWorkspaceLockfile=false
New workspace-yaml setting (default
true, matching pnpm). When flipped tofalse:<member>/aube-lock.yaml(orpnpm-lock.yamlwhen the project already uses pnpm format) carrying its own importer remapped to.plus the transitive closure reachable from it.workspace:*deps resolve correctly.Implementation reuses
LockfileGraph::subset_to_importer(the same helperaube deployuses to slice a workspace lockfile down to one importer). Caveats documented at the setting site: auto-install state (node_modules/.aube-state) and the frozen-lockfile fast path stay anchored at the workspace root, so installs under this layout re-resolve more eagerly than shared installs do.Tests
test/pnpm_savecatalog.bats ports all 8 cases from
pnpm/test/saveCatalog.ts. 8/8 run green:sharedWorkspaceLockfile=false)workspace:*deps not catalogized--save-catalog --recursive--save-catalog-name=<name>named catalogsPlus regression tests:
aube add --save-catalog conflicts with --no-save(clap conflict, both--save-catalogand--save-catalog-name)aube install: sharedWorkspaceLockfile=false writes per-project lockfiles(test/workspace.bats)Substitutions for the offline registry:
bar/pkg-a→is-odd,foo→is-even,pkg-b→is-number,pkg-c→semver.Test plan
cargo test— 330 + smaller crate suites passcargo clippy --all-targets -- -D warnings— cleanmise run test:bats test/pnpm_savecatalog.bats— 9 ok (8 saveCatalog ports + the --no-save conflict regression)mise run test:bats test/workspace.bats test/install.bats test/catalogs.bats test/add.bats— no regressions🤖 Generated with Claude Code