diff --git a/aube.usage.kdl b/aube.usage.kdl index 79d1f310..24d1f413 100644 --- a/aube.usage.kdl +++ b/aube.usage.kdl @@ -83,6 +83,9 @@ cmd add help="Add a dependency" { flag --no-save help="Install without persisting the dependency to `package.json`" { long_help "Install without persisting the dependency to `package.json`.\n\nSnapshots `package.json` and the lockfile, links the named packages into `node_modules`, and then restores both files — so the dependency is usable for the current process but the project's committed state is untouched.\n\nHandy for one-off experiments and for scripts that install a tool transiently. Mirrors `pnpm add --no-save`. Conflicts with `-g`/`--global`, which has to persist the install to its global manifest." } + flag --no-save-workspace-protocol help="Inverse of `--save-workspace-protocol`" { + long_help "Inverse of `--save-workspace-protocol`.\n\nForces the manifest specifier into a registry-style spec (`^`) for this invocation, even when `linkWorkspacePackages` matched a local sibling. The install pipeline still prefers the local workspace copy at resolve time — this flag only controls what's written to `package.json`. Mirrors `pnpm add --no-save-workspace-protocol`." + } flag --save-catalog help="Save the new dependency into the workspace's default catalog" { long_help "Save the new dependency into the workspace's default catalog.\n\nWrites `catalog:` into `package.json` and seeds/upserts the resolved range under `catalog:` in the workspace yaml. Mirrors `pnpm add --save-catalog`.\n\nWorkspace and aliased specs (`workspace:*`, `npm:`, `jsr:`) are never catalogized — the manifest gets the original spec and the catalog yaml is left alone. If the package is already in the target catalog, the existing entry is preserved (never overwritten); the manifest then gets `catalog:` only when the existing entry is compatible with the user's range.\n\nConflicts with `--no-save`: catalog mutations write to the workspace yaml, which the `--no-save` restore path doesn't snapshot — combining the two would silently leave an orphaned catalog entry behind." } @@ -93,6 +96,9 @@ cmd add help="Add a dependency" { flag --save-peer help="Add as a peer dependency (written to `peerDependencies` in package.json)" { long_help "Add as a peer dependency (written to `peerDependencies` in package.json).\n\nBy convention you usually pair this with `--save-dev` so the peer is also installed for local development; that's what pnpm does." } + flag --save-workspace-protocol help="Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env" { + long_help "Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env.\n\nOnly meaningful when `linkWorkspacePackages` (or a workspace sibling already exists for the named package). With this flag the entry written to `package.json` is `workspace:^` (rolling) or `workspace:^` (pinned), depending on the resolved `saveWorkspaceProtocol` value." + } flag "-w --workspace" help="Add the dependency to the workspace root's `package.json`" { long_help "Add the dependency to the workspace root's `package.json`.\n\nApplies regardless of the current working directory: walks up from cwd looking for `aube-workspace.yaml`, `pnpm-workspace.yaml`, or a `package.json` with a `workspaces` field and runs the add against that directory." } diff --git a/crates/aube-manifest/src/workspace.rs b/crates/aube-manifest/src/workspace.rs index 4b171c9a..c4934bac 100644 --- a/crates/aube-manifest/src/workspace.rs +++ b/crates/aube-manifest/src/workspace.rs @@ -365,6 +365,23 @@ pub struct WorkspaceConfig { #[serde(default)] pub cleanup_unused_catalogs: Option, + // -- Workspace-protocol settings -- + /// Resolve `aube add ` against local workspace siblings + /// before falling back to the registry. Wired through + /// `aube_settings::resolved::link_workspace_packages`; the typed + /// field exists so `meta::workspace_yaml_keys_...` sees the key + /// as a real field and doesn't fall through to `extra`. + #[serde(default)] + pub link_workspace_packages: Option, + + /// Spec form written to `package.json` when `aube add` matches a + /// workspace sibling. The yaml value can be the booleans `true` / + /// `false` or the string `"rolling"`, so the typed field lands at + /// `yaml_serde::Value` and the resolver normalizes via + /// `aube_settings::resolved::SaveWorkspaceProtocol::from_str_normalized`. + #[serde(default)] + pub save_workspace_protocol: Option, + // -- Peer Dependency Settings -- /// Whether to auto-install peer dependencies (default: true). #[serde(default)] diff --git a/crates/aube-settings/settings.toml b/crates/aube-settings/settings.toml index 9cbe419c..5812b012 100644 --- a/crates/aube-settings/settings.toml +++ b/crates/aube-settings/settings.toml @@ -1985,6 +1985,69 @@ sources.env = ["npm_config_save_prefix", "NPM_CONFIG_SAVE_PREFIX", "AUBE_SAVE_PR sources.npmrc = ["save-prefix", "savePrefix"] examples = [] +[linkWorkspacePackages] +description = "Resolve `aube add ` against local workspace siblings before falling back to the registry." +type = "bool" +default = "false" +docs = """ +When `true`, `aube add ` checks the workspace for a package +whose `name` matches the spec before falling back to the registry. +A match wires the dep up as a workspace link; the manifest specifier +written to `package.json` is controlled by `saveWorkspaceProtocol`. + +If the user typed an explicit range (`aube add pkg@^1.2.0`), the +sibling's version must satisfy it — otherwise the spec falls through +to the registry path so an incompatible local copy isn't silently +linked. + +Off by default to match pnpm 8+ — opt in via `pnpm-workspace.yaml` +when you want every `aube add` to prefer the local copy of a sibling. +Aube's resolver already prefers workspace siblings on bare semver +ranges at install time, so this setting only controls `aube add`'s +manifest-write path; pnpm's `"deep"` value (which extends the +preference to transitive deps) is therefore not implemented and +treating the value as a plain bool is intentional. +""" +sources.cli = [] +sources.env = [ + "npm_config_link_workspace_packages", + "NPM_CONFIG_LINK_WORKSPACE_PACKAGES", + "AUBE_LINK_WORKSPACE_PACKAGES", +] +sources.npmrc = ["link-workspace-packages", "linkWorkspacePackages"] +sources.workspaceYaml = ["linkWorkspacePackages"] +examples = [] + +[saveWorkspaceProtocol] +description = "Spec form written to `package.json` when `aube add` resolves against a workspace sibling." +type = '"true" | "false" | "rolling"' +default = "\"rolling\"" +docs = """ +- `"true"` writes a version-pinned workspace spec (`workspace:^1.0.0`, + honoring `savePrefix`). The exact lockfile entry never moves + without an explicit `aube update`. +- `"rolling"` (default) writes the rolling form `workspace:^` + (or `workspace:~` / `workspace:*` per `savePrefix`). Sibling + version bumps flow into dependents on the next install without + re-running `aube add`. +- `"false"` writes a plain registry-style spec (`^1.0.0`). The dep + is still linked locally on install (controlled by + `linkWorkspacePackages`), but the manifest looks identical to a + registry dep. + +The `--save-workspace-protocol` / `--no-save-workspace-protocol` CLI +flags on `aube add` override this setting per-invocation. +""" +sources.cli = [] +sources.env = [ + "npm_config_save_workspace_protocol", + "NPM_CONFIG_SAVE_WORKSPACE_PROTOCOL", + "AUBE_SAVE_WORKSPACE_PROTOCOL", +] +sources.npmrc = ["save-workspace-protocol", "saveWorkspaceProtocol"] +sources.workspaceYaml = ["saveWorkspaceProtocol"] +examples = [] + [tag] description = "Default dist-tag used by `aube add` without a version." type = "string" diff --git a/crates/aube/src/commands/add.rs b/crates/aube/src/commands/add.rs index 3ee639ed..4732404a 100644 --- a/crates/aube/src/commands/add.rs +++ b/crates/aube/src/commands/add.rs @@ -82,6 +82,16 @@ pub struct AddArgs { /// manifest. #[arg(long, conflicts_with = "global")] pub no_save: bool, + /// Inverse of `--save-workspace-protocol`. + /// + /// Forces the manifest specifier into a registry-style spec + /// (`^`) for this invocation, even when + /// `linkWorkspacePackages` matched a local sibling. The install + /// pipeline still prefers the local workspace copy at resolve + /// time — this flag only controls what's written to + /// `package.json`. Mirrors `pnpm add --no-save-workspace-protocol`. + #[arg(long, overrides_with = "save_workspace_protocol")] + pub no_save_workspace_protocol: bool, /// Save the new dependency into the workspace's default catalog. /// /// Writes `catalog:` into `package.json` and seeds/upserts the @@ -117,6 +127,17 @@ pub struct AddArgs { /// does. #[arg(long, conflicts_with = "save_optional")] pub save_peer: bool, + /// Force the manifest specifier into `workspace:` form for this + /// invocation, overriding `saveWorkspaceProtocol` from the + /// workspace yaml / `.npmrc` / env. + /// + /// Only meaningful when `linkWorkspacePackages` (or a workspace + /// sibling already exists for the named package). With this flag + /// the entry written to `package.json` is `workspace:^` (rolling) + /// or `workspace:^` (pinned), depending on the resolved + /// `saveWorkspaceProtocol` value. + #[arg(long, overrides_with = "no_save_workspace_protocol")] + pub save_workspace_protocol: bool, /// Add the dependency to the workspace root's `package.json`. /// /// Applies regardless of the current working directory: walks up @@ -176,6 +197,16 @@ struct ParsedPkgSpec { /// skip the packument fetch, write the verbatim string into /// `package.json`, and let the resolver dispatch the local path. local_spec: Option, + /// Set when `linkWorkspacePackages=true` matched a local sibling + /// for this spec. The string is the sibling's `package.json#version` + /// (or `"0.0.0"` when the sibling has no version). The packument + /// fetch is skipped and the manifest-write path branches on + /// `saveWorkspaceProtocol` to choose between rolling + /// (`workspace:^`), pinned (`workspace:^`), or + /// registry-style (`^`) — the resolver picks up the + /// sibling either way because aube already prefers workspace + /// matches on bare semver ranges. + linked_workspace_version: Option, } /// Parse a package spec into its components. @@ -449,6 +480,7 @@ fn parse_git_pkg_spec(verbatim: &str, alias: Option) -> miette::Result

) -> miette::Result) -> ParsedPkgSpec { has_explicit_range: true, git_spec: None, local_spec: None, + linked_workspace_version: None, }; } } @@ -558,6 +592,7 @@ fn parse_name_range(s: &str, alias: Option) -> ParsedPkgSpec { has_explicit_range: false, git_spec: None, local_spec: None, + linked_workspace_version: None, }; } @@ -571,6 +606,7 @@ fn parse_name_range(s: &str, alias: Option) -> ParsedPkgSpec { has_explicit_range: true, git_spec: None, local_spec: None, + linked_workspace_version: None, } } else { ParsedPkgSpec { @@ -581,6 +617,7 @@ fn parse_name_range(s: &str, alias: Option) -> ParsedPkgSpec { has_explicit_range: false, git_spec: None, local_spec: None, + linked_workspace_version: None, } } } @@ -613,6 +650,7 @@ fn parse_jsr_name_range(s: &str, alias: Option) -> miette::Result`. save_catalog: Option, + /// `--save-workspace-protocol` / `--no-save-workspace-protocol` + /// per-invocation override. `None` defers to the resolved + /// `saveWorkspaceProtocol` setting; `Some(true)` forces the + /// `workspace:` form regardless of the setting; `Some(false)` + /// forces a registry-style spec even when `linkWorkspacePackages` + /// matched a sibling. + workspace_protocol_override: Option, } impl AddManifestOptions { @@ -910,20 +961,52 @@ impl AddManifestOptions { None } }), + workspace_protocol_override: workspace_protocol_override_from_flags( + args.save_workspace_protocol, + args.no_save_workspace_protocol, + ), } } } +/// Map the paired `--save-workspace-protocol` / `--no-save-workspace-protocol` +/// flags to a tri-state. `clap`'s `overrides_with` ensures only the +/// last-typed flag survives, so at most one of the two is `true` at a +/// time and we don't need to disambiguate. Takes the two flag bools +/// directly so the call site in `run()` (which destructures `AddArgs` +/// into locals) and `from_args` can share the logic without going +/// back through the struct. +fn workspace_protocol_override_from_flags(save: bool, no_save: bool) -> Option { + if save { + Some(true) + } else if no_save { + Some(false) + } else { + None + } +} + async fn update_manifest_for_add( cwd: &Path, packages: &[String], opts: AddManifestOptions, print_updated: bool, ) -> miette::Result<()> { - // Resolve settings (savePrefix, tag, catalogMode) from .npmrc / - // workspace yaml. `catalog_mode` decides whether a newly-added dep - // that already lives in the default workspace catalog gets rewritten - // to `catalog:` (see `commands::catalogs::decide_add_rewrite`). + // Resolve settings (savePrefix, tag, catalogMode, link/save + // workspace protocol) from .npmrc / workspace yaml. `catalog_mode` + // decides whether a newly-added dep that already lives in the + // default workspace catalog gets rewritten to `catalog:` (see + // `commands::catalogs::decide_add_rewrite`). + // + // The two settings the workspace yaml owns end-to-end + // (`linkWorkspacePackages`, `saveWorkspaceProtocol`) read from + // the workspace yaml root so a sub-project's `aube add` honors + // the workspace-wide policy declared in + // `pnpm-workspace.yaml`/`aube-workspace.yaml`. Everything else + // (`tag`, `savePrefix`, `catalogMode`) reads from the project's + // own dir so a sub-project's `.npmrc` still wins — switching the + // entire context to the workspace root would silently drop those + // overrides, since `load_npmrc_entries` doesn't walk up. let (default_tag, default_prefix, catalog_mode) = super::with_settings_ctx(cwd, |ctx| { let tag = aube_settings::resolved::tag(ctx); let prefix = if opts.save_exact { @@ -945,6 +1028,16 @@ async fn update_manifest_for_add( let catalog_mode = aube_settings::resolved::catalog_mode(ctx); (tag, prefix, catalog_mode) }); + let workspace_settings_cwd = crate::dirs::find_workspace_yaml_root(cwd) + .or_else(|| crate::dirs::find_workspace_root(cwd)) + .unwrap_or_else(|| cwd.to_path_buf()); + let (link_workspace_packages, save_workspace_protocol_setting) = + super::with_settings_ctx(&workspace_settings_cwd, |ctx| { + ( + aube_settings::resolved::link_workspace_packages(ctx), + aube_settings::resolved::save_workspace_protocol(ctx), + ) + }); // Load the workspace catalog map up front — the resolver needs it // later, but `catalogMode` consults the default catalog while we // build the specifier below. Pass the same map to the resolver to @@ -963,7 +1056,7 @@ async fn update_manifest_for_add( // Parse all specs and fetch packuments concurrently. let client = std::sync::Arc::new(super::make_client(cwd)); - let parsed: Vec<_> = packages + let mut parsed: Vec<_> = packages .iter() .map(|s| { let mut spec = parse_pkg_spec(s)?; @@ -978,18 +1071,78 @@ async fn update_manifest_for_add( }) .collect::>>()?; + // `linkWorkspacePackages=true` (or the `--save-workspace-protocol` + // flag) makes `aube add ` look the package up in the local + // workspace before falling back to the registry. Build the + // (name → version) map once for this invocation and tag any + // matching specs so the packument-fetch loop skips them and the + // manifest-write path branches into the workspace formatter. + if link_workspace_packages || matches!(opts.workspace_protocol_override, Some(true)) { + let workspace_versions = collect_workspace_versions(cwd); + for spec in &mut parsed { + if spec.linked_workspace_version.is_some() { + continue; + } + // Only registry-shaped, non-aliased specs are eligible: + // workspace/git/local/jsr/npm-aliased specs already have + // their own routing and the user typed them on purpose. + // Aliased specs (`my-alias@project-2`) need to skip the + // workspace path too — `workspace:` resolves by manifest + // key, so writing `"my-alias": "workspace:^"` would point + // the resolver at a sibling named `my-alias` (which + // doesn't exist) and 404 on the registry fallback. + if aube_util::pkg::is_workspace_spec(&spec.range) + || aube_util::pkg::is_catalog_spec(&spec.range) + || aube_util::pkg::is_npm_spec(&spec.range) + || aube_util::pkg::is_jsr_spec(&spec.range) + || spec.git_spec.is_some() + || spec.local_spec.is_some() + || spec.jsr_name.is_some() + || spec.alias.is_some() + { + continue; + } + let Some(version) = workspace_versions.get(&spec.name) else { + continue; + }; + // When the user typed an explicit range + // (`aube add pkg@^1.2.0`), the sibling's version must + // satisfy it — otherwise we'd silently link an + // incompatible local copy. Fall through to the registry + // path on a mismatch (and on unparseable ranges, where + // the registry path's error message is more useful than + // a workspace mismatch). Bare adds (no `@`) carry + // `range = "latest"` from the parser; the implicit + // dist-tag never blocks a workspace match. + if spec.has_explicit_range + && let (Ok(parsed_version), Ok(parsed_range)) = ( + node_semver::Version::parse(version), + node_semver::Range::parse(&spec.range), + ) + && !parsed_version.satisfies(&parsed_range) + { + continue; + } + spec.linked_workspace_version = Some(version.clone()); + } + } + // Skip packument fetches for `workspace:*` / `workspace:^` / // `workspace:` specs — they resolve against the local // workspace, not the registry. Same skip applies to git specs // (`kevva/is-negative`, `github:user/repo`, …) and `file:` / // `link:` local-path specs which the resolver dispatches via the - // git or local branch respectively. Without these guards the - // parallel fetch below would 404 on the non-registry name. + // git or local branch respectively. Specs that the + // `linkWorkspacePackages` pass tagged with a sibling version + // also bypass the registry — the workspace is the source of + // truth for those names. Without these guards the parallel + // fetch below would 404 on the non-registry name. let mut handles = Vec::new(); for spec in &parsed { if aube_util::pkg::is_workspace_spec(&spec.range) || spec.git_spec.is_some() || spec.local_spec.is_some() + || spec.linked_workspace_version.is_some() { continue; } @@ -1049,6 +1202,26 @@ async fn update_manifest_for_add( continue; } + // `linkWorkspacePackages=true` matched a sibling for this + // spec. Write either a workspace-form specifier (rolling / + // pinned) or a registry-form specifier per the resolved + // `saveWorkspaceProtocol` setting and the per-invocation + // override; the resolver picks the local copy regardless of + // the form because it already prefers workspace siblings on + // bare semver ranges. + if let Some(version) = spec.linked_workspace_version.as_deref() { + apply_linked_workspace_to_manifest( + &mut manifest, + pkg_name_for_manifest, + version, + save_workspace_protocol_setting, + opts.workspace_protocol_override, + &default_prefix, + &opts, + ); + continue; + } + let packument = packuments.get(&spec.name).unwrap(); eprintln!("Resolving {}@{}...", spec.name, spec.range); @@ -1372,6 +1545,118 @@ fn apply_workspace_spec_to_manifest( Ok(()) } +/// Walk the workspace from `cwd` and build a `name → version` map of +/// every workspace package. Returns an empty map outside a workspace +/// or when discovery fails — `aube add` falls back to the registry +/// path in that case, so a partial workspace shouldn't error here. +fn collect_workspace_versions(cwd: &Path) -> std::collections::HashMap { + let workspace_root = match crate::dirs::find_workspace_yaml_root(cwd) + .or_else(|| crate::dirs::find_workspace_root(cwd)) + { + Some(root) => root, + None => return std::collections::HashMap::new(), + }; + let mut out = std::collections::HashMap::new(); + let dirs = match aube_workspace::find_workspace_packages(&workspace_root) { + Ok(d) => d, + Err(_) => return out, + }; + for dir in dirs { + let Ok(pkg) = aube_manifest::PackageJson::from_path(&dir.join("package.json")) else { + continue; + }; + if let Some(name) = pkg.name { + out.insert(name, pkg.version.unwrap_or_else(|| "0.0.0".to_string())); + } + } + out +} + +/// Write the manifest entry for a `linkWorkspacePackages` match. The +/// resolved `saveWorkspaceProtocol` and the per-invocation +/// `--save-workspace-protocol` / `--no-save-workspace-protocol` +/// override pick the form: +/// +/// - rolling: `workspace:^` (or `~`/`*` per `savePrefix`) +/// - true: `workspace:` (e.g. `workspace:^1.2.3`) +/// - false: `` (e.g. `^1.2.3`) — the manifest looks +/// like a registry dep but the resolver still links the local copy +/// because aube prefers workspace siblings on bare semver ranges. +/// +/// Mirrors the duplicate-section scrub from the registry path so a +/// follow-up `aube add` after a previous `--save-dev` add overwrites +/// the old entry rather than duplicating across sections. +#[allow(clippy::too_many_arguments)] +fn apply_linked_workspace_to_manifest( + manifest: &mut aube_manifest::PackageJson, + pkg_name_for_manifest: &str, + workspace_version: &str, + save_workspace_protocol: aube_settings::resolved::SaveWorkspaceProtocol, + workspace_protocol_override: Option, + save_prefix: &str, + opts: &AddManifestOptions, +) { + use aube_settings::resolved::SaveWorkspaceProtocol; + // `--no-save-workspace-protocol` forces registry-style; explicit + // `--save-workspace-protocol` keeps the configured workspace form + // (defaulting to `rolling` when nothing else picks); otherwise + // defer to the resolved setting. + let effective = match workspace_protocol_override { + Some(false) => SaveWorkspaceProtocol::False, + Some(true) if matches!(save_workspace_protocol, SaveWorkspaceProtocol::False) => { + SaveWorkspaceProtocol::Rolling + } + _ => save_workspace_protocol, + }; + let specifier = match effective { + SaveWorkspaceProtocol::Rolling => { + // Rolling form drops the version: `workspace:^`. Empty + // `savePrefix` (`--save-exact`) collapses to + // `workspace:*` so the rolling sigil still resolves the + // sibling regardless of its version. + let sigil = if save_prefix.is_empty() { + "*" + } else { + save_prefix + }; + format!("workspace:{sigil}") + } + SaveWorkspaceProtocol::True => { + format!("workspace:{save_prefix}{workspace_version}") + } + SaveWorkspaceProtocol::False => { + format!("{save_prefix}{workspace_version}") + } + }; + + eprintln!(" + {pkg_name_for_manifest}@{workspace_version} (specifier: {specifier})"); + + manifest.dependencies.remove(pkg_name_for_manifest); + manifest.optional_dependencies.remove(pkg_name_for_manifest); + if !opts.save_peer { + manifest.peer_dependencies.remove(pkg_name_for_manifest); + } + if !(opts.save_peer && opts.save_dev) { + manifest.dev_dependencies.remove(pkg_name_for_manifest); + } + + let dep_name = pkg_name_for_manifest.to_string(); + if opts.save_peer { + manifest + .peer_dependencies + .insert(dep_name.clone(), specifier.clone()); + if opts.save_dev { + manifest.dev_dependencies.insert(dep_name, specifier); + } + } else if opts.save_dev { + manifest.dev_dependencies.insert(dep_name, specifier); + } else if opts.save_optional { + manifest.optional_dependencies.insert(dep_name, specifier); + } else { + manifest.dependencies.insert(dep_name, specifier); + } +} + /// Write a git-form spec verbatim into the manifest. Mirrors the /// duplicate-section scrub of the registry path so re-running /// `aube add ` after a previous registry add overwrites the @@ -1899,6 +2184,8 @@ async fn run_global_inner( ignore_scripts: false, no_save: false, save_peer: false, + save_workspace_protocol: false, + no_save_workspace_protocol: false, // The throwaway install dir is never a workspace root, but // `run_global_inner` is the one place in aube that chdirs // after startup — if a future refactor reads `dirs::cwd()` diff --git a/docs/cli/add.md b/docs/cli/add.md index 19069402..139eba1a 100644 --- a/docs/cli/add.md +++ b/docs/cli/add.md @@ -58,6 +58,12 @@ Snapshots `package.json` and the lockfile, links the named packages into `node_m Handy for one-off experiments and for scripts that install a tool transiently. Mirrors `pnpm add --no-save`. Conflicts with `-g`/`--global`, which has to persist the install to its global manifest. +### `--no-save-workspace-protocol` + +Inverse of `--save-workspace-protocol`. + +Forces the manifest specifier into a registry-style spec (`^`) for this invocation, even when `linkWorkspacePackages` matched a local sibling. The install pipeline still prefers the local workspace copy at resolve time — this flag only controls what's written to `package.json`. Mirrors `pnpm add --no-save-workspace-protocol`. + ### `--save-catalog` Save the new dependency into the workspace's default catalog. @@ -80,6 +86,12 @@ Add as a peer dependency (written to `peerDependencies` in package.json). By convention you usually pair this with `--save-dev` so the peer is also installed for local development; that's what pnpm does. +### `--save-workspace-protocol` + +Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env. + +Only meaningful when `linkWorkspacePackages` (or a workspace sibling already exists for the named package). With this flag the entry written to `package.json` is `workspace:^` (rolling) or `workspace:^` (pinned), depending on the resolved `saveWorkspaceProtocol` value. + ### `-w --workspace` Add the dependency to the workspace root's `package.json`. diff --git a/docs/cli/commands.json b/docs/cli/commands.json index b480f9b9..f11387d1 100644 --- a/docs/cli/commands.json +++ b/docs/cli/commands.json @@ -151,6 +151,19 @@ "hide": false, "global": false }, + { + "name": "no-save-workspace-protocol", + "usage": "--no-save-workspace-protocol", + "help": "Inverse of `--save-workspace-protocol`", + "help_long": "Inverse of `--save-workspace-protocol`.\n\nForces the manifest specifier into a registry-style spec (`^`) for this invocation, even when `linkWorkspacePackages` matched a local sibling. The install pipeline still prefers the local workspace copy at resolve time — this flag only controls what's written to `package.json`. Mirrors `pnpm add --no-save-workspace-protocol`.", + "help_first_line": "Inverse of `--save-workspace-protocol`", + "short": [], + "long": [ + "no-save-workspace-protocol" + ], + "hide": false, + "global": false + }, { "name": "save-catalog", "usage": "--save-catalog", @@ -197,6 +210,19 @@ "hide": false, "global": false }, + { + "name": "save-workspace-protocol", + "usage": "--save-workspace-protocol", + "help": "Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env", + "help_long": "Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env.\n\nOnly meaningful when `linkWorkspacePackages` (or a workspace sibling already exists for the named package). With this flag the entry written to `package.json` is `workspace:^` (rolling) or `workspace:^` (pinned), depending on the resolved `saveWorkspaceProtocol` value.", + "help_first_line": "Force the manifest specifier into `workspace:` form for this invocation, overriding `saveWorkspaceProtocol` from the workspace yaml / `.npmrc` / env", + "short": [], + "long": [ + "save-workspace-protocol" + ], + "hide": false, + "global": false + }, { "name": "workspace", "usage": "-w --workspace", diff --git a/docs/settings/index.md b/docs/settings/index.md index f7e1c6bf..10d61ec7 100644 --- a/docs/settings/index.md +++ b/docs/settings/index.md @@ -109,6 +109,8 @@ Aube generates this page from [`settings.toml`](https://github.com/endevco/aube/ | [`nodeVersion`](#setting-nodeversion) | `string` | Node.js version aube reports when evaluating `engines` checks. | | [`nodeDownloadMirrors`](#setting-nodedownloadmirrors) | `object` | Custom Node.js download mirror URLs. | | [`savePrefix`](#setting-saveprefix) | `"^" \| "~" \| ""` | Version prefix used when installing a package. | +| [`linkWorkspacePackages`](#setting-linkworkspacepackages) | `bool` | Resolve `aube add <name>` against local workspace siblings before falling back to the registry. | +| [`saveWorkspaceProtocol`](#setting-saveworkspaceprotocol) | `"true" \| "false" \| "rolling"` | Spec form written to `package.json` when `aube add` resolves against a workspace sibling. | | [`tag`](#setting-tag) | `string` | Default dist-tag used by `aube add` without a version. | | [`globalDir`](#setting-globaldir) | `path` | Directory where globally installed packages live. | | [`globalBinDir`](#setting-globalbindir) | `path` | Directory where global binaries are symlinked. | @@ -2021,6 +2023,59 @@ Version prefix used when installing a package. Resolved from `.npmrc`. `--save-exact` overrides to empty prefix. +### `linkWorkspacePackages` {#setting-linkworkspacepackages} + +Resolve `aube add ` against local workspace siblings before falling back to the registry. + +- Type: `bool` +- Default: `false` +- Environment: `npm_config_link_workspace_packages`, `NPM_CONFIG_LINK_WORKSPACE_PACKAGES`, `AUBE_LINK_WORKSPACE_PACKAGES` +- .npmrc keys: `link-workspace-packages`, `linkWorkspacePackages` +- Workspace YAML keys: `linkWorkspacePackages` + +When `true`, `aube add ` checks the workspace for a package +whose `name` matches the spec before falling back to the registry. +A match wires the dep up as a workspace link; the manifest specifier +written to `package.json` is controlled by `saveWorkspaceProtocol`. + +If the user typed an explicit range (`aube add pkg@^1.2.0`), the +sibling's version must satisfy it — otherwise the spec falls through +to the registry path so an incompatible local copy isn't silently +linked. + +Off by default to match pnpm 8+ — opt in via `pnpm-workspace.yaml` +when you want every `aube add` to prefer the local copy of a sibling. +Aube's resolver already prefers workspace siblings on bare semver +ranges at install time, so this setting only controls `aube add`'s +manifest-write path; pnpm's `"deep"` value (which extends the +preference to transitive deps) is therefore not implemented and +treating the value as a plain bool is intentional. + +### `saveWorkspaceProtocol` {#setting-saveworkspaceprotocol} + +Spec form written to `package.json` when `aube add` resolves against a workspace sibling. + +- Type: `"true" | "false" | "rolling"` +- Default: `"rolling"` +- Environment: `npm_config_save_workspace_protocol`, `NPM_CONFIG_SAVE_WORKSPACE_PROTOCOL`, `AUBE_SAVE_WORKSPACE_PROTOCOL` +- .npmrc keys: `save-workspace-protocol`, `saveWorkspaceProtocol` +- Workspace YAML keys: `saveWorkspaceProtocol` + +- `"true"` writes a version-pinned workspace spec (`workspace:^1.0.0`, + honoring `savePrefix`). The exact lockfile entry never moves + without an explicit `aube update`. +- `"rolling"` (default) writes the rolling form `workspace:^` + (or `workspace:~` / `workspace:*` per `savePrefix`). Sibling + version bumps flow into dependents on the next install without + re-running `aube add`. +- `"false"` writes a plain registry-style spec (`^1.0.0`). The dep + is still linked locally on install (controlled by + `linkWorkspacePackages`), but the manifest looks identical to a + registry dep. + +The `--save-workspace-protocol` / `--no-save-workspace-protocol` CLI +flags on `aube add` override this setting per-invocation. + ### `tag` {#setting-tag} Default dist-tag used by `aube add` without a version. diff --git a/test/pnpm_monorepo_index.bats b/test/pnpm_monorepo_index.bats index 57886c34..5b0b31a4 100644 --- a/test/pnpm_monorepo_index.bats +++ b/test/pnpm_monorepo_index.bats @@ -215,3 +215,213 @@ _setup_no_match_workspace() { assert_success assert_output --partial "is-odd" } + +# Helper: stand up the four-project workspace pnpm uses for the +# link-workspace-packages tests. Mirrors `preparePackages([{name, version}, …])` +# from pnpm's test harness — a flat layout under the cwd where each +# project owns a `package.json` with `name` + `version`. +_link_workspace_packages_fixture() { + cat >package.json <<-'EOF' + {"name": "root", "version": "0.0.0", "private": true} + EOF + mkdir project-1 project-2 project-3 project-4 + cat >project-1/package.json <<-'EOF' + {"name": "project-1", "version": "1.0.0"} + EOF + cat >project-2/package.json <<-'EOF' + {"name": "project-2", "version": "2.0.0"} + EOF + cat >project-3/package.json <<-'EOF' + {"name": "project-3", "version": "3.0.0"} + EOF + cat >project-4/package.json <<-'EOF' + {"name": "project-4", "version": "4.0.0"} + EOF +} + +# Ported from pnpm/test/monorepo/index.ts:112 +# ('linking a package inside a monorepo with --link-workspace-packages +# when installing new dependencies'). Default `saveWorkspaceProtocol` +# is `rolling` in aube, matching what pnpm's test asserts: bare +# `aube add project-2` writes `workspace:^` (no version pin), and +# `--save-optional --no-save-workspace-protocol` opts the manifest +# back into a registry-style spec while the resolver still picks up +# the local sibling. +@test "aube add: --link-workspace-packages writes workspace:^ for siblings" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + EOF + + cd project-1 + run aube add project-2 + assert_success + run aube add project-3 --save-dev + assert_success + run aube add project-4 --save-optional --no-save-workspace-protocol + assert_success + + # Manifest assertions: rolling form for the default save and + # save-dev flows, registry-style for the explicit opt-out. + run grep -F '"project-2": "workspace:^"' package.json + assert_success + run grep -F '"project-3": "workspace:^"' package.json + assert_success + run grep -F '"project-4": "^4.0.0"' package.json + assert_success + + # Each sibling resolved through the local workspace — node_modules + # entries exist regardless of whether the spec form is workspace + # or registry style. + assert_link_exists node_modules/project-2 + assert_link_exists node_modules/project-3 + assert_link_exists node_modules/project-4 +} + +# Ported from pnpm/test/monorepo/index.ts:156 +# ('linking a package inside a monorepo with --link-workspace-packages +# when installing new dependencies and save-workspace-protocol is +# "rolling"'). Aube's default already matches `rolling`, so this test +# pins the explicit setting form — `saveWorkspaceProtocol: rolling` +# in the workspace yaml — and confirms the same outcomes as the +# default-only port above. +@test "aube add: --link-workspace-packages with saveWorkspaceProtocol: rolling" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + saveWorkspaceProtocol: rolling + EOF + + cd project-1 + run aube add project-2 + assert_success + run aube add project-3 --save-dev + assert_success + run aube add project-4 --save-optional --no-save-workspace-protocol + assert_success + + run grep -F '"project-2": "workspace:^"' package.json + assert_success + run grep -F '"project-3": "workspace:^"' package.json + assert_success + run grep -F '"project-4": "^4.0.0"' package.json + assert_success + + assert_link_exists node_modules/project-2 + assert_link_exists node_modules/project-3 + assert_link_exists node_modules/project-4 +} + +# Aube-side regression guard for `saveWorkspaceProtocol: true`: the +# pinned-version form (`workspace:^`) is the third valid +# manifest shape, and pnpm's docs document it as the historic default +# even though pnpm's tests have moved to assert the rolling form. The +# test stays in the aube suite (no pnpm equivalent) because the three +# saveWorkspaceProtocol variants share one code path and a regression +# in any one of them would silently slip through the rolling-only +# ports above. +@test "aube add: saveWorkspaceProtocol: true pins workspace:^" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + saveWorkspaceProtocol: true + EOF + + cd project-1 + run aube add project-2 + assert_success + + run grep -F '"project-2": "workspace:^2.0.0"' package.json + assert_success + assert_link_exists node_modules/project-2 +} + +# Regression guard for `aube add my-alias@project-2`: the +# `linkWorkspacePackages` eligibility block must skip aliased specs +# because `workspace:` resolves by manifest key, so writing +# `"my-alias": "workspace:^"` would point the resolver at a sibling +# named `my-alias` (which doesn't exist) and 404 on the registry +# fallback. With the skip in place the aliased spec falls through to +# the registry path — which we don't run end-to-end here (the +# offline registry doesn't host `project-2`), but the failure mode +# we want to prevent is the silent `workspace:^` write. +@test "aube add: aliased spec does NOT trigger linkWorkspacePackages workspace match" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + EOF + + cd project-1 + # Aliased to a name that doesn't match any sibling, but the + # real name (`project-2`) does. Pre-fix this would write + # `"my-alias": "workspace:^"`. Post-fix the spec falls + # through to the registry path and fails — the success + # criterion is that `package.json` does NOT carry a + # `workspace:` entry for `my-alias`. + run aube add my-alias@project-2 + # Either failure mode (registry 404) or success is acceptable; + # the regression guard is the manifest assertion below. + run grep -F '"my-alias": "workspace:' package.json + assert_failure +} + +# Regression guard for `aube add project-2@^1.0.0` when project-2 +# is at version 2.0.0 in the workspace: the user's explicit range +# rules out the local sibling, so the spec must fall through to the +# registry path rather than silently writing a `workspace:^` link +# that resolves to an incompatible version. The bats offline +# registry doesn't host project-2 so the registry path 404s — the +# success criterion is purely that the manifest does NOT carry a +# `workspace:` entry for project-2. +@test "aube add: explicit range mismatching sibling does NOT trigger workspace link" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + EOF + + cd project-1 + # project-2 is at 2.0.0 in the fixture; ^1.0.0 doesn't satisfy. + run aube add project-2@^1.0.0 + # Don't assert exit status — registry 404 is the expected + # fall-through. The regression guard is the manifest. + run grep -F '"project-2": "workspace:' package.json + assert_failure +} + +# Companion to the mismatch guard: `aube add project-2@^2.0.0` +# (which the sibling at 2.0.0 satisfies) MUST trigger the +# workspace match. This locks the satisfies-true branch so a +# regression can't silently skip every explicit-range add. +@test "aube add: explicit range satisfying sibling DOES trigger workspace link" { + _link_workspace_packages_fixture + cat >pnpm-workspace.yaml <<-'EOF' + packages: + - "**" + - "!store/**" + linkWorkspacePackages: true + EOF + + cd project-1 + run aube add project-2@^2.0.0 + assert_success + # Default rolling form, since the user-typed range is `^2.0.0` + # (caret) and the eligible sibling matches. + run grep -F '"project-2": "workspace:^"' package.json + assert_success + assert_link_exists node_modules/project-2 +}