rolldown-pnpm-config 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,6 +113,20 @@ publicHoistPattern: {
113
113
 
114
114
  See [exporting to pnpm-workspace.yaml](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/06-exporting.md) for the full surface.
115
115
 
116
+ ## Distributing dependency patches
117
+
118
+ A plugin author can ship pnpm dependency patches through the plugin. Author a `.patch` with stock pnpm into `public/patches/` — the bundler copies `public/` into the published package, so the file travels with the plugin. At build the plugin discovers each patch, rewrites its path to the consumer-resolved `node_modules/.pnpm-config/<name>/patches/<file>.patch` and bakes it into the emitted pnpmfile. Every consumer then applies it automatically through `updateConfig`, with no hand-written `patchedDependencies` entry:
119
+
120
+ ```ts
121
+ export const plugin = {
122
+ name: "@acme/pnpm-config",
123
+ // default once public/patches/ has files; a bare map is the manual escape hatch
124
+ patchedDependencies: { strategy: "rewrite" },
125
+ } satisfies PluginConfig;
126
+ ```
127
+
128
+ A sibling `patches/` folder stays local to your repo and never ships. On `rolldown-pnpm-config export` your own `pnpm-workspace.yaml` gets the patches with their local on-disk paths, merged by key so sibling and hand-written entries survive. See [distributing dependency patches](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/07-distributing-patches.md) for the full surface.
129
+
116
130
  ## Documentation
117
131
 
118
132
  - [Getting started](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/01-getting-started.md) — Wire the plugin into a vanilla rolldown build and emit a pnpmfile.
@@ -121,6 +135,7 @@ See [exporting to pnpm-workspace.yaml](https://github.com/spencerbeggs/rolldown-
121
135
  - [pnpm settings coverage](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/04-pnpm-settings-coverage.md) — Every pnpm-workspace.yaml setting the plugin manages and the ones it leaves to each consumer.
122
136
  - [Upgrading catalogs](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/05-upgrading-catalogs.md) — The `upgrade` CLI that rewrites catalog version ranges in place.
123
137
  - [Exporting to pnpm-workspace.yaml](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/06-exporting.md) — The `export` and `preview` CLI, the `local` merge directive and per-repo `excludeByRepo` filtering.
138
+ - [Distributing dependency patches](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/07-distributing-patches.md) — Ship pnpm patches through the plugin so every consumer applies them automatically.
124
139
 
125
140
  ## License
126
141
 
@@ -14,7 +14,7 @@ const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
14
14
  ]));
15
15
  Command.run(root, {
16
16
  name: "rolldown-pnpm-config",
17
- version: "0.1.0"
17
+ version: "0.2.1"
18
18
  })(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
19
19
 
20
20
  //#endregion
@@ -1,6 +1,9 @@
1
+ import { discoverPatches } from "../../patches/discover.js";
2
+ import { isRewriteDirective, readLocalPatchesDir, withResolvedBuildPatches } from "../../patches/build.js";
1
3
  import { DESCRIPTORS } from "../../descriptors/index.js";
2
4
  import { freeze } from "../../plugin/freeze.js";
3
5
  import { resolveRootName } from "../../runtime/ctx.js";
6
+ import { reconcilePatches } from "../../patches/reconcile.js";
4
7
  import { buildDiff } from "../diff/build.js";
5
8
  import { renderExportDiff } from "../diff/render.js";
6
9
  import { effectiveManaged } from "../effective.js";
@@ -12,7 +15,7 @@ import { canonicalize, findWorkspaceFile, parseWorkspace, renderWorkspace } from
12
15
  import { overlayWorkspace } from "../workspace-overlay.js";
13
16
  import { Data, Effect, Option } from "effect";
14
17
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
18
+ import { dirname, join, relative } from "node:path";
16
19
  import { Args, Command, Options } from "@effect/cli";
17
20
 
18
21
  //#region src/cli/commands/export.ts
@@ -45,7 +48,7 @@ function runExport(opts) {
45
48
  }), opts.configFile);
46
49
  if (config === null) return yield* Effect.fail(new ExportError({ message: `No PnpmConfigPlugin call found in ${opts.configFile}` }));
47
50
  if (errors.length > 0) return yield* Effect.fail(new ExportError({ message: `Non-literal config values: ${errors.join("; ")}` }));
48
- const { base, manifest } = yield* freeze(config).pipe(Effect.mapError((e) => new ExportError({ message: e.message })));
51
+ const { base, manifest } = yield* freeze(withResolvedBuildPatches(config, dirname(opts.configFile))).pipe(Effect.mapError((e) => new ExportError({ message: e.message })));
49
52
  const managed = {};
50
53
  for (const [k, v] of Object.entries(base)) if (WORKSPACE_FIELDS.has(k)) managed[k] = v;
51
54
  const path = opts.workspacePath ?? findWorkspaceFile(process.cwd()) ?? join(process.cwd(), "pnpm-workspace.yaml");
@@ -54,7 +57,33 @@ function runExport(opts) {
54
57
  catch: (e) => new ExportError({ message: `Cannot read or parse ${path}: ${String(e)}` })
55
58
  }) : {};
56
59
  const rootName = resolveRootName({ dir: dirname(path) });
57
- const merged = overlayWorkspace(effectiveManaged(managed, config.local && typeof config.local === "object" ? config.local : void 0, parsed, manifest, rootName), parsed);
60
+ const effective = effectiveManaged(managed, config.local && typeof config.local === "object" ? config.local : void 0, parsed, manifest, rootName);
61
+ const rawPatched = config.patchedDependencies;
62
+ const explicitPatchMap = rawPatched !== void 0 && !isRewriteDirective(rawPatched);
63
+ const contributed = {};
64
+ if (explicitPatchMap) {
65
+ const explicit = effective.patchedDependencies;
66
+ if (explicit !== null && typeof explicit === "object" && !Array.isArray(explicit)) Object.assign(contributed, explicit);
67
+ } else {
68
+ const localPatchesDir = readLocalPatchesDir(config);
69
+ const owned = discoverPatches({
70
+ baseDir: dirname(opts.configFile),
71
+ name: typeof config.name === "string" ? config.name : "",
72
+ ...localPatchesDir !== void 0 ? { localPatchesDir } : {}
73
+ });
74
+ const workspaceRoot = dirname(path);
75
+ for (const p of owned) contributed[p.key] = relative(workspaceRoot, p.absPath).split(/[\\/]/).join("/");
76
+ }
77
+ if (Object.keys(contributed).length > 0) effective.patchedDependencies = {
78
+ ...(parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.patchedDependencies : void 0) ?? {},
79
+ ...contributed
80
+ };
81
+ const report = reconcilePatches({
82
+ parsedPatched: effective.patchedDependencies ?? {},
83
+ root: dirname(path),
84
+ exists: existsSync
85
+ });
86
+ const merged = overlayWorkspace(effective, parsed);
58
87
  const rendered = renderWorkspace(merged);
59
88
  const localKeys = new Set(config.local && typeof config.local === "object" ? Object.keys(config.local) : []);
60
89
  const diff = renderExportDiff(buildDiff(canonicalize(parsed), canonicalize(merged), {
@@ -65,7 +94,8 @@ function runExport(opts) {
65
94
  path,
66
95
  rendered,
67
96
  written: false,
68
- diff
97
+ diff,
98
+ report
69
99
  };
70
100
  yield* Effect.try({
71
101
  try: () => writeFileSync(path, rendered, "utf8"),
@@ -75,7 +105,8 @@ function runExport(opts) {
75
105
  path,
76
106
  rendered,
77
107
  written: true,
78
- diff
108
+ diff,
109
+ report
79
110
  };
80
111
  });
81
112
  }
@@ -111,6 +142,8 @@ const exportCommand = Command.make("export", {
111
142
  process.stdout.write(`${toAnsi(result.diff, { color: caps.color })}\n`);
112
143
  process.stdout.write("\n+ added ~ changed - removed (local) local override (unmanaged) not managed\n");
113
144
  } else process.stdout.write(`Exported to ${result.path}\n`);
145
+ for (const k of result.report.staleEntries) process.stderr.write(`warning: patch entry "${k}" has no file on disk\n`);
146
+ for (const k of result.report.keyMismatches) process.stderr.write(`warning: patch entry "${k}" does not match its filename\n`);
114
147
  });
115
148
  })).pipe(Command.withDescription("Materialize the plugin config into pnpm-workspace.yaml (--dry-run to preview)"));
116
149
 
@@ -56,8 +56,9 @@ function combine(managed, value, strategy) {
56
56
  function applyLocalDirective(managed, raw, parsed, field) {
57
57
  const directive = isLocalDirective(raw) ? raw : { value: raw };
58
58
  let result;
59
- if (directive.strategy && directive.value !== void 0) result = combine(managed, directive.value, directive.strategy);
60
- else if (directive.value !== void 0) result = directive.value;
59
+ const strat = directive.strategy === "merge" ? "union" : directive.strategy;
60
+ if (strat && strat !== "rewrite" && directive.value !== void 0) result = combine(managed, directive.value, strat);
61
+ else if (directive.value !== void 0 && directive.strategy !== "rewrite") result = directive.value;
61
62
  else result = managed;
62
63
  if (field === "overrides") {
63
64
  const protocols = directive.preserve ?? DEFAULT_PRESERVE;
package/cli/plan.js CHANGED
@@ -24,7 +24,8 @@ function planEntry(entry, versions) {
24
24
  }
25
25
  parsed.sort((a, b) => a.compare(b));
26
26
  const maxOf = (list) => list.length ? list[list.length - 1] : null;
27
- const current = yield* parseOrNull(entry.currentRange.replace(/^[\^~]/, ""));
27
+ const currentStripped = entry.currentRange.replace(/^[\^~]/, "");
28
+ const current = yield* parseOrNull(currentStripped);
28
29
  const currentMajor = current?.major ?? 0;
29
30
  const inRangeMax = range ? maxOf(parsed.filter((v) => range.test(v))) : null;
30
31
  const overallMax = maxOf(parsed);
package/index.d.ts CHANGED
@@ -60,7 +60,7 @@ type FieldInput<T> = T | {
60
60
  interface LocalDirective<T> {
61
61
  readonly preserve?: readonly string[];
62
62
  readonly value?: T;
63
- readonly strategy?: "union" | "difference";
63
+ readonly strategy?: "union" | "difference" | "merge" | "rewrite";
64
64
  }
65
65
  /**
66
66
  * The declarative plugin configuration.
@@ -83,7 +83,14 @@ interface PluginConfig {
83
83
  * generated field when running `rolldown-pnpm-config export`. Ignored by the
84
84
  * build and the shipped pnpmfile.
85
85
  */
86
- readonly local?: { readonly [K in keyof PluginConfig]?: PluginConfig[K] | LocalDirective<PluginConfig[K]> };
86
+ readonly local?: {
87
+ /**
88
+ * Override the local discovery root for distributed patches (default
89
+ * `public/patches/` adjacent to the build file). The local-only `patches/`
90
+ * folder detection is independent and unaffected.
91
+ */
92
+ readonly localPatchesDir?: string;
93
+ } & { readonly [K in keyof PluginConfig]?: PluginConfig[K] | LocalDirective<PluginConfig[K]> };
87
94
  /** Whether pnpm prompts before purging `node_modules`. */
88
95
  readonly confirmModulesPurge?: FieldInput<boolean>;
89
96
  /** Per-package manifest overrides merged into the dependency graph. */
@@ -242,8 +249,15 @@ interface PluginConfig {
242
249
  readonly shellEmulator?: FieldInput<boolean>;
243
250
  /** Scripts that must exist in every project matching the current filter. */
244
251
  readonly requiredScripts?: FieldInput<string[]>;
245
- /** Patches applied to dependencies, keyed by package identifier. */
246
- readonly patchedDependencies?: FieldInput<Record<string, string>>;
252
+ /**
253
+ * Patches applied to dependencies, keyed by package identifier. Pass a plain
254
+ * map for explicit control, or `{ strategy: "rewrite" }` (the default when
255
+ * `public/patches/` contains files) to auto-discover and rewrite patch paths
256
+ * to their distributed `node_modules/.pnpm-config/<name>/` location.
257
+ */
258
+ readonly patchedDependencies?: FieldInput<Record<string, string>> | {
259
+ readonly strategy: "rewrite";
260
+ };
247
261
  /** Whether unused patches (patches that apply to no installed package) are allowed. */
248
262
  readonly allowUnusedPatches?: FieldInput<boolean>;
249
263
  /** Whether non-applied patches (patches that fail to apply) are allowed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolldown-pnpm-config",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "A dogfooding example of our plugin",
6
6
  "repository": {
@@ -34,16 +34,24 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@effect/cli": "^0.75.2",
37
+ "@effect/cluster": "^0.59.0",
38
+ "@effect/experimental": "^0.60.0",
37
39
  "@effect/platform": "^0.96.2",
38
40
  "@effect/platform-node": "^0.107.0",
41
+ "@effect/printer": "^0.49.0",
42
+ "@effect/printer-ansi": "^0.49.0",
43
+ "@effect/rpc": "^0.75.1",
44
+ "@effect/sql": "^0.51.1",
45
+ "@effect/typeclass": "^0.40.0",
46
+ "@effect/workflow": "^0.18.2",
39
47
  "effect": "^3.21.4",
40
48
  "ink": "^7.1.0",
41
49
  "ink-tab": "^5.2.0",
42
- "oxc-parser": "^0.137.0",
50
+ "oxc-parser": "^0.138.0",
43
51
  "react": "^19.2.7",
44
- "semver-effect": "^0.2.1",
52
+ "semver-effect": "^0.3.1",
45
53
  "std-env": "^4.1.0",
46
- "std-osc8": "^0.1.0",
54
+ "std-osc8": "^0.2.0",
47
55
  "yaml": "^2.9.0"
48
56
  },
49
57
  "peerDependencies": {
@@ -0,0 +1,44 @@
1
+ import { discoverPatches } from "./discover.js";
2
+
3
+ //#region src/patches/build.ts
4
+ /** True only for the `{ strategy: "rewrite" }` directive. @internal */
5
+ function isRewriteDirective(v) {
6
+ return v !== null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 1 && v.strategy === "rewrite";
7
+ }
8
+ /** Read `local.localPatchesDir` when it is a string. @internal */
9
+ function readLocalPatchesDir(config) {
10
+ const local = config.local;
11
+ return typeof local?.localPatchesDir === "string" ? local.localPatchesDir : void 0;
12
+ }
13
+ /**
14
+ * Resolve build-time `patchedDependencies`. When the field is absent or the
15
+ * `{ strategy: "rewrite" }` directive, run discovery and inject the distributed
16
+ * map (`name`-scoped `.pnpm-config` paths) so `freeze` sees a plain map. An
17
+ * explicit map / wrapped value passes through untouched.
18
+ *
19
+ * @internal
20
+ */
21
+ function withResolvedBuildPatches(config, baseDir) {
22
+ const raw = config.patchedDependencies;
23
+ if (raw !== void 0 && !isRewriteDirective(raw)) return config;
24
+ const localPatchesDir = readLocalPatchesDir(config);
25
+ const distributed = discoverPatches({
26
+ baseDir,
27
+ name: config.name,
28
+ ...localPatchesDir !== void 0 ? { localPatchesDir } : {}
29
+ }).filter((p) => p.distributed);
30
+ if (distributed.length === 0) {
31
+ if (raw === void 0) return config;
32
+ const { patchedDependencies: _drop, ...rest } = config;
33
+ return rest;
34
+ }
35
+ const map = {};
36
+ for (const p of distributed) map[p.key] = p.distributedPath;
37
+ return {
38
+ ...config,
39
+ patchedDependencies: map
40
+ };
41
+ }
42
+
43
+ //#endregion
44
+ export { isRewriteDirective, readLocalPatchesDir, withResolvedBuildPatches };
@@ -0,0 +1,39 @@
1
+ import { patchKeyFromFileName } from "./keys.js";
2
+ import { distributedPatchPath, distributedRel } from "./paths.js";
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import { isAbsolute, join } from "node:path";
5
+
6
+ //#region src/patches/discover.ts
7
+ /**
8
+ * Discover owned patches in the two convention folders adjacent to the build
9
+ * file: `public/patches/` (distributed, rewritten) and `patches/` (local-only).
10
+ * `localPatchesDir` overrides the distributed source root only. Read-only.
11
+ *
12
+ * @internal
13
+ */
14
+ function discoverPatches(opts) {
15
+ const distRoot = opts.localPatchesDir ? isAbsolute(opts.localPatchesDir) ? opts.localPatchesDir : join(opts.baseDir, opts.localPatchesDir) : join(opts.baseDir, "public", "patches");
16
+ const localOnlyRoot = join(opts.baseDir, "patches");
17
+ const out = [];
18
+ collect(distRoot, true);
19
+ collect(localOnlyRoot, false);
20
+ return out;
21
+ function collect(dir, distributed) {
22
+ if (!existsSync(dir)) return;
23
+ for (const fileName of readdirSync(dir).sort()) {
24
+ const key = patchKeyFromFileName(fileName);
25
+ if (key === null) continue;
26
+ const absPath = join(dir, fileName);
27
+ out.push({
28
+ key,
29
+ fileName,
30
+ distributed,
31
+ absPath,
32
+ ...distributed ? { distributedPath: distributedPatchPath(opts.name, distributedRel(opts.baseDir, distRoot, fileName)) } : {}
33
+ });
34
+ }
35
+ }
36
+ }
37
+
38
+ //#endregion
39
+ export { discoverPatches };
@@ -0,0 +1,17 @@
1
+ //#region src/patches/keys.ts
2
+ /**
3
+ * Derive the `patchedDependencies` key from a `.patch` filename, reversing pnpm's
4
+ * `/`→`__` mangling (`@scope__pkg@1.0.0.patch` → `@scope/pkg@1.0.0`). Returns
5
+ * `null` when the name does not end in `.patch` or has an empty stem.
6
+ *
7
+ * @internal
8
+ */
9
+ function patchKeyFromFileName(fileName) {
10
+ if (!fileName.endsWith(".patch")) return null;
11
+ const stem = fileName.slice(0, -6);
12
+ if (stem.length === 0) return null;
13
+ return stem.replace(/__/g, "/");
14
+ }
15
+
16
+ //#endregion
17
+ export { patchKeyFromFileName };
@@ -0,0 +1,29 @@
1
+ import { basename, join, posix, relative } from "node:path";
2
+
3
+ //#region src/patches/paths.ts
4
+ /**
5
+ * Consumer-resolved distributed patch path for a config dependency:
6
+ * `node_modules/.pnpm-config/<name>/<rel>`, POSIX separators. `<name>` is used
7
+ * verbatim (a scoped name keeps its `/`). Verify the prefix against a real
8
+ * install — see plan Task 1.
9
+ *
10
+ * @internal
11
+ */
12
+ function distributedPatchPath(name, rel) {
13
+ const segments = rel.split(/[\\/]/).filter(Boolean);
14
+ return posix.join("node_modules", ".pnpm-config", name, ...segments);
15
+ }
16
+ /**
17
+ * The patch's path relative to the bundler's `public/` directory — the subpath
18
+ * the bundler preserves when copying `public/` into `dist/`. Falls back to
19
+ * `<basename(distRoot)>/<fileName>` when `distRoot` is not under `public/`.
20
+ *
21
+ * @internal
22
+ */
23
+ function distributedRel(baseDir, distRoot, fileName) {
24
+ const rel = relative(join(baseDir, "public"), distRoot);
25
+ return `${(rel === "" || rel.startsWith("..") ? basename(distRoot) : rel).split(/[\\/]/).filter(Boolean).join("/")}/${fileName}`;
26
+ }
27
+
28
+ //#endregion
29
+ export { distributedPatchPath, distributedRel };
@@ -0,0 +1,27 @@
1
+ import { patchKeyFromFileName } from "./keys.js";
2
+ import { basename, join } from "node:path";
3
+
4
+ //#region src/patches/reconcile.ts
5
+ /**
6
+ * Report `patchedDependencies` entries whose file is missing (`staleEntries`) or
7
+ * whose key does not derive from its filename (`keyMismatches`). `exists` is
8
+ * injected so the function stays pure and testable.
9
+ *
10
+ * @internal
11
+ */
12
+ function reconcilePatches(args) {
13
+ const staleEntries = [];
14
+ const keyMismatches = [];
15
+ for (const [key, rel] of Object.entries(args.parsedPatched)) {
16
+ if (!args.exists(join(args.root, rel))) staleEntries.push(key);
17
+ const derived = patchKeyFromFileName(basename(rel));
18
+ if (derived !== null && derived !== key) keyMismatches.push(key);
19
+ }
20
+ return {
21
+ staleEntries,
22
+ keyMismatches
23
+ };
24
+ }
25
+
26
+ //#endregion
27
+ export { reconcilePatches };
package/plugin/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { withResolvedBuildPatches } from "../patches/build.js";
1
2
  import { freeze } from "./freeze.js";
2
3
  import { emitCatalogsModule, emitPnpmfileModule } from "./serialize.js";
3
4
  import { Effect } from "effect";
@@ -14,7 +15,7 @@ const CATALOGS_SPEC = "rolldown-pnpm-config/virtual/catalogs";
14
15
  */
15
16
  function createPnpmConfigPlugin(config, deps = { freeze }) {
16
17
  let frozen;
17
- const getFrozen = () => frozen ??= Effect.runPromise(deps.freeze(config));
18
+ const getFrozen = () => frozen ??= Effect.runPromise(deps.freeze(withResolvedBuildPatches(config, process.cwd())));
18
19
  return {
19
20
  name: "rolldown-pnpm-config",
20
21
  resolveId(source) {