diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 53b866da33..05391f2eb0 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -21,4 +21,4 @@ npm/private/test/vendored/lodash-4.17.21.tgz=-1206623349 npm/private/test/vendored/semver-max/package.json=578664053 package.json=564790136 pnpm-lock.yaml=322824775 -pnpm-workspace.yaml=1888889068 +pnpm-workspace.yaml=-1219461713 diff --git a/MODULE.bazel b/MODULE.bazel index 2bdbf6ef2a..d3f84069e9 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -51,7 +51,10 @@ use_repo( # The default + public version of pnpm used by default. pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm") -pnpm.pnpm(name = "pnpm") +pnpm.pnpm( + name = "pnpm", + include_npm = True, +) use_repo(pnpm, "pnpm", "pnpm__links") bazel_lib_toolchains = use_extension("@bazel_lib//lib:extensions.bzl", "toolchains") diff --git a/npm/BUILD.bazel b/npm/BUILD.bazel index e618f9fa51..2871abac88 100644 --- a/npm/BUILD.bazel +++ b/npm/BUILD.bazel @@ -16,6 +16,8 @@ bzl_library( "//npm/private:npm_package", "//npm/private:npm_package_info", "//npm/private:npm_package_store_info", + "//npm/private:pnpm_extract_catalogs", + "//npm/private:pnpm_package", ], ) diff --git a/npm/defs.bzl b/npm/defs.bzl index deb5eae7ad..6a0b6194cd 100644 --- a/npm/defs.bzl +++ b/npm/defs.bzl @@ -11,7 +11,16 @@ load( _npm_package = "npm_package", _stamped_package_json = "stamped_package_json", ) - +load( + "//npm/private:pnpm_extract_catalogs.bzl", + _pnpm_extract_catalogs = "pnpm_extract_catalogs", +) +load( + "//npm/private:pnpm_package.bzl", + _pnpm_package = "pnpm_package", +) npm_package = _npm_package npm_link_package = _npm_link_package +pnpm_extract_catalogs = _pnpm_extract_catalogs +pnpm_package = _pnpm_package stamped_package_json = _stamped_package_json diff --git a/npm/private/BUILD.bazel b/npm/private/BUILD.bazel index 999b141f7b..d6a7a2a42f 100644 --- a/npm/private/BUILD.bazel +++ b/npm/private/BUILD.bazel @@ -1,6 +1,6 @@ "Internal implementation details" -load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library") load("@bazel_skylib//:bzl_library.bzl", "bzl_library") package(default_visibility = ["//npm:__subpackages__"]) @@ -11,6 +11,33 @@ js_library( visibility = ["//visibility:public"], ) +js_binary( + name = "pnpm_package_json_transform_bin", + entry_point = "pnpm_package_json_transform.mjs", + visibility = ["//visibility:public"], +) + + +bzl_library( + name = "pnpm_extract_catalogs", + srcs = ["pnpm_extract_catalogs.bzl"], + deps = ["@yq.bzl//yq:yq"], +) + +bzl_library( + name = "pnpm_package", + srcs = ["pnpm_package.bzl"], + deps = [ + ":npm_package", + ":pnpm_package_json_transform", + ], +) + +bzl_library( + name = "pnpm_package_json_transform", + srcs = ["pnpm_package_json_transform.bzl"], +) + bzl_library( name = "npm_package", srcs = ["npm_package.bzl"], diff --git a/npm/private/npm_translate_lock_generate.bzl b/npm/private/npm_translate_lock_generate.bzl index f06374841b..64e29f663d 100644 --- a/npm/private/npm_translate_lock_generate.bzl +++ b/npm/private/npm_translate_lock_generate.bzl @@ -58,7 +58,13 @@ def generate_repository_files(attr, state, npm_imports): packages = state.packages() root_package = state.root_package() - final_rctx_files = {} + workspace_package_versions = state.workspace_package_versions() + pnpm_catalogs = state.pnpm_catalogs() + + final_rctx_files = { + "workspace_versions.json": json.encode(workspace_package_versions), + "catalogs.json": json.encode(pnpm_catalogs), + } rctx_files = { "BUILD.bazel": [ @@ -75,6 +81,8 @@ js_binary(name = "sync", entry_point = "noop.js") "", "exports_files({})".format(starlark_codegen_utils.to_list_attr([ _DEFS_BZL_FILENAME, + "workspace_versions.json", + "catalogs.json", ])), ], } diff --git a/npm/private/npm_translate_lock_state.bzl b/npm/private/npm_translate_lock_state.bzl index c1745f2d81..321bdf3b6c 100644 --- a/npm/private/npm_translate_lock_state.bzl +++ b/npm/private/npm_translate_lock_state.bzl @@ -152,6 +152,15 @@ def _init_workspace(priv, rctx, is_windows): priv["pnpm_settings"] = priv["pnpm_settings"] | pnpm_workspace_settings priv["only_built_dependencies"] = _only_built_dependencies(priv) + workspace_data = json.decode(pnpm_workspace_json) + catalogs = {} + default_catalog = workspace_data.get("catalog") + if default_catalog: + catalogs["default"] = default_catalog + named_catalogs = workspace_data.get("catalogs", {}) + catalogs.update(named_catalogs) + priv["pnpm_catalogs"] = catalogs + if workspace_parse_err != None: should_update = _should_update_pnpm_lock(priv) msg = """ @@ -227,7 +236,8 @@ ERROR: Implicitly using pnpm-workspace.yaml file `{pnpm_workspace}` since the `{ pnpm_lock_dir = str(rctx.path(pnpm_lock_label).dirname) if pnpm_lock_label else priv["src_root"] rel_dir = _rel_path(priv, pnpm_lock_dir) - # package.json files + # package.json files — read each workspace package's name and version + workspace_package_versions = {} for package_json in priv["importers"].keys(): rel_path = paths.normalize(paths.join(rel_dir, package_json, "package.json")) workspace_path = paths.join(priv["src_root"], rel_path) @@ -241,6 +251,16 @@ ERROR: Implicitly using pnpm-workspace.yaml file `{pnpm_workspace}` since the `{ fail(msg) _copy_input_file(priv, rctx, attr, workspace_path, str(rctx.path(rel_path))) + pkg_json_path = rctx.path(rel_path) + if pkg_json_path.exists: + pkg_json = json.decode(rctx.read(pkg_json_path)) + pkg_name = pkg_json.get("name") + pkg_version = pkg_json.get("version") + if pkg_name and pkg_version: + workspace_package_versions[pkg_name] = pkg_version + + priv["workspace_package_versions"] = workspace_package_versions + # Read patches from pnpm-lock.yaml `patchedDependencies` for name in priv["pnpm_patched_dependencies"]: patch_path = _patch_path_for(priv, name) @@ -517,6 +537,8 @@ def _new(rctx, mod, attr): "pnpm_settings": {}, "pnpm_patched_dependencies": {}, "should_update_pnpm_lock": should_update_pnpm_lock, + "workspace_package_versions": {}, + "pnpm_catalogs": {}, } _init(priv, rctx, attr) @@ -538,6 +560,8 @@ def _new(rctx, mod, attr): root_package = lambda: priv["root_package"], set_input_hash = lambda label, value: _set_input_hash(priv, label, value), action_cache_miss = lambda: _action_cache_miss(priv, rctx), + workspace_package_versions = lambda: priv["workspace_package_versions"], + pnpm_catalogs = lambda: priv["pnpm_catalogs"], write_action_cache = lambda: _write_action_cache(priv, rctx), ) diff --git a/npm/private/pnpm_extract_catalogs.bzl b/npm/private/pnpm_extract_catalogs.bzl new file mode 100644 index 0000000000..9474b4eafe --- /dev/null +++ b/npm/private/pnpm_extract_catalogs.bzl @@ -0,0 +1,29 @@ +"""Macro for extracting pnpm catalog definitions from pnpm-workspace.yaml. + +Uses yq to select the catalog/catalogs sections and emit a JSON file +mapping catalog names to {package: version} dicts. The unnamed catalog: +section is keyed as "default". +""" + +load("@yq.bzl//yq:yq.bzl", "yq") + +def pnpm_extract_catalogs( + name, + workspace_manifest = "//:pnpm-workspace.yaml", + **kwargs): + """Extracts catalog/catalogs sections from pnpm-workspace.yaml into a JSON file. + + Args: + name: Target name. Output will be catalogs.json. + workspace_manifest: The pnpm-workspace.yaml file to extract catalogs from. + Defaults to //:pnpm-workspace.yaml. + **kwargs: Additional arguments passed to the underlying yq rule. + """ + yq( + name = name, + srcs = [workspace_manifest], + expression = '{"default": .catalog} * (.catalogs // {}) | with_entries(select(.value != null))', + args = ["-o=json"], + outs = ["catalogs.json"], + **kwargs + ) diff --git a/npm/private/pnpm_git_tag_version.bzl b/npm/private/pnpm_git_tag_version.bzl new file mode 100644 index 0000000000..30ae826185 --- /dev/null +++ b/npm/private/pnpm_git_tag_version.bzl @@ -0,0 +1,63 @@ +"""Rule for extracting package versions from git tags. + +Example usage: + +```starlark +load("@aspect_rules_js//npm:defs.bzl", "pnpm_package") +load("@aspect_rules_js//npm/private:pnpm_git_tag_version.bzl", "pnpm_git_tag_version") + +pnpm_git_tag_version( + name = "version", + prefix = "@internal/my-package", + fallback = "0.0.0-development", +) + +pnpm_package( + name = "pkg", + srcs = [":lib"], + version = ":version", +) +``` + +Given git tags like `@internal/my-package@1.2.3`, this extracts `1.2.3` as the version. +The latest matching tag (by semver sort) is used. +""" + +def _pnpm_git_tag_version_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".version") + + ctx.actions.run_shell( + outputs = [output], + command = """\ +prefix="$1" +fallback="$2" +output="$3" +version=$(git tag -l "${prefix}@*" --sort=-v:refname | head -1 | sed 's/.*@//') +if [ -z "$version" ]; then + version="${fallback}" +fi +printf '%s' "$version" > "${output}" +""", + arguments = [ctx.attr.prefix, ctx.attr.fallback, output.path], + execution_requirements = { + "local": "1", + "no-sandbox": "1", + }, + ) + + return [DefaultInfo(files = depset([output]))] + +pnpm_git_tag_version = rule( + implementation = _pnpm_git_tag_version_impl, + attrs = { + "prefix": attr.string( + mandatory = True, + doc = "Git tag prefix to match, e.g. '@internal/my-package'. Tags should be in the format '@'.", + ), + "fallback": attr.string( + default = "0.0.0-development", + doc = "Version to use when no matching git tag is found", + ), + }, + doc = "Extracts a package version from git tags matching the given prefix.", +) diff --git a/npm/private/pnpm_package.bzl b/npm/private/pnpm_package.bzl new file mode 100644 index 0000000000..7628995412 --- /dev/null +++ b/npm/private/pnpm_package.bzl @@ -0,0 +1,227 @@ +"""Rule for building npm packages in pnpm workspaces. + +Wraps the upstream npm_package rule, adding a build-time transform that resolves +pnpm workspace protocols (catalog:, workspace:, etc.) in package.json, mirroring +what pnpm publish does before packing. + +Load with: + +```starlark +load("@aspect_rules_js//npm:defs.bzl", "pnpm_package") +``` +""" + +load(":npm_package.bzl", _npm_package = "npm_package") +load(":pnpm_package_json_transform.bzl", _pnpm_package_json_transform = "pnpm_package_json_transform") + +def _runfiles_path(ctx, f): + p = f.short_path + if p.startswith("../"): + return p[3:] + return ctx.workspace_name + "/" + p + +def _pnpm_publish_impl(ctx): + launcher = ctx.actions.declare_file(ctx.label.name + ".sh") + + pnpm_bin = ctx.executable._pnpm + pkg_dir = ctx.attr.pkg[DefaultInfo].files.to_list()[0] + + pnpm_path = _runfiles_path(ctx, pnpm_bin) + pkg_path = _runfiles_path(ctx, pkg_dir) + + ctx.actions.write( + output = launcher, + content = """\ +#!/usr/bin/env bash +set -euo pipefail + +# Resolve the runfiles directory +if [[ -d "$0.runfiles" ]]; then + RUNFILES="$0.runfiles" +elif [[ "${{RUNFILES_DIR:-}}" ]]; then + RUNFILES="${{RUNFILES_DIR}}" +else + echo >&2 "ERROR: Cannot find runfiles directory" + exit 1 +fi + +exec "${{RUNFILES}}/{pnpm}" publish --no-git-checks "${{RUNFILES}}/{pkg}" {extra_args} "$@" +""".format( + pnpm = pnpm_path, + pkg = pkg_path, + extra_args = " ".join(["'%s'" % a for a in ctx.attr.extra_args]), + ), + is_executable = True, + ) + + runfiles = ctx.runfiles(files = [launcher, pkg_dir]) + runfiles = runfiles.merge(ctx.attr._pnpm[DefaultInfo].default_runfiles) + runfiles = runfiles.merge(ctx.attr.pkg[DefaultInfo].default_runfiles) + + return [DefaultInfo( + executable = launcher, + runfiles = runfiles, + )] + +_pnpm_publish = rule( + implementation = _pnpm_publish_impl, + executable = True, + attrs = { + "pkg": attr.label( + mandatory = True, + doc = "The npm_package target to publish", + ), + "extra_args": attr.string_list( + doc = "Additional arguments passed to pnpm publish", + ), + "_pnpm": attr.label( + executable = True, + cfg = "target", + default = "@pnpm//:pnpm", + ), + }, +) + +def _is_label(value): + """Returns True if value looks like a Bazel label rather than a plain string.""" + return value.startswith(":") or value.startswith("//") or value.startswith("@") + +def _to_transform_label(dep): + """Convert a pnpm_package label to its internal transform target label.""" + if ":" in dep: + return dep + "_package_json" + last = dep.split("/")[-1] + return dep + ":" + last + "_package_json" + +def pnpm_package( + name, + srcs, + pnpm_catalogs = "@npm//:catalogs.json", + package_json = "package.json", + pnpm_workspace_versions = "@npm//:workspace_versions.json", + workspace_deps = [], + version = None, + stamped = False, + publishable = False, + tag = "latest", + **kwargs): + """Creates an npm package with pnpm-compatible package.json transforms. + + Wraps the upstream rules_js npm_package rule, adding a build-time transform + that resolves pnpm workspace protocols (catalog:, workspace:, etc.) in + package.json, mirroring what pnpm publish does before packing. + + Both catalog and workspace protocol resolution use JSON files generated + automatically by npm_translate_lock: + - `@npm//:catalogs.json` — catalog definitions from pnpm-workspace.yaml + - `@npm//:workspace_versions.json` — workspace package name-to-version mapping + + With the defaults, no explicit configuration is needed: + + ```starlark + pnpm_package( + name = "my_package", + srcs = [":my_lib"], + ) + ``` + + When workspace packages derive their version at build time (e.g. from git + tags) rather than from the source package.json, use `workspace_deps` to + reference those packages. Their resolved versions override the base + workspace_versions.json entries: + + ```starlark + pnpm_package( + name = "my_consumer", + srcs = [":my_lib"], + workspace_deps = [ + "//packages/dep-a", + "//packages/dep-b", + ], + ) + ``` + + The version can be a string literal or a label to a file containing the version: + + ```starlark + pnpm_package( + name = "my_package", + srcs = [":my_lib"], + version = ":version_from_git_tag", + ) + ``` + + Args: + name: Target name + srcs: Source files to include in the package (excluding package.json) + pnpm_catalogs: Label of the catalogs JSON file. Defaults to @npm//:catalogs.json + generated by npm_translate_lock. + package_json: The package.json file to transform (default: "package.json") + pnpm_workspace_versions: Label of the workspace_versions.json generated by + npm_translate_lock. Used as the base for resolving workspace: protocols. + workspace_deps: Labels of other pnpm_package targets in the workspace. Their + resolved versions override entries in workspace_versions.json, ensuring + consumers get the correct build-time version even when source package.json + files use placeholder versions like "0.0.0-development". + version: Override the version in the output package.json. Either a version string + (e.g. "1.2.3") or a label to a file containing the version (e.g. ":version"). + stamped: When True, appends BUILD_TIMESTAMP-SHORT_GIT_COMMIT to the version + publishable: When True, also creates a {name}.publish target + tag: The dist-tag to use when publishing (default: "latest") + **kwargs: Additional arguments passed to the upstream npm_package rule + """ + transform_name = name + "_package_json" + + version_string = None + version_file = None + if version and _is_label(version): + version_file = version + elif version: + version_string = version + + transform_kwargs = {} + if version_string: + transform_kwargs["version"] = version_string + if version_file: + transform_kwargs["version_file"] = version_file + if stamped: + transform_kwargs["stamped"] = True + + if pnpm_workspace_versions: + transform_kwargs["pnpm_workspace_versions"] = pnpm_workspace_versions + + if workspace_deps: + transform_kwargs["workspace_deps"] = [_to_transform_label(dep) for dep in workspace_deps] + + _pnpm_package_json_transform( + name = transform_name, + package_json = package_json, + pnpm_catalogs = pnpm_catalogs, + srcs = srcs, + tags = kwargs.get("tags", []) + ["manual"], + **transform_kwargs + ) + + if publishable: + _pnpm_publish( + name = "{}.publish".format(name), + pkg = name, + extra_args = ["--tag", tag], + tags = kwargs.get("tags", []) + ["manual"], + testonly = kwargs.get("testonly", False), + visibility = kwargs.get("visibility", None), + ) + + replace_prefixes = dict(kwargs.pop("replace_prefixes", {})) + replace_prefixes[transform_name + "/"] = "" + + npm_package_kwargs = dict(kwargs) + if version_string: + npm_package_kwargs["version"] = version_string + + _npm_package( + name = name, + srcs = srcs + [":" + transform_name], + replace_prefixes = replace_prefixes, + **npm_package_kwargs + ) diff --git a/npm/private/pnpm_package_json_transform.bzl b/npm/private/pnpm_package_json_transform.bzl new file mode 100644 index 0000000000..d13f2fc68b --- /dev/null +++ b/npm/private/pnpm_package_json_transform.bzl @@ -0,0 +1,148 @@ +"""Private rule for transforming package.json to resolve pnpm workspace protocols.""" + +PnpmPackageInfo = provider( + doc = "Version information for a pnpm workspace package, provided by pnpm_package_json_transform", + fields = { + "package_json": "The transformed package.json File containing the resolved name and version", + }, +) + +_TransitivePnpmPackageInfos = provider( + doc = "Collects PnpmPackageInfo from the transitive dependency graph", + fields = { + "package_jsons": "depset of transformed package.json Files from workspace deps", + }, +) + +def _collect_workspace_versions_aspect_impl(target, ctx): + direct = [] + if PnpmPackageInfo in target: + direct.append(target[PnpmPackageInfo].package_json) + + transitive = [] + for attr_name in ["srcs", "deps", "src"]: + deps = getattr(ctx.rule.attr, attr_name, None) + if deps == None: + continue + # label_keyed_string_dict attrs produce a dict, label_list attrs produce a list + targets = deps.keys() if type(deps) == "dict" else deps + for dep in targets: + if _TransitivePnpmPackageInfos in dep: + transitive.append(dep[_TransitivePnpmPackageInfos].package_jsons) + + return [_TransitivePnpmPackageInfos( + package_jsons = depset(direct, transitive = transitive), + )] + +_collect_workspace_versions_aspect = aspect( + implementation = _collect_workspace_versions_aspect_impl, + attr_aspects = ["srcs", "deps", "src"], +) + +def _pnpm_package_json_transform_impl(ctx): + if ctx.attr.version and ctx.file.version_file: + fail("Only one of version or version_file may be specified") + + output = ctx.actions.declare_file(ctx.label.name + "/package.json") + + args = ctx.actions.args() + args.add("--catalogs-json", ctx.file.pnpm_catalogs) + args.add("--package-json", ctx.file.package_json) + args.add("--output", output) + + version = ctx.attr.version + if version and ctx.attr.stamped: + version = "{}-{}-{}".format( + version, + ctx.var["BUILD_TIMESTAMP"], + ctx.var["SHORT_GIT_COMMIT"], + ) + + if version: + args.add("--version", version) + elif ctx.file.version_file: + args.add("--version-file", ctx.file.version_file) + + if ctx.file.pnpm_workspace_versions: + args.add("--workspace-versions-json", ctx.file.pnpm_workspace_versions) + + inputs = [ctx.file.pnpm_catalogs, ctx.file.package_json] + if ctx.file.version_file: + inputs.append(ctx.file.version_file) + if ctx.file.pnpm_workspace_versions: + inputs.append(ctx.file.pnpm_workspace_versions) + + # Collect workspace dep versions from explicit workspace_deps and aspect discovery + seen = {} + for dep in ctx.attr.workspace_deps: + dep_pj = dep[PnpmPackageInfo].package_json + seen[dep_pj.path] = True + args.add("--workspace-dep-package-json", dep_pj) + inputs.append(dep_pj) + + for src in ctx.attr.srcs: + if _TransitivePnpmPackageInfos in src: + for pj in src[_TransitivePnpmPackageInfos].package_jsons.to_list(): + if pj.path not in seen: + seen[pj.path] = True + args.add("--workspace-dep-package-json", pj) + inputs.append(pj) + + ctx.actions.run( + executable = ctx.executable._transform_tool, + arguments = [args], + inputs = inputs, + outputs = [output], + env = {"BAZEL_BINDIR": ctx.bin_dir.path}, + ) + + return [ + DefaultInfo(files = depset([output])), + PnpmPackageInfo(package_json = output), + ] + +pnpm_package_json_transform = rule( + implementation = _pnpm_package_json_transform_impl, + attrs = { + "package_json": attr.label( + allow_single_file = True, + mandatory = True, + doc = "The package.json file to transform", + ), + "pnpm_catalogs": attr.label( + allow_single_file = True, + mandatory = True, + doc = "The catalogs JSON file, generated by npm_translate_lock or pnpm_extract_catalogs", + ), + "pnpm_workspace_versions": attr.label( + allow_single_file = True, + doc = "JSON file mapping workspace package names to versions, generated by npm_translate_lock", + ), + "workspace_deps": attr.label_list( + providers = [PnpmPackageInfo], + doc = "Explicit workspace dep targets whose versions override workspace_versions.json", + ), + "srcs": attr.label_list( + allow_files = True, + aspects = [_collect_workspace_versions_aspect], + doc = "Sources to scan for transitive PnpmPackageInfo providers via aspect", + ), + "stamped": attr.bool( + default = False, + doc = "When True, appends BUILD_TIMESTAMP-SHORT_GIT_COMMIT to the version", + ), + "version": attr.string( + doc = "Override the version in the output package.json", + ), + "version_file": attr.label( + allow_single_file = True, + doc = "A file containing the version string. Alternative to the version string attribute.", + ), + "_transform_tool": attr.label( + executable = True, + cfg = "exec", + default = "@aspect_rules_js//npm/private:pnpm_package_json_transform_bin", + ), + }, + doc = "Transforms package.json to resolve pnpm workspace protocols (catalog:, workspace:, etc.)", +) diff --git a/npm/private/pnpm_package_json_transform.mjs b/npm/private/pnpm_package_json_transform.mjs new file mode 100644 index 0000000000..4855d32e22 --- /dev/null +++ b/npm/private/pnpm_package_json_transform.mjs @@ -0,0 +1,145 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve, isAbsolute } from 'node:path' +import { parseArgs } from 'node:util' + +function resolvePath(p) { + if (isAbsolute(p)) return p + const execroot = process.env.JS_BINARY__EXECROOT + if (execroot) return resolve(execroot, p) + return resolve(p) +} + +const { values: argv } = parseArgs({ + options: { + 'catalogs-json': { type: 'string' }, + 'package-json': { type: 'string' }, + 'output': { type: 'string' }, + 'version': { type: 'string' }, + 'version-file': { type: 'string' }, + 'workspace-versions-json': { type: 'string' }, + 'workspace-dep-package-json': { type: 'string', multiple: true }, + }, + strict: true, +}) + +if (!argv['catalogs-json'] || !argv['package-json'] || !argv['output']) { + process.stderr.write( + 'usage: pnpm_package_json_transform --catalogs-json --package-json --output [--version ] [--version-file ] [--workspace-versions-json ]\n' + ) + process.exit(1) +} + +const catalogs = JSON.parse(readFileSync(resolvePath(argv['catalogs-json']), 'utf8')) +const manifest = JSON.parse(readFileSync(resolvePath(argv['package-json']), 'utf8')) + +const workspaceVersions = argv['workspace-versions-json'] + ? JSON.parse(readFileSync(resolvePath(argv['workspace-versions-json']), 'utf8')) + : {} + +for (const depPath of argv['workspace-dep-package-json'] ?? []) { + const dep = JSON.parse(readFileSync(resolvePath(depPath), 'utf8')) + if (dep.name && dep.version) { + workspaceVersions[dep.name] = dep.version + } +} + +const version = argv.version ?? (argv['version-file'] ? readFileSync(resolvePath(argv['version-file']), 'utf8').trim() : null) +if (version) manifest.version = version + +// --- Dependency protocol resolution --- + +function resolveVersion(pkgName, spec) { + if (spec.startsWith('catalog:')) { + const catalogName = spec.slice('catalog:'.length) || 'default' + const catalog = catalogs[catalogName] + if (!catalog) throw new Error(`Unknown catalog '${catalogName}' referenced by '${pkgName}'`) + const resolved = catalog[pkgName] + if (!resolved) throw new Error(`Package '${pkgName}' not found in catalog '${catalogName}'`) + return resolved + } + if (spec.startsWith('workspace:')) { + const rest = spec.slice('workspace:'.length) + if (rest === '*' || rest === '^' || rest === '~') { + const resolved = workspaceVersions[pkgName] + if (!resolved) throw new Error(`Workspace package '${pkgName}' not found in workspace_versions.json. Ensure the package has a name and version in its package.json.`) + if (rest === '*') return resolved + return rest + resolved + } + return rest + } + return spec +} + +for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { + const deps = manifest[field] + if (deps && typeof deps === 'object') { + const sorted = {} + for (const key of Object.keys(deps).sort()) { + sorted[key] = resolveVersion(key, deps[key]) + } + manifest[field] = sorted + } +} + +// --- Strip devDependencies --- + +delete manifest.devDependencies + +// --- Strip publish lifecycle scripts --- + +const PUBLISH_LIFECYCLE_SCRIPTS = new Set([ + 'prepublishOnly', 'prepack', 'prepare', 'postpack', + 'publish', 'postpublish', +]) + +if (manifest.scripts) { + for (const name of PUBLISH_LIFECYCLE_SCRIPTS) { + delete manifest.scripts[name] + } + if (Object.keys(manifest.scripts).length === 0) { + delete manifest.scripts + } +} + +// --- Strip pnpm-specific fields --- + +delete manifest.pnpm +delete manifest.packageManager + +// --- publishConfig promotion --- + +const PUBLISH_CONFIG_PROMOTABLE = new Set([ + 'main', 'module', 'types', 'typings', 'exports', 'browser', + 'esnext', 'es2015', 'unpkg', 'umd:main', + 'bin', 'engines', 'type', 'os', 'cpu', 'libc', + 'typesVersions', 'imports', +]) + +if (manifest.publishConfig && typeof manifest.publishConfig === 'object') { + for (const field of PUBLISH_CONFIG_PROMOTABLE) { + if (field in manifest.publishConfig) { + manifest[field] = manifest.publishConfig[field] + delete manifest.publishConfig[field] + } + } + if (Object.keys(manifest.publishConfig).length === 0) { + delete manifest.publishConfig + } +} + +// --- Normalize bin (string → object) --- + +if (typeof manifest.bin === 'string') { + let binName = manifest.name || '' + const slashIdx = binName.indexOf('/') + if (slashIdx !== -1) binName = binName.slice(slashIdx + 1) + manifest.bin = { [binName]: manifest.bin } +} + +// --- Normalize repository (string → object) --- + +if (typeof manifest.repository === 'string') { + manifest.repository = { type: 'git', url: manifest.repository } +} + +writeFileSync(resolvePath(argv.output), JSON.stringify(manifest, null, 2) + '\n') diff --git a/npm/private/test/pnpm_package/BUILD.bazel b/npm/private/test/pnpm_package/BUILD.bazel new file mode 100644 index 0000000000..2b4f1f121e --- /dev/null +++ b/npm/private/test/pnpm_package/BUILD.bazel @@ -0,0 +1,174 @@ +load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory") +load("@bazel_lib//lib:diff_test.bzl", "diff_test") +load("@bazel_skylib//rules:write_file.bzl", "write_file") +load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//npm:defs.bzl", "pnpm_extract_catalogs", "pnpm_package") + +# Extract catalogs from the test pnpm-workspace.yaml +pnpm_extract_catalogs( + name = "pnpm_catalogs", + workspace_manifest = "pnpm-workspace.yaml", +) + +# Test 1: Verify extracted catalogs match expected output +diff_test( + name = "test_extract_catalogs", + file1 = ":pnpm_catalogs", + file2 = "expected_catalogs.json", +) + +# Test 2: Basic pnpm_package with catalog and workspace protocol resolution +pnpm_package( + name = "pkg", + srcs = ["index.js"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "workspace_versions.json", +) + +copy_to_directory( + name = "expected_pkg", + srcs = [ + "expected_package.json", + "index.js", + ], + replace_prefixes = { + "expected_package.json": "package.json", + }, +) + +diff_test( + name = "test_pkg", + file1 = ":pkg", + file2 = ":expected_pkg", +) + +# Test 3: pnpm_package with version override +pnpm_package( + name = "pkg_versioned", + srcs = ["index.js"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "workspace_versions.json", + version = "2.0.0-rc.1", +) + +copy_to_directory( + name = "expected_pkg_versioned", + srcs = [ + "expected_package_versioned.json", + "index.js", + ], + replace_prefixes = { + "expected_package_versioned.json": "package.json", + }, +) + +diff_test( + name = "test_pkg_versioned", + file1 = ":pkg_versioned", + file2 = ":expected_pkg_versioned", +) + +# Test 4: pnpm_package with version_file (simulates git tag version) +write_file( + name = "version_from_tag", + out = "version_from_tag.txt", + content = ["2.0.0-rc.1"], +) + +pnpm_package( + name = "pkg_version_file", + srcs = ["index.js"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "workspace_versions.json", + version = ":version_from_tag", +) + +diff_test( + name = "test_pkg_version_file", + file1 = ":pkg_version_file", + file2 = ":expected_pkg_versioned", +) + +# Test 5: workspace_deps overrides base workspace_versions.json +# Simulates: @test/sibling's source package.json has "0.0.0-development" +# but its pnpm_package has version = "3.2.1" (e.g. from a git tag). +# The consumer should resolve "workspace:^" to "^3.2.1", not "^0.0.0-development". +pnpm_package( + name = "sibling_pkg", + srcs = ["index.js"], + package_json = "sibling_package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "workspace_versions.json", + version = "3.2.1", +) + +pnpm_package( + name = "pkg_with_workspace_deps", + srcs = ["index.js"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "base_workspace_versions.json", + workspace_deps = [":sibling_pkg"], +) + +diff_test( + name = "test_workspace_deps", + file1 = ":pkg_with_workspace_deps", + file2 = ":expected_pkg", +) + +# Test 6: aspect auto-discovers workspace dep versions via the transform's srcs. +# The pnpm_package macro passes srcs to the transform rule with the aspect attached. +# The aspect collects PnpmPackageInfo from targets in srcs without explicit workspace_deps. +pnpm_package( + name = "pkg_auto_discover", + srcs = ["index.js", ":sibling_pkg"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "base_workspace_versions.json", +) + +# Verify the aspect-resolved package.json has the correct version (^3.2.1, not ^0.0.0-development). +# The sibling_pkg files end up in the output alongside index.js; the expected_pkg_auto_discover +# directory mirrors that structure. +copy_to_directory( + name = "expected_pkg_auto_discover", + srcs = [ + "expected_package.json", + "index.js", + ":sibling_pkg", + ], + replace_prefixes = { + "expected_package.json": "package.json", + }, +) + +diff_test( + name = "test_aspect_auto_discover", + file1 = ":pkg_auto_discover", + file2 = ":expected_pkg_auto_discover", +) + +# Test 7: pnpm_package with publishable=True invokes pnpm from the toolchain +pnpm_package( + name = "pkg_publishable", + srcs = ["index.js"], + package_json = "package.json", + pnpm_catalogs = "expected_catalogs.json", + pnpm_workspace_versions = "workspace_versions.json", + publishable = True, +) + +sh_test( + name = "test_publish", + srcs = ["test_publish.sh"], + args = ["$(locations :pkg_publishable.publish)"], + data = [":pkg_publishable.publish"], + tags = [ + "no-remote-exec", + "no-sandbox", + ], +) diff --git a/npm/private/test/pnpm_package/base_workspace_versions.json b/npm/private/test/pnpm_package/base_workspace_versions.json new file mode 100644 index 0000000000..8d4d20faf4 --- /dev/null +++ b/npm/private/test/pnpm_package/base_workspace_versions.json @@ -0,0 +1 @@ +{"@test/sibling":"0.0.0-development"} \ No newline at end of file diff --git a/npm/private/test/pnpm_package/expected_catalogs.json b/npm/private/test/pnpm_package/expected_catalogs.json new file mode 100644 index 0000000000..729023076c --- /dev/null +++ b/npm/private/test/pnpm_package/expected_catalogs.json @@ -0,0 +1,9 @@ +{ + "default": { + "react": "^18.2.0", + "lodash": "^4.17.21" + }, + "utils": { + "underscore": "1.13.6" + } +} diff --git a/npm/private/test/pnpm_package/expected_package.json b/npm/private/test/pnpm_package/expected_package.json new file mode 100644 index 0000000000..a1aa770057 --- /dev/null +++ b/npm/private/test/pnpm_package/expected_package.json @@ -0,0 +1,28 @@ +{ + "name": "@test/pnpm-pkg", + "version": "1.0.0", + "main": "./dist/index.js", + "bin": { + "pnpm-pkg": "./bin/cli.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/test/pnpm-pkg.git" + }, + "scripts": { + "build": "tsc", + "test": "jest" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@test/sibling": "^3.2.1", + "react": "^18.2.0", + "underscore": "1.13.6" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + } +} diff --git a/npm/private/test/pnpm_package/expected_package_versioned.json b/npm/private/test/pnpm_package/expected_package_versioned.json new file mode 100644 index 0000000000..6c57cc4509 --- /dev/null +++ b/npm/private/test/pnpm_package/expected_package_versioned.json @@ -0,0 +1,28 @@ +{ + "name": "@test/pnpm-pkg", + "version": "2.0.0-rc.1", + "main": "./dist/index.js", + "bin": { + "pnpm-pkg": "./bin/cli.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/test/pnpm-pkg.git" + }, + "scripts": { + "build": "tsc", + "test": "jest" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@test/sibling": "^3.2.1", + "react": "^18.2.0", + "underscore": "1.13.6" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + } +} diff --git a/npm/private/test/pnpm_package/index.js b/npm/private/test/pnpm_package/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/npm/private/test/pnpm_package/package.json b/npm/private/test/pnpm_package/package.json new file mode 100644 index 0000000000..00958e8b08 --- /dev/null +++ b/npm/private/test/pnpm_package/package.json @@ -0,0 +1,36 @@ +{ + "name": "@test/pnpm-pkg", + "version": "1.0.0", + "main": "./src/index.js", + "bin": "./bin/cli.js", + "repository": "https://github.com/test/pnpm-pkg.git", + "scripts": { + "build": "tsc", + "test": "jest", + "prepublishOnly": "npm run build", + "prepare": "npm run build", + "postpublish": "echo done" + }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "access": "public" + }, + "dependencies": { + "@test/sibling": "workspace:^", + "react": "catalog:", + "underscore": "catalog:utils" + }, + "devDependencies": { + "lodash": "catalog:" + }, + "pnpm": { + "overrides": { + "foo": "1.0.0" + } + }, + "packageManager": "pnpm@9.0.0" +} diff --git a/npm/private/test/pnpm_package/pnpm-workspace.yaml b/npm/private/test/pnpm_package/pnpm-workspace.yaml new file mode 100644 index 0000000000..e9bc6ed971 --- /dev/null +++ b/npm/private/test/pnpm_package/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - 'packages/*' + +catalog: + react: ^18.2.0 + lodash: ^4.17.21 + +catalogs: + utils: + underscore: 1.13.6 diff --git a/npm/private/test/pnpm_package/sibling_package.json b/npm/private/test/pnpm_package/sibling_package.json new file mode 100644 index 0000000000..155935d334 --- /dev/null +++ b/npm/private/test/pnpm_package/sibling_package.json @@ -0,0 +1,5 @@ +{ + "name": "@test/sibling", + "version": "0.0.0-development", + "main": "./index.js" +} diff --git a/npm/private/test/pnpm_package/test_publish.sh b/npm/private/test/pnpm_package/test_publish.sh new file mode 100755 index 0000000000..e58534d368 --- /dev/null +++ b/npm/private/test/pnpm_package/test_publish.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly PUBLISH="$1" + +# pnpm publish should pick up the transformed package.json and report the package name. +# It will fail with an auth error since we're not actually publishing, but the output +# should contain the package name proving the package.json was correctly resolved. +$PUBLISH >pub.log 2>&1 || true + +if grep -q '@test/pnpm-pkg' pub.log; then + echo "PASS: pnpm publish found @test/pnpm-pkg in output" + exit 0 +fi + +echo "FAIL: expected pnpm publish output to reference '@test/pnpm-pkg', GOT:" +cat pub.log +exit 1 diff --git a/npm/private/test/pnpm_package/workspace_versions.json b/npm/private/test/pnpm_package/workspace_versions.json new file mode 100644 index 0000000000..6166556989 --- /dev/null +++ b/npm/private/test/pnpm_package/workspace_versions.json @@ -0,0 +1 @@ +{"@test/sibling":"3.2.1"} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a80623c6b5..26bccef0d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - 'npm/private/lifecycle' - 'npm/private/test' - 'npm/private/test/*' + - '!npm/private/test/pnpm_package' # NOTE: ensure npm/private/test/subs/* is NOT included onlyBuiltDependencies: