Skip to content

feat: --save-catalog, workspace:* parsing, and sharedWorkspaceLockfile=false#418

Merged
jdx merged 10 commits intomainfrom
claude/pnpm-port-savecatalog
Apr 30, 2026
Merged

feat: --save-catalog, workspace:* parsing, and sharedWorkspaceLockfile=false#418
jdx merged 10 commits intomainfrom
claude/pnpm-port-savecatalog

Conversation

@jdx
Copy link
Copy Markdown
Contributor

@jdx jdx commented Apr 30, 2026

Summary

Lands all 8 of the pnpm/test/saveCatalog.ts ports green by implementing four pieces of pnpm parity in aube add / aube install:

  1. --save-catalog — write catalog: to package.json and seed/upsert the resolved range under catalog: in the workspace yaml.
  2. --save-catalog-name=<name> — same for a named catalog (catalogs.<name>); writes catalog:<name> to package.json.
  3. <pkg>@workspace:* (and workspace:^, workspace:~, workspace:1.2.0) — aube add no longer chokes when the user passes a workspace-protocol spec.
  4. 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

  • Manifest write: writes catalog: (or catalog:<name>) into package.json.
  • Workspace yaml upsert: resolved range lands under catalog: (or catalogs.<name>). Range follows the same rule as a normal aube add write — explicit user range as-is, dist-tags / no-range get <save-prefix><resolved-version>.
  • Existing entries are never overwritten: when the package is already in the target catalog, the manifest gets catalog: only if the existing entry is range-compatible; otherwise the user's explicit spec is written verbatim and the catalog stays untouched.
  • Excluded specs: workspace:, npm:, jsr:, and pre-catalog: specs are never catalogized.
  • catalogMode short-circuit: when --save-catalog is set, the user's explicit intent overrides catalogMode={prefer,strict,manual}.
  • Single-pass yaml write: per-package decisions queue into one edit_workspace_yaml call so the file is rewritten at most once per command.
  • --no-save conflict: clap rejects --save-catalog/--save-catalog-name combined with --no-save because the workspace-yaml mutation isn't covered by the --no-save snapshot/restore path.

workspace:* parsing

parse_pkg_spec already extracts range="workspace:*"; the per-package loop now branches on it before the registry path. Workspace-resolution path:

  • Skips packument fetching (workspace deps don't live on the registry).
  • Looks the package up by name in aube_workspace::find_workspace_packages walking up from cwd.
  • Errors clearly when the workspace package is missing.
  • Writes the user's literal workspace:<…> spec to the manifest (install resolves it to link:../foo).

sharedWorkspaceLockfile=false

New workspace-yaml setting (default true, matching pnpm). When flipped to false:

  • Each workspace member gets <member>/aube-lock.yaml (or pnpm-lock.yaml when the project already uses pnpm format) carrying its own importer remapped to . plus the transitive closure reachable from it.
  • The workspace-root lockfile is not written.
  • The resolver still runs once over the whole workspace, so workspace:* deps resolve correctly.

Implementation reuses LockfileGraph::subset_to_importer (the same helper aube deploy uses 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:

# Scenario
1 single-package workspace catalog write
2 shared-lockfile workspace
3 multi-lockfile workspace (sharedWorkspaceLockfile=false)
4 workspace:* deps not catalogized
5 edited-into-package.json deps not catalogized
6 existing catalog entries never overwritten
7 --save-catalog --recursive
8 --save-catalog-name=<name> named catalogs

Plus regression tests:

  • aube add --save-catalog conflicts with --no-save (clap conflict, both --save-catalog and --save-catalog-name)
  • aube install: sharedWorkspaceLockfile=false writes per-project lockfiles (test/workspace.bats)

Substitutions for the offline registry: bar/pkg-ais-odd, foois-even, pkg-bis-number, pkg-csemver.

Test plan

  • cargo test — 330 + smaller crate suites pass
  • cargo clippy --all-targets -- -D warnings — clean
  • mise 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

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-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 30, 2026

Greptile Summary

This PR lands four pnpm-parity features in aube add / aube install: --save-catalog and --save-catalog-name (catalog yaml upsert with a never-overwrite, single-pass write strategy), workspace:* spec parsing that bypasses the registry path, and sharedWorkspaceLockfile=false (per-project lockfile layout via LockfileGraph::subset_to_importer). The clap-level conflicts_with guard on --no-save correctly prevents the orphaned-catalog-entry scenario. All 8 ported test cases run green with good offline-registry substitutions.

Confidence Score: 5/5

Safe 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

Filename Overview
crates/aube/src/commands/add.rs Adds --save-catalog/--save-catalog-name flags with clap conflicts enforced against --no-save; workspace:* specs bypass registry path; catalog upserts queued and applied in a single yaml write per invocation.
crates/aube/src/commands/catalogs.rs Exports range_compatible as pub(crate); adds CatalogUpsert struct and upsert_catalog_entries using or_insert_with to enforce never-overwrite semantics.
crates/aube/src/commands/install/mod.rs Reads sharedWorkspaceLockfile setting and branches lockfile writes: shared path unchanged, false path calls new write_per_project_lockfiles which subsets the graph per importer and skips the root '.'.
crates/aube-settings/settings.toml Adds sharedWorkspaceLockfile setting; description text describes the false (non-default) behavior, which will confuse readers.
test/pnpm_savecatalog.bats Ports all 8 saveCatalog.ts tests; header comment incorrectly states 6/8 run with 2 remaining skips when all 8 tests are now active.
crates/aube-manifest/src/workspace.rs Adds optional shared_workspace_lockfile field to WorkspaceConfig for settings-meta parity; no logic changes.
test/workspace.bats Adds regression test for sharedWorkspaceLockfile=false; verifies per-project lockfiles exist and root lockfile is absent, and checks importer isolation via awk+grep.

Fix All in Claude Code

Reviews (4): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile

Comment thread test/pnpm_savecatalog.bats
Comment thread test/pnpm_savecatalog.bats
Comment thread test/pnpm_savecatalog.bats
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread test/pnpm_savecatalog.bats Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 30, 2026

Benchmark changes

Versions:

  • aube: 1.5.1 -> 1.5.2
  • pnpm: 11.0.1 -> 11.0.3

Public ratios: warm installs vs Bun 3x -> 6x; warm installs vs pnpm 9x -> 8x.

Benchmark aube bun pnpm
Fresh install (warm cache) 237ms -> 317ms (+34%) 728ms -> 1926ms (+165%) 2104ms -> 2549ms (+21%)
CI install (warm cache, GVS disabled) 564ms -> 1036ms (+84%) 742ms -> 1972ms (+166%) 2094ms -> 2401ms (+15%)
CI install (cold cache, GVS disabled) 2282ms -> 4227ms (+85%) 1895ms -> 4331ms (+129%) 5439ms -> 5448ms (+0%)

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>
@jdx jdx changed the title test: stub pnpm saveCatalog.ts ports (blocked on --save-catalog flag) feat(cli): add --save-catalog and --save-catalog-name to aube add Apr 30, 2026
autofix-ci Bot and others added 3 commits April 30, 2026 21:22
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>
Comment thread crates/aube/src/commands/add.rs
`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>
@jdx jdx changed the title feat(cli): add --save-catalog and --save-catalog-name to aube add feat(cli): add --save-catalog, --save-catalog-name, and workspace:* parsing to aube add Apr 30, 2026
jdx and others added 3 commits April 30, 2026 16:33
`--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>
@jdx jdx changed the title feat(cli): add --save-catalog, --save-catalog-name, and workspace:* parsing to aube add feat: --save-catalog, workspace:* parsing, and sharedWorkspaceLockfile=false Apr 30, 2026
@jdx jdx enabled auto-merge (squash) April 30, 2026 22:01
@jdx jdx merged commit a4ba957 into main Apr 30, 2026
16 checks passed
@jdx jdx deleted the claude/pnpm-port-savecatalog branch April 30, 2026 22:05
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