@effected/workspaces 0.23.0 → 0.24.0

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.
@@ -0,0 +1,307 @@
1
+ import { Array, Duration, Effect, Exit, Option, Predicate, Result } from "effect";
2
+ import { CatalogAssemblyError, PackageManagerCache } from "@effected/npm";
3
+ import { Yaml } from "@effected/yaml";
4
+ import { readFile, readdir, realpath } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
7
+
8
+ //#region src/internal/configDependencyResolution.ts
9
+ /** The pnpmfile filenames a config dependency may ship, in the order pnpm tries them. */
10
+ const PNPMFILE_CANDIDATES = [
11
+ "pnpmfile.mjs",
12
+ "pnpmfile.cjs",
13
+ "pnpmfile.js"
14
+ ];
15
+ /**
16
+ * The version part of a `configDependencies` spec: the text before the first
17
+ * `+` (`0.9.0+sha512-…` → `0.9.0`); a bare `0.9.0` is returned whole.
18
+ */
19
+ const declaredVersionOf = (spec) => {
20
+ const plus = spec.indexOf("+");
21
+ return plus === -1 ? spec : spec.slice(0, plus);
22
+ };
23
+ /**
24
+ * Whether a config-dependency `name` carries a `..` path segment. A scoped name
25
+ * legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
26
+ * it would traverse out of `.pnpm-config` and feed an attacker-chosen path to the
27
+ * dynamic `import()`.
28
+ */
29
+ const hasTraversalSegment = (name) => name.split(/[/\\]/).includes("..");
30
+ /** The typed `hooks`-source failure every rung of the ladder reports through. */
31
+ const hooksError = (path, cause) => new CatalogAssemblyError({
32
+ source: "hooks",
33
+ path,
34
+ cause
35
+ });
36
+ /**
37
+ * The declared `(name, version)` pairs of a `configDependencies` map, in
38
+ * declaration order, with EVERY name validated before any path is built from
39
+ * one: a `..` segment fails typed. Shared by both resolvers so the refusal and
40
+ * the version split cannot drift between them.
41
+ */
42
+ const declaredEntries = (configDependencies) => {
43
+ const entries = Object.entries(configDependencies).map(([name, spec]) => ({
44
+ name,
45
+ version: declaredVersionOf(spec)
46
+ }));
47
+ const traversal = entries.find((entry) => hasTraversalSegment(entry.name));
48
+ return traversal === void 0 ? Effect.succeed(entries) : Effect.fail(hooksError(traversal.name, /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${traversal.name}`)));
49
+ };
50
+ /** Whether a `node:fs` rejection means "nothing there" (as opposed to a real IO failure). */
51
+ const isAbsent = (cause) => Predicate.isObject(cause) && (cause.code === "ENOENT" || cause.code === "ENOTDIR");
52
+ /**
53
+ * Run a `node:fs/promises` call, mapping an absent target to `Option.none()`
54
+ * and ANY other rejection (`EACCES`, `EIO`, …) to a typed `hooks` error
55
+ * attributed to `path` — never a silent skip.
56
+ */
57
+ const ioOrNone = (path, run) => Effect.tryPromise({
58
+ try: run,
59
+ catch: (cause) => cause
60
+ }).pipe(Effect.map(Option.some), Effect.catch((cause) => isAbsent(cause) ? Effect.succeed(Option.none()) : Effect.fail(hooksError(path, cause))));
61
+ /**
62
+ * The `version` of the `package.json` in `dir`: `None` when the manifest is
63
+ * absent, `Some("")` when present but carrying no string version (so it can
64
+ * never match a declared version), typed on any other IO failure or on
65
+ * unparseable JSON — a manifest that exists but cannot be read is not
66
+ * evidence of absence.
67
+ */
68
+ const manifestVersion = (name, dir) => ioOrNone(name, () => readFile(join(dir, "package.json"), "utf8")).pipe(Effect.flatMap((text) => {
69
+ if (Option.isNone(text)) return Effect.succeed(Option.none());
70
+ return Effect.try({
71
+ try: () => JSON.parse(text.value),
72
+ catch: (cause) => hooksError(name, cause)
73
+ }).pipe(Effect.map((parsed) => Option.some(Predicate.isObject(parsed) && typeof parsed.version === "string" ? parsed.version : "")));
74
+ }));
75
+ /** Directory entries of `dir`, or `[]` when it is absent; typed on any other failure. */
76
+ const entriesOf = (path, dir) => ioOrNone(path, () => readdir(dir)).pipe(Effect.map(Option.getOrElse(() => [])));
77
+ /** The `<root>/node_modules/.pnpm-config` directory. */
78
+ const pnpmConfigDir = (root) => join(root, "node_modules", ".pnpm-config");
79
+ /**
80
+ * The store directory a realpath'd `.pnpm-config` entry lives under: the
81
+ * parent of the ancestor directory named `links`. `None` when the resolved
82
+ * path is not under a `links` tree (a non-store installation).
83
+ */
84
+ const storeOfLinkedPath = (resolved) => {
85
+ let current = resolved;
86
+ for (;;) {
87
+ const parent = dirname(current);
88
+ if (parent === current) return Option.none();
89
+ if (basename(current) === "links") return Option.some(parent);
90
+ current = parent;
91
+ }
92
+ };
93
+ /**
94
+ * Rung 1 of store discovery: the `storeDir` key of
95
+ * `<root>/node_modules/.modules.yaml`. Absent in a config-only install; a
96
+ * malformed file or a missing key contributes nothing (discovery is
97
+ * best-effort — the final error lists what was searched); a non-absent IO
98
+ * failure is typed.
99
+ */
100
+ const storeFromModulesYaml = (root) => ioOrNone(root, () => readFile(join(root, "node_modules", ".modules.yaml"), "utf8")).pipe(Effect.map((text) => {
101
+ if (Option.isNone(text)) return [];
102
+ const parsed = Yaml.parseResult(text.value);
103
+ if (Result.isFailure(parsed)) return [];
104
+ const document = parsed.success;
105
+ return Predicate.isObject(document) && typeof document.storeDir === "string" && document.storeDir.length > 0 ? [document.storeDir] : [];
106
+ }));
107
+ /**
108
+ * Rung 2 of store discovery: the realpath of every `.pnpm-config` entry,
109
+ * each walked up to its `links` ancestor. Scoped entries (`@scope/pkg`) are
110
+ * one level deeper.
111
+ */
112
+ const storesFromLinks = (root) => Effect.gen(function* () {
113
+ const base = pnpmConfigDir(root);
114
+ const candidates = [];
115
+ for (const entry of yield* entriesOf(root, base)) if (entry.startsWith("@")) for (const inner of yield* entriesOf(root, join(base, entry))) candidates.push(`${entry}/${inner}`);
116
+ else candidates.push(entry);
117
+ const stores = [];
118
+ for (const candidate of candidates) {
119
+ const resolved = yield* ioOrNone(root, () => realpath(join(base, candidate)));
120
+ if (Option.isNone(resolved)) continue;
121
+ const store = storeOfLinkedPath(resolved.value);
122
+ if (Option.isSome(store)) stores.push(store.value);
123
+ }
124
+ return stores;
125
+ });
126
+ /**
127
+ * Rung 3 of store discovery: the conventional store roots — `$PNPM_HOME/store`,
128
+ * `$XDG_DATA_HOME/pnpm/store`, `%LOCALAPPDATA%\pnpm\store` on Windows, then
129
+ * the platform default from `@effected/npm`'s cache table — each expanded to
130
+ * its `v*` subdirectories (a store root holds one directory per store
131
+ * version, `v11` today).
132
+ */
133
+ const storesFromEnvironment = (root) => Effect.gen(function* () {
134
+ const env = process.env;
135
+ const platform = process.platform;
136
+ const roots = [];
137
+ if (env.PNPM_HOME) roots.push(join(env.PNPM_HOME, "store"));
138
+ if (env.XDG_DATA_HOME) roots.push(join(env.XDG_DATA_HOME, "pnpm", "store"));
139
+ if (platform === "win32" && env.LOCALAPPDATA) roots.push(join(env.LOCALAPPDATA, "pnpm", "store"));
140
+ const home = homedir();
141
+ roots.push(PackageManagerCache.defaultDirectory("pnpm", {
142
+ platform,
143
+ home
144
+ }));
145
+ if (platform !== "win32") roots.push(join(home, ".local", "share", "pnpm", "store"));
146
+ const stores = [];
147
+ for (const storeRoot of roots) {
148
+ const versions = (yield* entriesOf(root, storeRoot)).filter((entry) => /^v\d+$/.test(entry)).sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)));
149
+ for (const version of versions) stores.push(join(storeRoot, version));
150
+ }
151
+ return stores;
152
+ });
153
+ /**
154
+ * Every store directory the ladder searches, in discovery order,
155
+ * deduplicated. IO failures during discovery are attributed to the workspace
156
+ * root, since discovery serves every dependency of one `resolvePnpmfiles`
157
+ * call rather than any single one.
158
+ */
159
+ const discoverStores = (root) => Effect.gen(function* () {
160
+ const [fromYaml, fromLinks, fromEnvironment] = yield* Effect.all([
161
+ storeFromModulesYaml(root),
162
+ storesFromLinks(root),
163
+ storesFromEnvironment(root)
164
+ ]);
165
+ const canonical = [];
166
+ for (const store of [
167
+ ...fromYaml,
168
+ ...fromLinks,
169
+ ...fromEnvironment
170
+ ]) {
171
+ const resolved = yield* ioOrNone(root, () => realpath(store));
172
+ if (Option.isSome(resolved) && !canonical.includes(resolved.value)) canonical.push(resolved.value);
173
+ }
174
+ return canonical;
175
+ });
176
+ /**
177
+ * The `<store>/links/<name>/<declared>/<hash>/node_modules/<name>` directories
178
+ * whose manifest carries exactly `declared`, from the FIRST store (in
179
+ * discovery order) that holds any — `[]` when none does.
180
+ *
181
+ * @remarks
182
+ * The `<hash>` segment is pnpm's, and it is NOT derivable from the declared
183
+ * integrity (sha256/sha512 of the integrity string, its decoded bytes, the
184
+ * `name@version` dep path and the tarball URL were all tried and none match),
185
+ * and the store keeps no integrity metadata beside the entry — so the
186
+ * manifest version is the only identity this rung can check.
187
+ *
188
+ * Two rules follow. The decision is scoped to ONE store: discovery order
189
+ * already encodes authority (`.modules.yaml` names the store this workspace
190
+ * installed from, the environment rungs are guesses), so a version held by
191
+ * several distinct stores — `store/v10` beside `store/v11`, a workspace store
192
+ * beside the platform default — resolves from the first, exactly as the rung
193
+ * order intends. Within that store every match is returned rather than the
194
+ * first taken, because the CALLER must fail closed when more than one hash
195
+ * directory carries the version: two honest copies of one version (a
196
+ * same-version re-publish, a private mirror, a hand-populated store) cannot be
197
+ * told apart here, and importing whichever `readdir` listed first would
198
+ * execute code the ref's own integrity pin was written to exclude. The stores
199
+ * are already deduplicated by realpath, so two matches here are two entries,
200
+ * never one entry reached by two spellings.
201
+ */
202
+ const findInStores = (name, declared, stores) => Effect.gen(function* () {
203
+ for (const store of stores) {
204
+ const versionDir = join(store, "links", name, declared);
205
+ const matches = [];
206
+ for (const hash of yield* entriesOf(name, versionDir)) {
207
+ const dir = join(versionDir, hash, "node_modules", name);
208
+ const version = yield* manifestVersion(name, dir);
209
+ if (Option.isSome(version) && version.value === declared) matches.push(dir);
210
+ }
211
+ if (matches.length > 0) return {
212
+ store,
213
+ matches
214
+ };
215
+ }
216
+ return {
217
+ store: "",
218
+ matches: []
219
+ };
220
+ });
221
+ /** The fail-closed message for a version ONE store holds more than once: which store, which copies, how to disambiguate. */
222
+ const ambiguousMessage = (name, declared, store, matches) => `config dependency ${name}@${declared} is ambiguous: the pnpm store at ${store} holds ${matches.length} copies of that version (${matches.join(", ")}) and the store records no integrity to tell them apart, so none is replayed. Remove the stale copies, or install ${name}@${declared} in this workspace so node_modules/.pnpm-config answers instead.`;
223
+ /** The fail-closed message: what was declared, what is installed, where we looked, and how to fix it. */
224
+ const notInstalledMessage = (name, declared, installed, stores) => {
225
+ return `config dependency ${name}@${declared} is not installed: node_modules/.pnpm-config/${name} holds ${Option.match(installed, {
226
+ onNone: () => "nothing",
227
+ onSome: (version) => version === "" ? "a package with no version" : `version ${version}`
228
+ })}, and no store copy of ${name}@${declared} was found (${stores.length === 0 ? "no pnpm store directory could be located" : `searched ${stores.join(", ")}`}). Run \`pnpm add --config ${name}@${declared}\` in a throwaway workspace to populate the store, then retry.`;
229
+ };
230
+ /**
231
+ * The first pnpmfile candidate present in `dir`, from ONE directory listing.
232
+ * `None` when the dependency ships none. Presence is settled by the listing
233
+ * alone: a listed-but-unreadable file (`EACCES`, say) is returned and fails
234
+ * typed at `import()` time — it never reads as "this dependency ships no
235
+ * hook".
236
+ */
237
+ const pnpmfileIn = (name, dir) => entriesOf(name, dir).pipe(Effect.map((entries) => Option.map(Array.findFirst(PNPMFILE_CANDIDATES, (candidate) => entries.includes(candidate)), (filename) => join(dir, filename))));
238
+ /**
239
+ * Resolve one declared config dependency to the directory holding its
240
+ * declared version: `.pnpm-config` when it holds exactly that version, else
241
+ * the store, else a typed fail-closed error. `stores` is the memoized
242
+ * discovery, run at most once per {@link resolvePnpmfiles} call and only on
243
+ * the first `.pnpm-config` miss.
244
+ */
245
+ const resolveDirectory = (root, name, declared, stores) => Effect.gen(function* () {
246
+ const installedDir = join(pnpmConfigDir(root), name);
247
+ const installed = yield* manifestVersion(name, installedDir);
248
+ if (Option.isSome(installed) && installed.value === declared) return {
249
+ dir: installedDir,
250
+ source: "installed"
251
+ };
252
+ const searched = yield* stores;
253
+ const { store, matches } = yield* findInStores(name, declared, searched);
254
+ const [only, ...rest] = matches;
255
+ if (only !== void 0 && rest.length === 0) return {
256
+ dir: only,
257
+ source: "store"
258
+ };
259
+ if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches))));
260
+ return yield* Effect.fail(hooksError(name, new Error(notInstalledMessage(name, declared, installed, searched))));
261
+ });
262
+ /**
263
+ * Resolve every declared config dependency at its declared version, in
264
+ * declaration order. A dependency that ships no pnpmfile is still returned
265
+ * (resolved, `path` undefined — the one legitimate skip at replay time); a
266
+ * `..` segment in a name, an unresolvable declared version, or any
267
+ * non-absent IO failure fails typed as a `hooks`-source
268
+ * `CatalogAssemblyError`.
269
+ */
270
+ const resolvePnpmfiles = (root, configDependencies) => Effect.gen(function* () {
271
+ const entries = yield* declaredEntries(configDependencies);
272
+ const [discoverOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(discoverStores(root), Duration.infinity);
273
+ const stores = Effect.onExit(discoverOnce, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate);
274
+ return yield* Effect.forEach(entries, ({ name, version }) => Effect.gen(function* () {
275
+ const { dir, source } = yield* resolveDirectory(root, name, version, stores);
276
+ const pnpmfile = yield* pnpmfileIn(name, dir);
277
+ return {
278
+ name,
279
+ version,
280
+ source,
281
+ path: Option.getOrUndefined(pnpmfile)
282
+ };
283
+ }), { concurrency: "unbounded" });
284
+ });
285
+ /**
286
+ * The `layerFrom` counterpart of {@link resolvePnpmfiles}: look each declared
287
+ * `(name, version)` up in a caller-supplied `"<name>@<version>" → path` map.
288
+ * Same order, same `..` refusal, same fail-closed shape for a missing entry
289
+ * — no filesystem is consulted, so it is safe under any `FileSystem` layer.
290
+ */
291
+ const lookupPnpmfiles = (entries, configDependencies) => Effect.flatMap(declaredEntries(configDependencies), (declared) => Effect.forEach(declared, ({ name, version }) => {
292
+ const key = `${name}@${version}`;
293
+ const path = entries[key];
294
+ if (path === void 0) {
295
+ const known = Object.keys(entries);
296
+ return Effect.fail(hooksError(name, /* @__PURE__ */ new Error(`config dependency ${key} has no entry in the supplied pnpmfile map (known: ${known.length === 0 ? "none" : known.join(", ")})`)));
297
+ }
298
+ return Effect.succeed({
299
+ name,
300
+ version,
301
+ source: "supplied",
302
+ path
303
+ });
304
+ }));
305
+
306
+ //#endregion
307
+ export { lookupPnpmfiles, resolvePnpmfiles };
@@ -2,6 +2,7 @@ import { Effect, FileSystem, Path } from "effect";
2
2
  import { Yaml } from "@effected/yaml";
3
3
 
4
4
  //#region src/internal/patterns.ts
5
+ /** The string entries of `value` when it is an array, else `undefined`. */
5
6
  const stringsOf = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
6
7
  /** The `packages:` list of a `pnpm-workspace.yaml` document. Total on a parsed document. */
7
8
  const pnpmPatternsOf = (document) => {
@@ -59,4 +60,4 @@ const readPatterns = (root) => Effect.gen(function* () {
59
60
  });
60
61
 
61
62
  //#endregion
62
- export { manifestPatternsOf, pnpmPatternsOf, readPatterns };
63
+ export { manifestPatternsOf, pnpmPatternsOf, readPatterns, stringsOf };
@@ -0,0 +1,35 @@
1
+ import { ReleaseAgeGate } from "@effected/npm";
2
+
3
+ //#region src/internal/peerPatterns.ts
4
+ /** One pattern (negation stripped) as a predicate, on the shared single-pattern grammar. */
5
+ const matcherFromPattern = (pattern) => (name) => ReleaseAgeGate.matchesExclude(name, [pattern]);
6
+ const isNegation = (pattern) => pattern.startsWith("!");
7
+ /**
8
+ * Compile one `ignoreMissing` / `allowAny` list into a predicate over peer
9
+ * names, with `@pnpm/matcher`'s composition rules (see the module header).
10
+ *
11
+ * @internal
12
+ */
13
+ const peerNameMatcher = (patterns) => {
14
+ if (patterns.length === 0) return () => false;
15
+ const compiled = patterns.map((pattern) => isNegation(pattern) ? {
16
+ negation: true,
17
+ match: matcherFromPattern(pattern.slice(1))
18
+ } : {
19
+ negation: false,
20
+ match: matcherFromPattern(pattern)
21
+ });
22
+ const negations = compiled.filter((entry) => entry.negation);
23
+ if (negations.length === 0) return (name) => compiled.some((entry) => entry.match(name));
24
+ if (negations.length === compiled.length) return (name) => !negations.some((entry) => entry.match(name));
25
+ return (name) => {
26
+ let matched = false;
27
+ for (const entry of compiled) if (entry.negation) {
28
+ if (entry.match(name)) matched = false;
29
+ } else if (!matched && entry.match(name)) matched = true;
30
+ return matched;
31
+ };
32
+ };
33
+
34
+ //#endregion
35
+ export { peerNameMatcher };
@@ -0,0 +1,44 @@
1
+ import { stringsOf } from "./patterns.js";
2
+ import { NoPeerDependencyRules } from "../ConfigDependencyHooks.js";
3
+ import { Predicate } from "effect";
4
+
5
+ //#region src/internal/workspaceYaml.ts
6
+ /** The `configDependencies` map (name → version+integrity) of a parsed pnpm-workspace document. */
7
+ const configDependenciesOf = (document) => {
8
+ if (!Predicate.isObject(document) || !Predicate.isObject(document.configDependencies)) return {};
9
+ const out = {};
10
+ for (const [name, spec] of Object.entries(document.configDependencies)) if (typeof spec === "string") out[name] = spec;
11
+ return out;
12
+ };
13
+ /**
14
+ * The `peerDependencyRules` a `pnpm-workspace.yaml` declares inline — the half
15
+ * `pnpm:export` materializes into the file, as opposed to the half a config
16
+ * dependency injects at replay time.
17
+ *
18
+ * @remarks
19
+ * **Tolerant, unlike the catalog blocks**, and the asymmetry is deliberate: a
20
+ * malformed catalog block must hard-fail because a silently-empty catalog makes
21
+ * every dependency look newly added, whereas a malformed rules block costs only
22
+ * suppression — the failure mode is reporting a peer pnpm would have hidden,
23
+ * which is visible and safe. Failing the whole assembly over it would take the
24
+ * catalogs down with it.
25
+ *
26
+ * Every axis is read independently, so a malformed `ignoreMissing` does not
27
+ * discard a well-formed `allowedVersions`.
28
+ */
29
+ const inlinePeerDependencyRules = (document) => {
30
+ if (!Predicate.isObject(document) || !Predicate.isObject(document.peerDependencyRules)) return NoPeerDependencyRules;
31
+ const block = document.peerDependencyRules;
32
+ const allowedVersions = {};
33
+ if (Predicate.isObject(block.allowedVersions)) {
34
+ for (const [key, value] of Object.entries(block.allowedVersions)) if (typeof value === "string") allowedVersions[key] = value;
35
+ }
36
+ return {
37
+ allowedVersions,
38
+ ignoreMissing: stringsOf(block.ignoreMissing) ?? [],
39
+ allowAny: stringsOf(block.allowAny) ?? []
40
+ };
41
+ };
42
+
43
+ //#endregion
44
+ export { configDependenciesOf, inlinePeerDependencyRules };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/workspaces",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
6
6
  "keywords": [