@effected/workspaces 0.27.0 → 0.28.0-next-20260927071821
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/ConfigDependencyHooks.js +31 -10
- package/ConfigDependencySpec.js +170 -0
- package/WorkspaceCatalogs.js +9 -4
- package/WorkspaceDiscovery.js +2 -1
- package/WorkspaceRoot.js +4 -1
- package/WorkspaceSnapshots.js +11 -5
- package/index.d.ts +239 -12
- package/index.js +2 -1
- package/internal/configDependencyFetch.js +146 -0
- package/internal/configDependencyResolution.js +83 -57
- package/internal/configDependencyShared.js +51 -0
- package/internal/configDependencySpecGrammar.js +15 -0
- package/package.json +4 -4
- package/testing.d.ts +22 -0
package/ConfigDependencyHooks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalize } from "./internal/catalogs.js";
|
|
2
|
+
import { makeFetchConfigDependency } from "./internal/configDependencyFetch.js";
|
|
2
3
|
import { lookupPnpmfiles, resolvePnpmfiles } from "./internal/configDependencyResolution.js";
|
|
3
4
|
import { Context, Duration, Effect, Layer, Predicate, Schema } from "effect";
|
|
4
5
|
import { CatalogAssemblyError } from "@effected/npm";
|
|
@@ -381,9 +382,10 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
381
382
|
* `links/<name>/<declared>/*\/node_modules/<name>` (the store is located
|
|
382
383
|
* from `node_modules/.modules.yaml`, the realpath of any `.pnpm-config`
|
|
383
384
|
* entry, then `$PNPM_HOME` / `$XDG_DATA_HOME` / the platform default), else
|
|
384
|
-
* a typed error naming the package, the
|
|
385
|
-
* installed, the stores searched, and the
|
|
386
|
-
* (`pnpm add --config <name>@<version>` in a throwaway
|
|
385
|
+
* a typed error with `reason: "notInstalled"` naming the package, the
|
|
386
|
+
* declared version, what is installed, the stores searched, and the
|
|
387
|
+
* remediation (`pnpm add --config <name>@<version>` in a throwaway
|
|
388
|
+
* workspace, or the subprocess layer, which fetches). In the
|
|
387
389
|
* chosen directory the first of `pnpmfile.mjs`, `pnpmfile.cjs`,
|
|
388
390
|
* `pnpmfile.js` present in one directory listing is loaded; a listed but
|
|
389
391
|
* unreadable file fails typed at import time, never as "ships no hook".
|
|
@@ -396,9 +398,9 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
396
398
|
* wired by `WorkspaceCatalogs.layerWithConfigDependencies` and the
|
|
397
399
|
* `Workspaces` composites of the same name.
|
|
398
400
|
*/
|
|
399
|
-
static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
|
|
401
|
+
static layerLive = Layer.succeed(ConfigDependencyHooks, { inject: (root, configDependencies, seed, rules, context) => Effect.gen(function* () {
|
|
400
402
|
if (Object.keys(configDependencies).length === 0) return untouched(seed, rules);
|
|
401
|
-
const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies);
|
|
403
|
+
const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies, { side: context });
|
|
402
404
|
return yield* replayInProcess(pnpmfiles, seed, rules);
|
|
403
405
|
}) });
|
|
404
406
|
/**
|
|
@@ -463,9 +465,24 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
463
465
|
*
|
|
464
466
|
* The declared-version resolution is the PARENT's: the same ladder as
|
|
465
467
|
* `layerLive` (`.pnpm-config` when it holds the declared version, else the
|
|
466
|
-
* pnpm store
|
|
467
|
-
*
|
|
468
|
-
*
|
|
468
|
+
* pnpm store) runs here, and the child receives the resolved
|
|
469
|
+
* `[name, fileUrl]` pairs as one JSON argv argument.
|
|
470
|
+
*
|
|
471
|
+
* One rung more than `layerLive`: a version held nowhere is FETCHED into
|
|
472
|
+
* the store rather than failing, so a diff whose base side declares a
|
|
473
|
+
* version this checkout never installed still replays that version. pnpm
|
|
474
|
+
* runs `install --frozen-lockfile` in a scratch workspace (removed
|
|
475
|
+
* afterwards) whose lockfile pins the integrity the declaring side recorded
|
|
476
|
+
* — the inline `<version>+<integrity>` spec, else that side's
|
|
477
|
+
* `pnpm-lock.yaml` env preamble ({@link HookReplayContext.lockfile}) — so
|
|
478
|
+
* pnpm itself refuses a download that does not match. It writes to the
|
|
479
|
+
* first store the ladder searched, so the next replay finds the version
|
|
480
|
+
* there. The replay records `source: "fetched"`. Fail-closed: two recorded
|
|
481
|
+
* integrities that disagree fail with `reason: "integrityMismatch"`, none
|
|
482
|
+
* with `reason: "integrityUnavailable"` — nothing is fetched in either case
|
|
483
|
+
* — and a fetch that fails with `reason: "fetchFailed"`, keeping the
|
|
484
|
+
* not-installed remediation. The fetch needs `pnpm` 11 or 12 on `PATH` and
|
|
485
|
+
* registry access, and is bounded at two minutes. An empty `configDependencies`, or one whose dependencies all
|
|
469
486
|
* ship no pnpmfile, returns the seed without spawning anything; a `..` path
|
|
470
487
|
* segment in a dependency name fails typed **before** any spawn; a
|
|
471
488
|
* load failure in the child — a syntax error, a throwing top level, an
|
|
@@ -498,9 +515,13 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
498
515
|
*/
|
|
499
516
|
static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
|
|
500
517
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
501
|
-
|
|
518
|
+
const fetch = makeFetchConfigDependency(spawner);
|
|
519
|
+
return { inject: (root, configDependencies, seed, rules, context) => Effect.gen(function* () {
|
|
502
520
|
if (Object.keys(configDependencies).length === 0) return untouched(seed, rules);
|
|
503
|
-
const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies
|
|
521
|
+
const pnpmfiles = yield* resolvePnpmfiles(root, configDependencies, {
|
|
522
|
+
side: context,
|
|
523
|
+
fetch
|
|
524
|
+
});
|
|
504
525
|
const loadable = pnpmfiles.flatMap(({ name, path }) => path === void 0 ? [] : [[name, pathToFileURL(path).href]]);
|
|
505
526
|
if (loadable.length === 0) return untouched(seed, rules, replaysOf(pnpmfiles));
|
|
506
527
|
const command = ChildProcess.make("node", [
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { splitConfigDependencySpec } from "./internal/configDependencySpecGrammar.js";
|
|
2
|
+
import { Effect, Exit, Option, Result, Schema, SchemaIssue, SchemaTransformation } from "effect";
|
|
3
|
+
import { SriIntegrityHash } from "@effected/npm";
|
|
4
|
+
import { SemVer } from "@effected/semver";
|
|
5
|
+
|
|
6
|
+
//#region src/ConfigDependencySpec.ts
|
|
7
|
+
/**
|
|
8
|
+
* Indicates that a string could not be parsed as a pnpm `configDependencies`
|
|
9
|
+
* spec (`<version>[+<integrity>]`).
|
|
10
|
+
*
|
|
11
|
+
* Raised by {@link ConfigDependencySpec.parse} and
|
|
12
|
+
* {@link ConfigDependencySpec.parseResult}; the decode direction of
|
|
13
|
+
* {@link ConfigDependencySpec.FromString} reports the same failure through a
|
|
14
|
+
* generic `Schema` parse error carrying the same message. The offending string
|
|
15
|
+
* is preserved on `input`; `reason` says which half failed.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
var InvalidConfigDependencySpecError = class extends Schema.TaggedError()("InvalidConfigDependencySpecError", {
|
|
20
|
+
/** The raw spec string that failed validation. */
|
|
21
|
+
input: Schema.String,
|
|
22
|
+
/**
|
|
23
|
+
* Which half of the spec failed: `version` (the text before the first
|
|
24
|
+
* `+` is not an exact SemVer version — ranges, partial versions,
|
|
25
|
+
* dist-tags and padded values all land here) or `integrity` (the text
|
|
26
|
+
* after the first `+` is not an SRI `<algo>-<base64>` hash).
|
|
27
|
+
*/
|
|
28
|
+
reason: Schema.Literals(["version", "integrity"])
|
|
29
|
+
}) {
|
|
30
|
+
get message() {
|
|
31
|
+
return this.reason === "version" ? `Invalid config dependency spec "${this.input}": version must be an exact SemVer version (ranges, partial versions and dist-tags are not allowed)` : `Invalid config dependency spec "${this.input}": integrity must be an SRI <algo>-<base64> hash`;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const specVersion = SemVer.pipe(Schema.check(Schema.makeFilter((version) => version.build.length === 0 ? void 0 : "Expected a version without build metadata")));
|
|
35
|
+
/**
|
|
36
|
+
* A pnpm `configDependencies` spec from `pnpm-workspace.yaml`: an exact
|
|
37
|
+
* `version` and, on the legacy inline form only, an SRI `integrity`.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* pnpm 11 and later write the **bare** form — `pnpm add --config` records
|
|
41
|
+
* `0.11.1`, and the integrity lives in the lockfile. The inline form
|
|
42
|
+
* `0.11.1+sha512-<base64>` is deprecated but still read, and workspaces in the
|
|
43
|
+
* wild still carry it. {@link ConfigDependencySpec.bare} renders the bare form
|
|
44
|
+
* (a tool normalizing the field writes this); `toString()` re-renders the form
|
|
45
|
+
* that was parsed, so a tool that must not rewrite the field round-trips it
|
|
46
|
+
* byte-identically.
|
|
47
|
+
*
|
|
48
|
+
* The first `+` always begins the integrity, never semver build metadata, so
|
|
49
|
+
* `0.11.1+sha512-…` is version `0.11.1` plus integrity `sha512-…`; a tail that
|
|
50
|
+
* is not an SRI hash fails with `reason: "integrity"` rather than falling back
|
|
51
|
+
* to build-metadata parsing.
|
|
52
|
+
*
|
|
53
|
+
* `version` is `@effected/semver`'s `SemVer`: prerelease versions are
|
|
54
|
+
* accepted, ranges (`^0.11.1`), partial versions, dist-tags, a `v` prefix and
|
|
55
|
+
* padded values are not.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { ConfigDependencySpec } from "@effected/workspaces";
|
|
60
|
+
* import { Effect } from "effect";
|
|
61
|
+
*
|
|
62
|
+
* const program = Effect.gen(function* () {
|
|
63
|
+
* const spec = yield* ConfigDependencySpec.parse("0.11.1+sha512-m35m…==");
|
|
64
|
+
* return [spec.bare, spec.hasIntegrity] as const; // ["0.11.1", true]
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
var ConfigDependencySpec = class ConfigDependencySpec extends Schema.Class("ConfigDependencySpec")({
|
|
71
|
+
/**
|
|
72
|
+
* The exact declared version. Never carries build metadata — the spec
|
|
73
|
+
* grammar cannot express it (see the class remarks), and a version
|
|
74
|
+
* constructed with build identifiers is rejected at construction.
|
|
75
|
+
*/
|
|
76
|
+
version: specVersion,
|
|
77
|
+
/**
|
|
78
|
+
* The inline integrity (e.g. `sha512-m35m…==`): `@effected/npm`'s
|
|
79
|
+
* `SriIntegrityHash`, the shared restriction of the `IntegrityHash` brand
|
|
80
|
+
* to the SRI `<algo>-<base64>` form. `None` on the bare form pnpm 11+
|
|
81
|
+
* writes.
|
|
82
|
+
*/
|
|
83
|
+
integrity: Schema.Option(SriIntegrityHash)
|
|
84
|
+
}) {
|
|
85
|
+
/**
|
|
86
|
+
* Schema transformation between the `<version>[+<integrity>]` string and a
|
|
87
|
+
* {@link ConfigDependencySpec}. Decoding parses via
|
|
88
|
+
* {@link ConfigDependencySpec.parseResult} (the surfaces share one grammar);
|
|
89
|
+
* encoding prints `toString()`, the form that was parsed.
|
|
90
|
+
*/
|
|
91
|
+
static FromString = Schema.String.pipe(Schema.decodeTo(Schema.instanceOf(ConfigDependencySpec), SchemaTransformation.transformEffect({
|
|
92
|
+
decode: (input) => {
|
|
93
|
+
const parsed = ConfigDependencySpec.parseResult(input);
|
|
94
|
+
return Result.isSuccess(parsed) ? Effect.succeed(parsed.success) : Effect.fail(new SchemaIssue.InvalidValue({ message: parsed.failure.message }, input));
|
|
95
|
+
},
|
|
96
|
+
encode: (spec) => Effect.succeed(spec.toString())
|
|
97
|
+
})));
|
|
98
|
+
/**
|
|
99
|
+
* Parse a `configDependencies` spec, synchronously, returning a `Result`
|
|
100
|
+
* instead of an `Effect`.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* The text before the first `+` must be an exact SemVer version that
|
|
104
|
+
* renders back to itself byte for byte (`SemVer.isPinnable`, plus a
|
|
105
|
+
* canonical-spelling check so `toString()` round-trips); the text after it,
|
|
106
|
+
* when present, must be an SRI `<algo>-<base64>` hash. An empty tail
|
|
107
|
+
* (`0.11.1+`) is an integrity failure, not a bare spec.
|
|
108
|
+
* {@link ConfigDependencySpec.parse} is defined in terms of this function.
|
|
109
|
+
*
|
|
110
|
+
* @param input - the spec string to parse
|
|
111
|
+
* @returns a `Result` succeeding with the parsed {@link ConfigDependencySpec},
|
|
112
|
+
* or failing with {@link InvalidConfigDependencySpecError} naming the half
|
|
113
|
+
* that failed.
|
|
114
|
+
*/
|
|
115
|
+
static parseResult(input) {
|
|
116
|
+
const parts = splitConfigDependencySpec(input);
|
|
117
|
+
const version = SemVer.parseResult(parts.version);
|
|
118
|
+
if (Result.isFailure(version) || !SemVer.isPinnable(parts.version) || version.success.toString() !== parts.version) return Result.fail(new InvalidConfigDependencySpecError({
|
|
119
|
+
input,
|
|
120
|
+
reason: "version"
|
|
121
|
+
}));
|
|
122
|
+
if (parts.integrity === void 0) return Result.succeed(ConfigDependencySpec.make({
|
|
123
|
+
version: version.success,
|
|
124
|
+
integrity: Option.none()
|
|
125
|
+
}));
|
|
126
|
+
const integrity = Schema.decodeUnknownExit(SriIntegrityHash)(parts.integrity);
|
|
127
|
+
if (Exit.isFailure(integrity)) return Result.fail(new InvalidConfigDependencySpecError({
|
|
128
|
+
input,
|
|
129
|
+
reason: "integrity"
|
|
130
|
+
}));
|
|
131
|
+
return Result.succeed(ConfigDependencySpec.make({
|
|
132
|
+
version: version.success,
|
|
133
|
+
integrity: Option.some(integrity.value)
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parse a `configDependencies` spec. Defined in terms of
|
|
138
|
+
* {@link ConfigDependencySpec.parseResult} — synchronous callers can use
|
|
139
|
+
* that variant directly.
|
|
140
|
+
*
|
|
141
|
+
* @param input - the spec string to parse
|
|
142
|
+
* @returns the parsed {@link ConfigDependencySpec}. Fails with
|
|
143
|
+
* {@link InvalidConfigDependencySpecError} when `input` is not a valid spec.
|
|
144
|
+
*/
|
|
145
|
+
static parse = Effect.fn("ConfigDependencySpec.parse")((input) => Effect.fromResult(ConfigDependencySpec.parseResult(input)));
|
|
146
|
+
/** Whether the spec carries an inline integrity (the legacy form). */
|
|
147
|
+
get hasIntegrity() {
|
|
148
|
+
return Option.isSome(this.integrity);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The bare `<version>` form — what pnpm 11+ writes to `configDependencies`
|
|
152
|
+
* — with any inline integrity dropped.
|
|
153
|
+
*/
|
|
154
|
+
get bare() {
|
|
155
|
+
return this.version.toString();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The spec as parsed: `<version>` or `<version>+<integrity>`. The encode
|
|
159
|
+
* direction of {@link ConfigDependencySpec.FromString} prints exactly this.
|
|
160
|
+
*/
|
|
161
|
+
toString() {
|
|
162
|
+
return Option.match(this.integrity, {
|
|
163
|
+
onNone: () => this.bare,
|
|
164
|
+
onSome: (integrity) => `${this.bare}+${integrity}`
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
export { ConfigDependencySpec, InvalidConfigDependencySpecError };
|
package/WorkspaceCatalogs.js
CHANGED
|
@@ -7,6 +7,7 @@ import { configDependenciesOf, inlinePeerDependencyRules } from "./internal/work
|
|
|
7
7
|
import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
|
|
8
8
|
import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, PartialReleaseAgeGate, ReleaseAgeGate } from "@effected/npm";
|
|
9
9
|
import { Yaml } from "@effected/yaml";
|
|
10
|
+
import { filenameFor } from "@effected/lockfiles";
|
|
10
11
|
|
|
11
12
|
//#region src/WorkspaceCatalogs.ts
|
|
12
13
|
/**
|
|
@@ -289,10 +290,13 @@ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceCat
|
|
|
289
290
|
* @remarks
|
|
290
291
|
* Rules are SEEDED, not merged afterwards: the workspace file's block goes
|
|
291
292
|
* into the threaded config so the hooks merge onto it, exactly as pnpm seeds
|
|
292
|
-
* its own config and takes back what the hooks return.
|
|
293
|
-
*
|
|
293
|
+
* its own config and takes back what the hooks return. `context` is the
|
|
294
|
+
* document's own side — its lockfile and, at a ref, the ref — so a fetched
|
|
295
|
+
* config dependency is verified against the record of the side that declared
|
|
296
|
+
* it. Not part of the public surface; `WorkspaceSnapshots` imports it
|
|
297
|
+
* directly.
|
|
294
298
|
*/
|
|
295
|
-
const injectFromDocument = (hooks, root, document, inline) => Effect.map(hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document)), (injection) => ({
|
|
299
|
+
const injectFromDocument = (hooks, root, document, inline, context) => Effect.map(hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document), context), (injection) => ({
|
|
296
300
|
injected: CatalogSet.fromCatalogs(injection.catalogs),
|
|
297
301
|
injection
|
|
298
302
|
}));
|
|
@@ -370,7 +374,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
370
374
|
yield* validatePnpmWorkspaceCatalogs(document);
|
|
371
375
|
inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
|
|
372
376
|
inlineGate = yield* inlineReleaseAge(document);
|
|
373
|
-
const
|
|
377
|
+
const lockfileText = Object.keys(configDependenciesOf(document)).length === 0 ? void 0 : yield* fs.readFileString(path.join(root, filenameFor("pnpm"))).pipe(Effect.orElseSucceed(() => void 0));
|
|
378
|
+
const replayed = yield* injectFromDocument(hooks, root, document, inline, { lockfile: lockfileText });
|
|
374
379
|
injected = replayed.injected;
|
|
375
380
|
hookGate = replayed.injection.releaseAge;
|
|
376
381
|
peerDependencyRules = replayed.injection.peerDependencyRules;
|
package/WorkspaceDiscovery.js
CHANGED
|
@@ -256,7 +256,8 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
|
|
|
256
256
|
};
|
|
257
257
|
}).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
|
|
258
258
|
const discover = Effect.gen(function* () {
|
|
259
|
-
const
|
|
259
|
+
const stopAt = options?.stopAt;
|
|
260
|
+
const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd(), stopAt === void 0 ? void 0 : { stopAt }));
|
|
260
261
|
return yield* discoverAt(root);
|
|
261
262
|
});
|
|
262
263
|
const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(discover, Duration.infinity);
|
package/WorkspaceRoot.js
CHANGED
|
@@ -8,6 +8,8 @@ import { Walker } from "@effected/walker";
|
|
|
8
8
|
* @public
|
|
9
9
|
*/
|
|
10
10
|
const WORKSPACE_MARKERS = ["pnpm-workspace.yaml", "package.json"];
|
|
11
|
+
/** How the not-found message names the `package.json` marker: only one with a `workspaces` field counts. */
|
|
12
|
+
const PACKAGE_JSON_MARKER_PROSE = "package.json with a \"workspaces\" field";
|
|
11
13
|
/**
|
|
12
14
|
* Raised when no workspace root can be found by ascending from a directory.
|
|
13
15
|
*
|
|
@@ -36,7 +38,8 @@ var WorkspaceRootNotFoundError = class extends Schema.TaggedError()("WorkspaceRo
|
|
|
36
38
|
/** Renders the search path, probed markers and any ceiling into a one-line message. */
|
|
37
39
|
get message() {
|
|
38
40
|
const bound = this.stopAt === void 0 ? "" : ` up to ${this.stopAt}`;
|
|
39
|
-
|
|
41
|
+
const looked = this.markers.map((marker) => marker === "package.json" ? PACKAGE_JSON_MARKER_PROSE : marker);
|
|
42
|
+
return `No workspace root above ${this.searchPath}${bound} (looked for ${looked.join(", ")})`;
|
|
40
43
|
}
|
|
41
44
|
};
|
|
42
45
|
/**
|
package/WorkspaceSnapshots.js
CHANGED
|
@@ -127,18 +127,20 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
127
127
|
const catalogsService = yield* WorkspaceCatalogs;
|
|
128
128
|
const hooks = yield* ConfigDependencyHooks;
|
|
129
129
|
const seeded = (snapshot) => options?.seedCatalogs === void 0 ? snapshot : snapshot.withSeededCatalogs(options.seedCatalogs);
|
|
130
|
+
/** A manager's lockfile text at the ref, `none` when the ref has none. */
|
|
131
|
+
const lockfileText = (root, ref, format) => git.show(root, ref, `./${filenameFor(format)}`);
|
|
130
132
|
/**
|
|
131
133
|
* What a manager's lockfile records at the ref: its catalog set and its
|
|
132
134
|
* importer versions, from ONE parse. Empty on both counts when the
|
|
133
135
|
* lockfile is absent or malformed.
|
|
134
136
|
*/
|
|
135
|
-
const lockfileRecord = (
|
|
137
|
+
const lockfileRecord = (content, format) => Option.match(content, {
|
|
136
138
|
onNone: () => Effect.succeed(EMPTY_LOCKFILE_RECORD),
|
|
137
139
|
onSome: (text) => Lockfile.parse(text, { format }).pipe(Effect.map((lockfile) => ({
|
|
138
140
|
catalogs: CatalogSet.fromLockfile(lockfile),
|
|
139
141
|
importerVersions: importerVersionsOf(lockfile)
|
|
140
142
|
})), Effect.catch(() => Effect.succeed(EMPTY_LOCKFILE_RECORD)))
|
|
141
|
-
})
|
|
143
|
+
});
|
|
142
144
|
const computeAt = (root, ref) => Effect.gen(function* () {
|
|
143
145
|
const [pnpmWorkspaceText, rootManifestText] = yield* Effect.all([git.show(root, ref, "./pnpm-workspace.yaml"), git.show(root, ref, "./package.json")], { concurrency: 2 });
|
|
144
146
|
const rootManifest = Option.match(rootManifestText, {
|
|
@@ -159,15 +161,19 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
159
161
|
const pnpmPatterns = pnpmPatternsOf(document);
|
|
160
162
|
patterns = pnpmPatterns.length > 0 ? pnpmPatterns : manifestPatternsOf(rootManifest);
|
|
161
163
|
inline = yield* CatalogSet.fromWorkspaceYaml(pnpmWorkspaceText.value);
|
|
162
|
-
const
|
|
164
|
+
const lockfile = yield* lockfileText(root, ref, "pnpm");
|
|
165
|
+
const replayed = yield* injectFromDocument(hooks, root, document, inline, {
|
|
166
|
+
lockfile: Option.getOrUndefined(lockfile),
|
|
167
|
+
ref
|
|
168
|
+
});
|
|
163
169
|
injected = replayed.injected;
|
|
164
170
|
hookReplays = replayed.injection.replays;
|
|
165
|
-
recorded = yield* lockfileRecord(
|
|
171
|
+
recorded = yield* lockfileRecord(lockfile, "pnpm");
|
|
166
172
|
} else {
|
|
167
173
|
patterns = manifestPatternsOf(rootManifest);
|
|
168
174
|
inline = bunInlineCatalogs(rootManifest);
|
|
169
175
|
injected = CatalogSet.empty();
|
|
170
|
-
recorded = yield* lockfileRecord(root, ref, "bun");
|
|
176
|
+
recorded = yield* lockfileRecord(yield* lockfileText(root, ref, "bun"), "bun");
|
|
171
177
|
}
|
|
172
178
|
const catalogs = CatalogSet.merge(recorded.catalogs, inline, injected);
|
|
173
179
|
const globs = yield* GlobSet.compile(patterns).pipe(Effect.mapError((error) => new CatalogAssemblyError({
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
|
|
2
|
-
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
3
3
|
import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, Manifest, PartialReleaseAgeGate, ReleaseAgeGate, UnresolvedDependencyError, WorkspaceResolver } from "@effected/npm";
|
|
4
4
|
import { GlobPattern } from "@effected/glob";
|
|
5
5
|
import { Lockfile, LockfileFramingError, LockfileIntegrity, LockfileParseError, ResolvedPackage, WorkspaceManifest } from "@effected/lockfiles";
|
|
6
6
|
import { Package } from "@effected/package-json";
|
|
7
7
|
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
8
|
+
import { SemVer } from "@effected/semver";
|
|
8
9
|
import { LocalExec } from "@effected/commands";
|
|
9
10
|
//#region src/WorkspacePackage.d.ts
|
|
10
11
|
declare const PublishConfig_base: Schema.Class<PublishConfig, Schema.Struct<{
|
|
@@ -540,6 +541,28 @@ interface WorkspaceDiscoveryOptions {
|
|
|
540
541
|
* honoured.
|
|
541
542
|
*/
|
|
542
543
|
readonly cwd?: string;
|
|
544
|
+
/**
|
|
545
|
+
* A ceiling for the layer-bound root ascent from `cwd`, passed straight
|
|
546
|
+
* through to the `stopAt` of {@link WorkspaceRoot}'s `find`.
|
|
547
|
+
*
|
|
548
|
+
* @remarks
|
|
549
|
+
* Inclusive: the ceiling itself is probed, so a `cwd` that is its own
|
|
550
|
+
* workspace root still resolves with `stopAt: cwd`. A relative ceiling is
|
|
551
|
+
* resolved against the process working directory, exactly as `cwd` is. When
|
|
552
|
+
* no root is found at or below the ceiling, the methods fail with
|
|
553
|
+
* {@link WorkspaceRootNotFoundError} carrying the resolved `stopAt`, rather
|
|
554
|
+
* than adopting an enclosing directory's workspace — pass `stopAt: cwd` for
|
|
555
|
+
* a checkout nested inside someone else's monorepo.
|
|
556
|
+
*
|
|
557
|
+
* Applies ONLY to the layer-bound methods (`info`, `listPackages`,
|
|
558
|
+
* `importerMap`, `getPackage`, `resolveFile`, `resolveFiles`). The per-call
|
|
559
|
+
* `infoIn`, `listPackagesIn` and `refreshIn` resolve from an arbitrary
|
|
560
|
+
* directory the caller names, which a single layer-level ceiling cannot
|
|
561
|
+
* sensibly bound, so they ascend unbounded as before.
|
|
562
|
+
*
|
|
563
|
+
* @defaultValue no ceiling — the ascent runs to the filesystem root.
|
|
564
|
+
*/
|
|
565
|
+
readonly stopAt?: string | undefined;
|
|
543
566
|
/** Descent cap for segment-crossing patterns. Defaults to 32. */
|
|
544
567
|
readonly maxDepth?: number;
|
|
545
568
|
}
|
|
@@ -934,12 +957,43 @@ interface PeerDependencyRules {
|
|
|
934
957
|
export declare const NoPeerDependencyRules: PeerDependencyRules;
|
|
935
958
|
/**
|
|
936
959
|
* Where a replayed config dependency's declared version was found: the
|
|
937
|
-
* `node_modules/.pnpm-config` copy, the pnpm store's `links/` tree,
|
|
960
|
+
* `node_modules/.pnpm-config` copy, the pnpm store's `links/` tree, the store
|
|
961
|
+
* after {@link ConfigDependencyHooks.layerSubprocess} fetched it, or a
|
|
938
962
|
* {@link ConfigDependencyHooks.layerFrom} entry.
|
|
939
963
|
*
|
|
940
964
|
* @public
|
|
941
965
|
*/
|
|
942
|
-
type HookReplaySource = "installed" | "store" | "supplied";
|
|
966
|
+
type HookReplaySource = "installed" | "store" | "fetched" | "supplied";
|
|
967
|
+
/**
|
|
968
|
+
* What the side whose `configDependencies` are being replayed recorded beyond
|
|
969
|
+
* that map — the input the fetch rung needs to verify what it fetches.
|
|
970
|
+
*
|
|
971
|
+
* @remarks
|
|
972
|
+
* Both fields are optional, and omitting the whole context is always valid:
|
|
973
|
+
* nothing here changes which version is replayed. `lockfile` matters only when
|
|
974
|
+
* a replaying layer must fetch a version installed nowhere, and only
|
|
975
|
+
* {@link ConfigDependencyHooks.layerSubprocess} fetches. Without it, a bare
|
|
976
|
+
* `configDependencies` spec has no integrity to verify a fetch against, so the
|
|
977
|
+
* fetch fails closed with `reason: "integrityUnavailable"`.
|
|
978
|
+
*
|
|
979
|
+
* `WorkspaceCatalogs` passes the working tree's lockfile, and
|
|
980
|
+
* `WorkspaceSnapshots.at(ref)` passes the lockfile and the ref it read at, so
|
|
981
|
+
* each side of a diff is verified against its own record.
|
|
982
|
+
*
|
|
983
|
+
* @public
|
|
984
|
+
*/
|
|
985
|
+
interface HookReplayContext {
|
|
986
|
+
/**
|
|
987
|
+
* The declaring side's `pnpm-lock.yaml` text, whose env preamble records each
|
|
988
|
+
* config dependency's integrity. `undefined` when that side has no lockfile.
|
|
989
|
+
*/
|
|
990
|
+
readonly lockfile?: string | undefined;
|
|
991
|
+
/**
|
|
992
|
+
* The git ref whose `pnpm-workspace.yaml` declared the `configDependencies`,
|
|
993
|
+
* used to name the side in error messages. `undefined` for the working tree.
|
|
994
|
+
*/
|
|
995
|
+
readonly ref?: string | undefined;
|
|
996
|
+
}
|
|
943
997
|
/**
|
|
944
998
|
* Which version of a config dependency a replay actually loaded, and where
|
|
945
999
|
* that version came from.
|
|
@@ -949,7 +1003,9 @@ type HookReplaySource = "installed" | "store" | "supplied";
|
|
|
949
1003
|
* WHAT the hooks injected but which pinned version injected it — the
|
|
950
1004
|
* evidence that a "range moved" row came from a config-dependency bump and
|
|
951
1005
|
* not from a hook edit. `installed` is the `node_modules/.pnpm-config` copy,
|
|
952
|
-
* `store` the pnpm store's `links/` tree, `
|
|
1006
|
+
* `store` the pnpm store's `links/` tree, `fetched` the store copy
|
|
1007
|
+
* {@link ConfigDependencyHooks.layerSubprocess} fetched, verified, because
|
|
1008
|
+
* neither held the declared version, and `supplied` a
|
|
953
1009
|
* {@link ConfigDependencyHooks.layerFrom} entry.
|
|
954
1010
|
*
|
|
955
1011
|
* @public
|
|
@@ -1048,8 +1104,12 @@ interface ConfigDependencyHooksShape {
|
|
|
1048
1104
|
* threaded config so hooks merge onto them rather than replacing them.
|
|
1049
1105
|
* Omitted means "the workspace file declares none", which is different
|
|
1050
1106
|
* from "nobody looked" — the caller owns that distinction.
|
|
1107
|
+
* @param context - What the declaring side recorded beyond the map: its
|
|
1108
|
+
* lockfile, which verifies a fetched version, and its ref, which names it
|
|
1109
|
+
* in errors. Only {@link ConfigDependencyHooks.layerSubprocess} reads the
|
|
1110
|
+
* lockfile; omitting the context never changes which version is replayed.
|
|
1051
1111
|
*/
|
|
1052
|
-
readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string>>>>, rules?: PeerDependencyRules) => Effect.Effect<HookInjection, CatalogAssemblyError>;
|
|
1112
|
+
readonly inject: (root: string, configDependencies: Readonly<Record<string, string>>, seed: Readonly<Record<string, Readonly<Record<string, string>>>>, rules?: PeerDependencyRules, context?: HookReplayContext) => Effect.Effect<HookInjection, CatalogAssemblyError>;
|
|
1053
1113
|
}
|
|
1054
1114
|
declare const ConfigDependencyHooks_base: Context.ServiceClass<ConfigDependencyHooks, "@effected/workspaces/ConfigDependencyHooks", ConfigDependencyHooksShape>;
|
|
1055
1115
|
/**
|
|
@@ -1100,9 +1160,10 @@ export declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
|
|
|
1100
1160
|
* `links/<name>/<declared>/*\/node_modules/<name>` (the store is located
|
|
1101
1161
|
* from `node_modules/.modules.yaml`, the realpath of any `.pnpm-config`
|
|
1102
1162
|
* entry, then `$PNPM_HOME` / `$XDG_DATA_HOME` / the platform default), else
|
|
1103
|
-
* a typed error naming the package, the
|
|
1104
|
-
* installed, the stores searched, and the
|
|
1105
|
-
* (`pnpm add --config <name>@<version>` in a throwaway
|
|
1163
|
+
* a typed error with `reason: "notInstalled"` naming the package, the
|
|
1164
|
+
* declared version, what is installed, the stores searched, and the
|
|
1165
|
+
* remediation (`pnpm add --config <name>@<version>` in a throwaway
|
|
1166
|
+
* workspace, or the subprocess layer, which fetches). In the
|
|
1106
1167
|
* chosen directory the first of `pnpmfile.mjs`, `pnpmfile.cjs`,
|
|
1107
1168
|
* `pnpmfile.js` present in one directory listing is loaded; a listed but
|
|
1108
1169
|
* unreadable file fails typed at import time, never as "ships no hook".
|
|
@@ -1174,9 +1235,24 @@ export declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
|
|
|
1174
1235
|
*
|
|
1175
1236
|
* The declared-version resolution is the PARENT's: the same ladder as
|
|
1176
1237
|
* `layerLive` (`.pnpm-config` when it holds the declared version, else the
|
|
1177
|
-
* pnpm store
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1238
|
+
* pnpm store) runs here, and the child receives the resolved
|
|
1239
|
+
* `[name, fileUrl]` pairs as one JSON argv argument.
|
|
1240
|
+
*
|
|
1241
|
+
* One rung more than `layerLive`: a version held nowhere is FETCHED into
|
|
1242
|
+
* the store rather than failing, so a diff whose base side declares a
|
|
1243
|
+
* version this checkout never installed still replays that version. pnpm
|
|
1244
|
+
* runs `install --frozen-lockfile` in a scratch workspace (removed
|
|
1245
|
+
* afterwards) whose lockfile pins the integrity the declaring side recorded
|
|
1246
|
+
* — the inline `<version>+<integrity>` spec, else that side's
|
|
1247
|
+
* `pnpm-lock.yaml` env preamble ({@link HookReplayContext.lockfile}) — so
|
|
1248
|
+
* pnpm itself refuses a download that does not match. It writes to the
|
|
1249
|
+
* first store the ladder searched, so the next replay finds the version
|
|
1250
|
+
* there. The replay records `source: "fetched"`. Fail-closed: two recorded
|
|
1251
|
+
* integrities that disagree fail with `reason: "integrityMismatch"`, none
|
|
1252
|
+
* with `reason: "integrityUnavailable"` — nothing is fetched in either case
|
|
1253
|
+
* — and a fetch that fails with `reason: "fetchFailed"`, keeping the
|
|
1254
|
+
* not-installed remediation. The fetch needs `pnpm` 11 or 12 on `PATH` and
|
|
1255
|
+
* registry access, and is bounded at two minutes. An empty `configDependencies`, or one whose dependencies all
|
|
1180
1256
|
* ship no pnpmfile, returns the seed without spawning anything; a `..` path
|
|
1181
1257
|
* segment in a dependency name fails typed **before** any spawn; a
|
|
1182
1258
|
* load failure in the child — a syntax error, a throwing top level, an
|
|
@@ -1210,6 +1286,157 @@ export declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
|
|
|
1210
1286
|
static readonly layerSubprocess: Layer.Layer<ConfigDependencyHooks, never, ChildProcessSpawner.ChildProcessSpawner>;
|
|
1211
1287
|
}
|
|
1212
1288
|
//#endregion
|
|
1289
|
+
//#region src/ConfigDependencySpec.d.ts
|
|
1290
|
+
declare const InvalidConfigDependencySpecError_base: Schema.Class<InvalidConfigDependencySpecError, Schema.TaggedStruct<"InvalidConfigDependencySpecError", {
|
|
1291
|
+
/** The raw spec string that failed validation. */
|
|
1292
|
+
readonly input: Schema.String;
|
|
1293
|
+
/**
|
|
1294
|
+
* Which half of the spec failed: `version` (the text before the first
|
|
1295
|
+
* `+` is not an exact SemVer version — ranges, partial versions,
|
|
1296
|
+
* dist-tags and padded values all land here) or `integrity` (the text
|
|
1297
|
+
* after the first `+` is not an SRI `<algo>-<base64>` hash).
|
|
1298
|
+
*/
|
|
1299
|
+
readonly reason: Schema.Literals<readonly ["version", "integrity"]>;
|
|
1300
|
+
}>, import("effect/Cause").YieldableError>;
|
|
1301
|
+
/**
|
|
1302
|
+
* Indicates that a string could not be parsed as a pnpm `configDependencies`
|
|
1303
|
+
* spec (`<version>[+<integrity>]`).
|
|
1304
|
+
*
|
|
1305
|
+
* Raised by {@link ConfigDependencySpec.parse} and
|
|
1306
|
+
* {@link ConfigDependencySpec.parseResult}; the decode direction of
|
|
1307
|
+
* {@link ConfigDependencySpec.FromString} reports the same failure through a
|
|
1308
|
+
* generic `Schema` parse error carrying the same message. The offending string
|
|
1309
|
+
* is preserved on `input`; `reason` says which half failed.
|
|
1310
|
+
*
|
|
1311
|
+
* @public
|
|
1312
|
+
*/
|
|
1313
|
+
export declare class InvalidConfigDependencySpecError extends InvalidConfigDependencySpecError_base {
|
|
1314
|
+
get message(): string;
|
|
1315
|
+
}
|
|
1316
|
+
declare const ConfigDependencySpec_base: Schema.Class<ConfigDependencySpec, Schema.Struct<{
|
|
1317
|
+
/**
|
|
1318
|
+
* The exact declared version. Never carries build metadata — the spec
|
|
1319
|
+
* grammar cannot express it (see the class remarks), and a version
|
|
1320
|
+
* constructed with build identifiers is rejected at construction.
|
|
1321
|
+
*/
|
|
1322
|
+
readonly version: Schema.decodeTo<Schema.declareConstructor<SemVer, {
|
|
1323
|
+
readonly major: number;
|
|
1324
|
+
readonly minor: number;
|
|
1325
|
+
readonly patch: number;
|
|
1326
|
+
readonly prerelease: readonly (string | number)[];
|
|
1327
|
+
readonly build: readonly string[];
|
|
1328
|
+
}, readonly [Schema.Struct<{
|
|
1329
|
+
readonly major: Schema.Number;
|
|
1330
|
+
readonly minor: Schema.Number;
|
|
1331
|
+
readonly patch: Schema.Number;
|
|
1332
|
+
readonly prerelease: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Number]>>;
|
|
1333
|
+
readonly build: Schema.$Array<Schema.String>;
|
|
1334
|
+
}>], {
|
|
1335
|
+
readonly major: number;
|
|
1336
|
+
readonly minor: number;
|
|
1337
|
+
readonly patch: number;
|
|
1338
|
+
readonly prerelease: readonly (string | number)[];
|
|
1339
|
+
readonly build: readonly string[];
|
|
1340
|
+
}>, Schema.Struct<{
|
|
1341
|
+
readonly major: Schema.Number;
|
|
1342
|
+
readonly minor: Schema.Number;
|
|
1343
|
+
readonly patch: Schema.Number;
|
|
1344
|
+
readonly prerelease: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Number]>>;
|
|
1345
|
+
readonly build: Schema.$Array<Schema.String>;
|
|
1346
|
+
}>, never, never>;
|
|
1347
|
+
/**
|
|
1348
|
+
* The inline integrity (e.g. `sha512-m35m…==`): `@effected/npm`'s
|
|
1349
|
+
* `SriIntegrityHash`, the shared restriction of the `IntegrityHash` brand
|
|
1350
|
+
* to the SRI `<algo>-<base64>` form. `None` on the bare form pnpm 11+
|
|
1351
|
+
* writes.
|
|
1352
|
+
*/
|
|
1353
|
+
readonly integrity: Schema.Option<Schema.brand<Schema.String, "IntegrityHash">>;
|
|
1354
|
+
}>, {}>;
|
|
1355
|
+
/**
|
|
1356
|
+
* A pnpm `configDependencies` spec from `pnpm-workspace.yaml`: an exact
|
|
1357
|
+
* `version` and, on the legacy inline form only, an SRI `integrity`.
|
|
1358
|
+
*
|
|
1359
|
+
* @remarks
|
|
1360
|
+
* pnpm 11 and later write the **bare** form — `pnpm add --config` records
|
|
1361
|
+
* `0.11.1`, and the integrity lives in the lockfile. The inline form
|
|
1362
|
+
* `0.11.1+sha512-<base64>` is deprecated but still read, and workspaces in the
|
|
1363
|
+
* wild still carry it. {@link ConfigDependencySpec.bare} renders the bare form
|
|
1364
|
+
* (a tool normalizing the field writes this); `toString()` re-renders the form
|
|
1365
|
+
* that was parsed, so a tool that must not rewrite the field round-trips it
|
|
1366
|
+
* byte-identically.
|
|
1367
|
+
*
|
|
1368
|
+
* The first `+` always begins the integrity, never semver build metadata, so
|
|
1369
|
+
* `0.11.1+sha512-…` is version `0.11.1` plus integrity `sha512-…`; a tail that
|
|
1370
|
+
* is not an SRI hash fails with `reason: "integrity"` rather than falling back
|
|
1371
|
+
* to build-metadata parsing.
|
|
1372
|
+
*
|
|
1373
|
+
* `version` is `@effected/semver`'s `SemVer`: prerelease versions are
|
|
1374
|
+
* accepted, ranges (`^0.11.1`), partial versions, dist-tags, a `v` prefix and
|
|
1375
|
+
* padded values are not.
|
|
1376
|
+
*
|
|
1377
|
+
* @example
|
|
1378
|
+
* ```ts
|
|
1379
|
+
* import { ConfigDependencySpec } from "@effected/workspaces";
|
|
1380
|
+
* import { Effect } from "effect";
|
|
1381
|
+
*
|
|
1382
|
+
* const program = Effect.gen(function* () {
|
|
1383
|
+
* const spec = yield* ConfigDependencySpec.parse("0.11.1+sha512-m35m…==");
|
|
1384
|
+
* return [spec.bare, spec.hasIntegrity] as const; // ["0.11.1", true]
|
|
1385
|
+
* });
|
|
1386
|
+
* ```
|
|
1387
|
+
*
|
|
1388
|
+
* @public
|
|
1389
|
+
*/
|
|
1390
|
+
export declare class ConfigDependencySpec extends ConfigDependencySpec_base {
|
|
1391
|
+
/**
|
|
1392
|
+
* Schema transformation between the `<version>[+<integrity>]` string and a
|
|
1393
|
+
* {@link ConfigDependencySpec}. Decoding parses via
|
|
1394
|
+
* {@link ConfigDependencySpec.parseResult} (the surfaces share one grammar);
|
|
1395
|
+
* encoding prints `toString()`, the form that was parsed.
|
|
1396
|
+
*/
|
|
1397
|
+
static readonly FromString: Schema.Codec<ConfigDependencySpec, string>;
|
|
1398
|
+
/**
|
|
1399
|
+
* Parse a `configDependencies` spec, synchronously, returning a `Result`
|
|
1400
|
+
* instead of an `Effect`.
|
|
1401
|
+
*
|
|
1402
|
+
* @remarks
|
|
1403
|
+
* The text before the first `+` must be an exact SemVer version that
|
|
1404
|
+
* renders back to itself byte for byte (`SemVer.isPinnable`, plus a
|
|
1405
|
+
* canonical-spelling check so `toString()` round-trips); the text after it,
|
|
1406
|
+
* when present, must be an SRI `<algo>-<base64>` hash. An empty tail
|
|
1407
|
+
* (`0.11.1+`) is an integrity failure, not a bare spec.
|
|
1408
|
+
* {@link ConfigDependencySpec.parse} is defined in terms of this function.
|
|
1409
|
+
*
|
|
1410
|
+
* @param input - the spec string to parse
|
|
1411
|
+
* @returns a `Result` succeeding with the parsed {@link ConfigDependencySpec},
|
|
1412
|
+
* or failing with {@link InvalidConfigDependencySpecError} naming the half
|
|
1413
|
+
* that failed.
|
|
1414
|
+
*/
|
|
1415
|
+
static parseResult(input: string): Result.Result<ConfigDependencySpec, InvalidConfigDependencySpecError>;
|
|
1416
|
+
/**
|
|
1417
|
+
* Parse a `configDependencies` spec. Defined in terms of
|
|
1418
|
+
* {@link ConfigDependencySpec.parseResult} — synchronous callers can use
|
|
1419
|
+
* that variant directly.
|
|
1420
|
+
*
|
|
1421
|
+
* @param input - the spec string to parse
|
|
1422
|
+
* @returns the parsed {@link ConfigDependencySpec}. Fails with
|
|
1423
|
+
* {@link InvalidConfigDependencySpecError} when `input` is not a valid spec.
|
|
1424
|
+
*/
|
|
1425
|
+
static readonly parse: (input: string) => Effect.Effect<ConfigDependencySpec, InvalidConfigDependencySpecError, never>;
|
|
1426
|
+
/** Whether the spec carries an inline integrity (the legacy form). */
|
|
1427
|
+
get hasIntegrity(): boolean;
|
|
1428
|
+
/**
|
|
1429
|
+
* The bare `<version>` form — what pnpm 11+ writes to `configDependencies`
|
|
1430
|
+
* — with any inline integrity dropped.
|
|
1431
|
+
*/
|
|
1432
|
+
get bare(): string;
|
|
1433
|
+
/**
|
|
1434
|
+
* The spec as parsed: `<version>` or `<version>+<integrity>`. The encode
|
|
1435
|
+
* direction of {@link ConfigDependencySpec.FromString} prints exactly this.
|
|
1436
|
+
*/
|
|
1437
|
+
toString(): string;
|
|
1438
|
+
}
|
|
1439
|
+
//#endregion
|
|
1213
1440
|
//#region src/DependencyGraph.d.ts
|
|
1214
1441
|
declare const CyclicDependencyError_base: Schema.Class<CyclicDependencyError, Schema.TaggedStruct<"CyclicDependencyError", {
|
|
1215
1442
|
/** The packages participating in the cycle. */
|
|
@@ -4312,5 +4539,5 @@ interface GetWorkspacePackagesSyncOptions extends WorkspacesSyncOptions {
|
|
|
4312
4539
|
*/
|
|
4313
4540
|
export declare const getWorkspacePackagesSync: (root: string, options: GetWorkspacePackagesSyncOptions) => ReadonlyArray<WorkspacePackage>;
|
|
4314
4541
|
//#endregion
|
|
4315
|
-
export type { CatalogAssemblyFailure, ChangeDetectionFailure, ChangeDetectorShape, ClassifyOptions, ConfigDependencyHooksShape, DependencyDiff, DuplicateCheckOptions, FindWorkspaceRootOptions, GetWorkspacePackagesSyncOptions, HookInjection, HookReplay, HookReplaySource, ImporterVersions, LockfileReadFailure, LockfileReaderOptions, LockfileReaderShape, PackageManagerDetectionFailure, PackageManagerDetectorShape, PackageRelease, PeerCheckOptions, PeerDependencyRules, PublishabilityDetectorShape, SyncDirectoryEntry, SyncFileSystem, SyncPath, TagClassification, TagFormatOptions, TrackingTagOptions, UnverifiedReason, VersioningDetectOptions, WorkspaceCatalogsOptions, WorkspaceCatalogsShape, WorkspaceDiscoveryFailure, WorkspaceDiscoveryOptions, WorkspaceDiscoveryShape, WorkspaceDiscoverySkip, WorkspaceDiscoverySkipKind, WorkspaceLookupFailure, WorkspaceRootShape, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshotsOptions, WorkspaceSnapshotsShape, WorkspacesGitOptions, WorkspacesOptions, WorkspacesServices, WorkspacesSyncOptions };
|
|
4542
|
+
export type { CatalogAssemblyFailure, ChangeDetectionFailure, ChangeDetectorShape, ClassifyOptions, ConfigDependencyHooksShape, DependencyDiff, DuplicateCheckOptions, FindWorkspaceRootOptions, GetWorkspacePackagesSyncOptions, HookInjection, HookReplay, HookReplayContext, HookReplaySource, ImporterVersions, LockfileReadFailure, LockfileReaderOptions, LockfileReaderShape, PackageManagerDetectionFailure, PackageManagerDetectorShape, PackageRelease, PeerCheckOptions, PeerDependencyRules, PublishabilityDetectorShape, SyncDirectoryEntry, SyncFileSystem, SyncPath, TagClassification, TagFormatOptions, TrackingTagOptions, UnverifiedReason, VersioningDetectOptions, WorkspaceCatalogsOptions, WorkspaceCatalogsShape, WorkspaceDiscoveryFailure, WorkspaceDiscoveryOptions, WorkspaceDiscoveryShape, WorkspaceDiscoverySkip, WorkspaceDiscoverySkipKind, WorkspaceLookupFailure, WorkspaceRootShape, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshotsOptions, WorkspaceSnapshotsShape, WorkspacesGitOptions, WorkspacesOptions, WorkspacesServices, WorkspacesSyncOptions };
|
|
4316
4543
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { PackageNotFoundError, WorkspaceDiscovery, WorkspaceDiscoveryError, Work
|
|
|
4
4
|
import { CyclicDependencyError, DependencyGraph } from "./DependencyGraph.js";
|
|
5
5
|
import { ChangeDetectionError, ChangeDetectionOptions, ChangeDetector } from "./ChangeDetector.js";
|
|
6
6
|
import { ConfigDependencyHooks, NoPeerDependencyRules } from "./ConfigDependencyHooks.js";
|
|
7
|
+
import { ConfigDependencySpec, InvalidConfigDependencySpecError } from "./ConfigDependencySpec.js";
|
|
7
8
|
import { Dependent, DuplicateCheck, DuplicateInstance, DuplicatedPackage, DuplicatedVersion } from "./DuplicateCheck.js";
|
|
8
9
|
import { DetectedPackageManager, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName } from "./PackageManagerName.js";
|
|
9
10
|
import { LockfileReadError, LockfileReader } from "./LockfileReader.js";
|
|
@@ -17,4 +18,4 @@ import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
|
|
|
17
18
|
import { Workspaces } from "./Workspaces.js";
|
|
18
19
|
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "./WorkspacesSync.js";
|
|
19
20
|
|
|
20
|
-
export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, CyclicDependencyError, DependencyGraph, Dependent, DetectedPackageManager, DuplicateCheck, DuplicateInstance, DuplicatedPackage, DuplicatedVersion, LockfileReadError, LockfileReader, NoPeerDependencyRules, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PeerCheck, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, UnsatisfiedPeer, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
|
21
|
+
export { CatalogSet, ChangeDetectionError, ChangeDetectionOptions, ChangeDetector, ConfigDependencyHooks, ConfigDependencySpec, CyclicDependencyError, DependencyGraph, Dependent, DetectedPackageManager, DuplicateCheck, DuplicateInstance, DuplicatedPackage, DuplicatedVersion, InvalidConfigDependencySpecError, LockfileReadError, LockfileReader, NoPeerDependencyRules, PackageManagerDetectionError, PackageManagerDetector, PackageManagerEvidence, PackageManagerName, PackageNotFoundError, PackageStateSnapshot, PeerCheck, PeerParent, PublishConfig, PublishTarget, PublishabilityDetector, ReleaseTag, TagStyle, TrackingTag, UnsatisfiedPeer, VersioningStrategy, VersioningStrategyType, WORKSPACE_MARKERS, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspaceManifestError, WorkspacePackage, WorkspacePatternError, WorkspaceRoot, WorkspaceRootNotFoundError, WorkspaceSnapshots, WorkspaceStateSnapshot, Workspaces, classifyTag, findWorkspaceRootSync, getWorkspacePackagesSync };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { carries, manifestVersion, messageOf, sideLabel } from "./configDependencyShared.js";
|
|
2
|
+
import { splitConfigDependencySpec } from "./configDependencySpecGrammar.js";
|
|
3
|
+
import { Duration, Effect } from "effect";
|
|
4
|
+
import { IntegrityHash } from "@effected/npm";
|
|
5
|
+
import { Run } from "@effected/commands";
|
|
6
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
7
|
+
import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
//#region src/internal/configDependencyFetch.ts
|
|
12
|
+
const fetchFailure = (reason, message, cause) => cause === void 0 ? {
|
|
13
|
+
reason,
|
|
14
|
+
message
|
|
15
|
+
} : {
|
|
16
|
+
reason,
|
|
17
|
+
message,
|
|
18
|
+
cause
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The ceiling on one fetch. It downloads one small tarball; two minutes covers
|
|
22
|
+
* a slow registry without letting a hung pnpm stall catalog assembly forever.
|
|
23
|
+
*/
|
|
24
|
+
const FETCH_TIMEOUT = Duration.minutes(2);
|
|
25
|
+
/**
|
|
26
|
+
* The integrity the fetched copy must match, decided before anything is
|
|
27
|
+
* spawned: the inline spec integrity (legacy) and the declaring side's
|
|
28
|
+
* lockfile preamble entry for exactly `<name>@<version>`. Both present and
|
|
29
|
+
* different, neither present, an inline integrity that is not SRI, or a
|
|
30
|
+
* lockfile that cannot be read all fail typed. A lockfile that records the
|
|
31
|
+
* name at a different version is not a source for this version.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* An unreadable lockfile fails closed even when the spec carries an inline
|
|
35
|
+
* integrity: the lockfile is the declaring side's own checksum store, and a
|
|
36
|
+
* store that cannot be read cannot be confirmed to agree with the inline pin.
|
|
37
|
+
*/
|
|
38
|
+
const expectedIntegrity = (request) => Effect.gen(function* () {
|
|
39
|
+
const { name, version, side } = request;
|
|
40
|
+
const key = `${name}@${version}`;
|
|
41
|
+
const inline = splitConfigDependencySpec(request.spec).integrity;
|
|
42
|
+
if (inline !== void 0 && !IntegrityHash.isSri(inline)) return yield* Effect.fail(fetchFailure("integrityUnavailable", `config dependency ${key} declared by ${sideLabel(side)} carries an inline integrity that is not an SRI hash, so a fetched copy cannot be verified and nothing was fetched.`));
|
|
43
|
+
const lock = (yield* request.locks.pipe(Effect.mapError((cause) => fetchFailure("integrityUnavailable", `config dependency ${key} must be fetched, but the pnpm-lock.yaml of ${sideLabel(side)} cannot be read (${messageOf(cause)}), so its recorded integrity cannot be checked and nothing was fetched.`, cause))))?.get(name);
|
|
44
|
+
const locked = lock !== void 0 && lock.version === version ? lock.integrity : void 0;
|
|
45
|
+
if (inline !== void 0 && locked !== void 0 && inline !== locked) return yield* Effect.fail(fetchFailure("integrityMismatch", `config dependency ${key} declared by ${sideLabel(side)} has two different recorded integrities: pnpm-workspace.yaml pins ${inline} and pnpm-lock.yaml records ${locked}. Nothing was fetched; reconcile the two (a fresh \`pnpm install\` rewrites the lockfile) before retrying.`));
|
|
46
|
+
const integrity = inline ?? locked;
|
|
47
|
+
if (integrity === void 0) return yield* Effect.fail(fetchFailure("integrityUnavailable", `config dependency ${key} must be fetched, but ${sideLabel(side)} records no integrity for it (no inline integrity in pnpm-workspace.yaml and no pnpm-lock.yaml entry for that version), so a fetched copy could not be verified and nothing was fetched.`));
|
|
48
|
+
return integrity;
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* The scratch workspace's `pnpm-workspace.yaml`. Every scalar is a
|
|
52
|
+
* JSON-quoted string, which YAML reads verbatim, so no declared text can
|
|
53
|
+
* change the document's structure.
|
|
54
|
+
*/
|
|
55
|
+
const scratchWorkspaceYaml = (name, version) => `configDependencies:\n ${JSON.stringify(name)}: ${JSON.stringify(version)}\n`;
|
|
56
|
+
/**
|
|
57
|
+
* The scratch workspace's `pnpm-lock.yaml`: the env preamble pnpm 11 and 12
|
|
58
|
+
* write for a lone config dependency, pinned to `integrity`, followed by the
|
|
59
|
+
* empty lockfile document they write when there are no projects.
|
|
60
|
+
*/
|
|
61
|
+
const scratchLockfile = (name, version, integrity) => {
|
|
62
|
+
const key = JSON.stringify(`${name}@${version}`);
|
|
63
|
+
return [
|
|
64
|
+
"---",
|
|
65
|
+
"lockfileVersion: '9.0'",
|
|
66
|
+
"",
|
|
67
|
+
"importers:",
|
|
68
|
+
"",
|
|
69
|
+
" .:",
|
|
70
|
+
" configDependencies:",
|
|
71
|
+
` ${JSON.stringify(name)}:`,
|
|
72
|
+
` specifier: ${JSON.stringify(version)}`,
|
|
73
|
+
` version: ${JSON.stringify(version)}`,
|
|
74
|
+
"",
|
|
75
|
+
"packages:",
|
|
76
|
+
"",
|
|
77
|
+
` ${key}:`,
|
|
78
|
+
` resolution: {integrity: ${JSON.stringify(integrity)}}`,
|
|
79
|
+
"",
|
|
80
|
+
"snapshots:",
|
|
81
|
+
"",
|
|
82
|
+
` ${key}: {}`,
|
|
83
|
+
"",
|
|
84
|
+
"---",
|
|
85
|
+
""
|
|
86
|
+
].join("\n");
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The `--store-dir` to hand pnpm so it writes into the store the ladder
|
|
90
|
+
* searched first. Discovered stores are the versioned directories
|
|
91
|
+
* (`<root>/v11`), and pnpm appends that segment itself, so a versioned
|
|
92
|
+
* directory is passed as its parent. `undefined` (pnpm's own default) when no
|
|
93
|
+
* store was discovered.
|
|
94
|
+
*/
|
|
95
|
+
const storeDirArgument = (stores) => {
|
|
96
|
+
const first = stores[0];
|
|
97
|
+
if (first === void 0) return void 0;
|
|
98
|
+
return /^v\d+$/.test(basename(first)) ? dirname(first) : first;
|
|
99
|
+
};
|
|
100
|
+
/** The pnpm argv for the verified fetch. */
|
|
101
|
+
const fetchArgs = (scratch, stores) => {
|
|
102
|
+
const storeDir = storeDirArgument(stores);
|
|
103
|
+
return [
|
|
104
|
+
"install",
|
|
105
|
+
"--frozen-lockfile",
|
|
106
|
+
"--dir",
|
|
107
|
+
scratch,
|
|
108
|
+
...storeDir === void 0 ? [] : ["--store-dir", storeDir]
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
/** Whether `resolved` sits under a store's `links/<name>/<version>/` tree. */
|
|
112
|
+
const isStoreEntry = (resolved, name, version) => resolved.split(/[/\\]/).join("/").includes(`/links/${name}/${version}/`);
|
|
113
|
+
/**
|
|
114
|
+
* Build the fetch rung over a spawner. Wired only by
|
|
115
|
+
* `ConfigDependencyHooks.layerSubprocess`.
|
|
116
|
+
*/
|
|
117
|
+
const makeFetchConfigDependency = (spawner) => (request) => Effect.scoped(Effect.gen(function* () {
|
|
118
|
+
const { name, version } = request;
|
|
119
|
+
const key = `${name}@${version}`;
|
|
120
|
+
const failed = (message, cause) => fetchFailure("fetchFailed", message, cause);
|
|
121
|
+
const io = (what, run) => Effect.tryPromise({
|
|
122
|
+
try: run,
|
|
123
|
+
catch: (cause) => failed(`${what} failed: ${messageOf(cause)}`, cause)
|
|
124
|
+
});
|
|
125
|
+
const integrity = yield* expectedIntegrity(request);
|
|
126
|
+
const scratch = yield* Effect.acquireRelease(io("creating a scratch workspace", () => mkdtemp(join(tmpdir(), "effected-config-dependency-"))), (dir) => Effect.promise(() => rm(dir, {
|
|
127
|
+
recursive: true,
|
|
128
|
+
force: true
|
|
129
|
+
}).catch(() => void 0)));
|
|
130
|
+
const lockfile = scratchLockfile(name, version, integrity);
|
|
131
|
+
yield* io("writing the scratch workspace", async () => {
|
|
132
|
+
await writeFile(join(scratch, "pnpm-workspace.yaml"), scratchWorkspaceYaml(name, version));
|
|
133
|
+
await writeFile(join(scratch, "pnpm-lock.yaml"), lockfile);
|
|
134
|
+
});
|
|
135
|
+
const command = ChildProcess.make("pnpm", fetchArgs(scratch, request.stores), { cwd: scratch });
|
|
136
|
+
yield* Run.text(command, { timeout: FETCH_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError((cause) => failed(`fetching ${key} with \`pnpm ${fetchArgs("<scratch>", request.stores).join(" ")}\` failed: ${messageOf(cause)}`, cause)));
|
|
137
|
+
if ((yield* io("reading back the scratch lockfile", () => readFile(join(scratch, "pnpm-lock.yaml"), "utf8"))) !== lockfile) return yield* Effect.fail(failed(`fetching ${key}: pnpm rewrote the integrity-pinned scratch lockfile instead of installing from it, so the installed copy was not verified against the recorded integrity and is not used.`));
|
|
138
|
+
const linked = join(scratch, "node_modules", ".pnpm-config", name);
|
|
139
|
+
const resolved = yield* io(`resolving ${linked}`, () => realpath(linked));
|
|
140
|
+
const manifest = yield* manifestVersion(name, resolved).pipe(Effect.mapError((error) => failed(`the fetched ${key} has an unreadable package.json: ${messageOf(error.cause)}`, error.cause)));
|
|
141
|
+
if (!carries(manifest, version) || !isStoreEntry(resolved, name, version)) return yield* Effect.fail(failed(`fetching ${key}: pnpm linked ${resolved}, which is not a store copy of that version (its manifest reads ${manifest._tag === "version" ? manifest.version : "no version"}).`));
|
|
142
|
+
return resolved;
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { expectedIntegrity, fetchArgs, makeFetchConfigDependency, storeDirArgument };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { carries, hooksError, ioOrNone, manifestVersion, sideLabel } from "./configDependencyShared.js";
|
|
2
|
+
import { splitConfigDependencySpec } from "./configDependencySpecGrammar.js";
|
|
1
3
|
import { Array, Duration, Effect, Exit, Option, Predicate, Result } from "effect";
|
|
2
|
-
import {
|
|
4
|
+
import { PackageManagerCache } from "@effected/npm";
|
|
3
5
|
import { Yaml } from "@effected/yaml";
|
|
6
|
+
import { PnpmEnvLockfile } from "@effected/lockfiles";
|
|
4
7
|
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
5
8
|
import { homedir } from "node:os";
|
|
6
9
|
import { basename, dirname, join } from "node:path";
|
|
@@ -15,11 +18,13 @@ const PNPMFILE_CANDIDATES = [
|
|
|
15
18
|
/**
|
|
16
19
|
* The version part of a `configDependencies` spec: the text before the first
|
|
17
20
|
* `+` (`0.9.0+sha512-…` → `0.9.0`); a bare `0.9.0` is returned whole.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately lenient — it goes through the same split as the public
|
|
23
|
+
* `ConfigDependencySpec` but validates neither half. See
|
|
24
|
+
* `configDependencySpecGrammar.ts` for why replay must not adopt the strict
|
|
25
|
+
* model's rejections.
|
|
18
26
|
*/
|
|
19
|
-
const declaredVersionOf = (spec) =>
|
|
20
|
-
const plus = spec.indexOf("+");
|
|
21
|
-
return plus === -1 ? spec : spec.slice(0, plus);
|
|
22
|
-
};
|
|
27
|
+
const declaredVersionOf = (spec) => splitConfigDependencySpec(spec).version;
|
|
23
28
|
/**
|
|
24
29
|
* Whether a config-dependency `name` carries a `..` path segment. A scoped name
|
|
25
30
|
* legitimately contains `/` (`@scope/pkg`), so only a `..` *segment* is rejected —
|
|
@@ -27,12 +32,6 @@ const declaredVersionOf = (spec) => {
|
|
|
27
32
|
* dynamic `import()`.
|
|
28
33
|
*/
|
|
29
34
|
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
35
|
/**
|
|
37
36
|
* The declared `(name, version)` pairs of a `configDependencies` map, in
|
|
38
37
|
* declaration order, with EVERY name validated before any path is built from
|
|
@@ -42,36 +41,12 @@ const hooksError = (path, cause) => new CatalogAssemblyError({
|
|
|
42
41
|
const declaredEntries = (configDependencies) => {
|
|
43
42
|
const entries = Object.entries(configDependencies).map(([name, spec]) => ({
|
|
44
43
|
name,
|
|
45
|
-
version: declaredVersionOf(spec)
|
|
44
|
+
version: declaredVersionOf(spec),
|
|
45
|
+
spec
|
|
46
46
|
}));
|
|
47
47
|
const traversal = entries.find((entry) => hasTraversalSegment(entry.name));
|
|
48
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
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
50
|
/** Directory entries of `dir`, or `[]` when it is absent; typed on any other failure. */
|
|
76
51
|
const entriesOf = (path, dir) => ioOrNone(path, () => readdir(dir)).pipe(Effect.map(Option.getOrElse(() => [])));
|
|
77
52
|
/** The `<root>/node_modules/.pnpm-config` directory. */
|
|
@@ -206,7 +181,7 @@ const findInStores = (name, declared, stores) => Effect.gen(function* () {
|
|
|
206
181
|
for (const hash of yield* entriesOf(name, versionDir)) {
|
|
207
182
|
const dir = join(versionDir, hash, "node_modules", name);
|
|
208
183
|
const version = yield* manifestVersion(name, dir);
|
|
209
|
-
if (
|
|
184
|
+
if (carries(version, declared)) matches.push(dir);
|
|
210
185
|
}
|
|
211
186
|
if (matches.length > 0) return {
|
|
212
187
|
store,
|
|
@@ -220,12 +195,23 @@ const findInStores = (name, declared, stores) => Effect.gen(function* () {
|
|
|
220
195
|
});
|
|
221
196
|
/** The fail-closed message for a version ONE store holds more than once: which store, which copies, how to disambiguate. */
|
|
222
197
|
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
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The fail-closed message: what was declared, by which side, what is
|
|
200
|
+
* installed, where we looked, why that happens, and how to fix it. `fetch`
|
|
201
|
+
* is the fetch rung's failure when one ran.
|
|
202
|
+
*/
|
|
203
|
+
const notInstalledMessage = (name, declared, installed, stores, side, fetch) => {
|
|
204
|
+
const holds = installed._tag === "absent" ? "nothing" : installed._tag === "unversioned" ? "a package with no version" : `version ${installed.version}`;
|
|
205
|
+
const searched = stores.length === 0 ? "no pnpm store directory could be located" : `searched ${stores.join(", ")}`;
|
|
206
|
+
return [
|
|
207
|
+
`config dependency ${name}@${declared}${side.ref === void 0 ? "" : ` (declared at ${sideLabel(side)})`} is not installed: node_modules/.pnpm-config/${name} holds ${holds}, and no store copy of ${name}@${declared} was found (${searched}).`,
|
|
208
|
+
installed._tag === "version" && installed.version !== declared ? "This side declares a different version from the installed one, as the base side of a diff across a config-dependency bump does: that version was never installed in this checkout." : "",
|
|
209
|
+
Option.match(fetch, {
|
|
210
|
+
onNone: () => "This replay layer does not fetch; ConfigDependencyHooks.layerSubprocess, which the *Subprocess Workspaces composites use, fetches the declared version, verified, into the store.",
|
|
211
|
+
onSome: (failure) => failure.reason === "fetchFailed" ? `Fetching it into the store failed: ${failure.message}` : failure.message
|
|
212
|
+
}),
|
|
213
|
+
Option.exists(fetch, (failure) => failure.reason === "integrityMismatch") ? "" : `Run \`pnpm add --config ${name}@${declared}\` in a throwaway workspace to populate the store, then retry.`
|
|
214
|
+
].filter((sentence) => sentence !== "").join(" ");
|
|
229
215
|
};
|
|
230
216
|
/**
|
|
231
217
|
* The first pnpmfile candidate present in `dir`, from ONE directory listing.
|
|
@@ -238,14 +224,26 @@ const pnpmfileIn = (name, dir) => entriesOf(name, dir).pipe(Effect.map((entries)
|
|
|
238
224
|
/**
|
|
239
225
|
* Resolve one declared config dependency to the directory holding its
|
|
240
226
|
* declared version: `.pnpm-config` when it holds exactly that version, else
|
|
241
|
-
* the store, else
|
|
242
|
-
* discovery, run at most once per
|
|
243
|
-
* the first `.pnpm-config` miss
|
|
227
|
+
* the store, else the fetch rung when one is wired, else a typed fail-closed
|
|
228
|
+
* error. `stores` is the memoized discovery, run at most once per
|
|
229
|
+
* {@link resolvePnpmfiles} call and only on the first `.pnpm-config` miss;
|
|
230
|
+
* `locks` is the declaring side's memoized lockfile decode, run at most once
|
|
231
|
+
* per call and only by the fetch rung.
|
|
232
|
+
*
|
|
233
|
+
* @remarks
|
|
234
|
+
* A fetch failure is folded into the not-installed message, so the error
|
|
235
|
+
* still says what was declared, what is installed and where the ladder
|
|
236
|
+
* looked, and it keeps the fetch rung's own `reason` (`integrityMismatch`,
|
|
237
|
+
* `integrityUnavailable`, `fetchFailed`). The `pnpm add --config`
|
|
238
|
+
* remediation is kept except for `integrityMismatch`, where the pins must be
|
|
239
|
+
* reconciled first.
|
|
244
240
|
*/
|
|
245
|
-
const resolveDirectory = (root,
|
|
241
|
+
const resolveDirectory = (root, entry, stores, locks, options) => Effect.gen(function* () {
|
|
242
|
+
const { name, version: declared } = entry;
|
|
243
|
+
const side = options.side ?? {};
|
|
246
244
|
const installedDir = join(pnpmConfigDir(root), name);
|
|
247
245
|
const installed = yield* manifestVersion(name, installedDir);
|
|
248
|
-
if (
|
|
246
|
+
if (carries(installed, declared)) return {
|
|
249
247
|
dir: installedDir,
|
|
250
248
|
source: "installed"
|
|
251
249
|
};
|
|
@@ -256,23 +254,51 @@ const resolveDirectory = (root, name, declared, stores) => Effect.gen(function*
|
|
|
256
254
|
dir: only,
|
|
257
255
|
source: "store"
|
|
258
256
|
};
|
|
259
|
-
if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches))));
|
|
260
|
-
|
|
257
|
+
if (only !== void 0) return yield* Effect.fail(hooksError(name, new Error(ambiguousMessage(name, declared, store, matches)), "ambiguous"));
|
|
258
|
+
const notInstalled = (fetch) => {
|
|
259
|
+
const message = notInstalledMessage(name, declared, installed, searched, side, fetch);
|
|
260
|
+
const cause = Option.getOrUndefined(fetch)?.cause;
|
|
261
|
+
return hooksError(name, cause === void 0 ? new Error(message) : new Error(message, { cause }), Option.match(fetch, {
|
|
262
|
+
onNone: () => "notInstalled",
|
|
263
|
+
onSome: (failure) => failure.reason
|
|
264
|
+
}));
|
|
265
|
+
};
|
|
266
|
+
if (options.fetch === void 0) return yield* Effect.fail(notInstalled(Option.none()));
|
|
267
|
+
return {
|
|
268
|
+
dir: yield* options.fetch({
|
|
269
|
+
name,
|
|
270
|
+
version: declared,
|
|
271
|
+
spec: entry.spec,
|
|
272
|
+
stores: searched,
|
|
273
|
+
side,
|
|
274
|
+
locks
|
|
275
|
+
}).pipe(Effect.mapError((failure) => notInstalled(Option.some(failure)))),
|
|
276
|
+
source: "fetched"
|
|
277
|
+
};
|
|
261
278
|
});
|
|
262
279
|
/**
|
|
280
|
+
* A lazy memo of `effect`, scoped to one {@link resolvePnpmfiles} call: the
|
|
281
|
+
* house success-only memo (never `Effect.cached`, which would pin an
|
|
282
|
+
* interrupt), so a failed or interrupted run is retried by the next user.
|
|
283
|
+
*/
|
|
284
|
+
const memoizeSuccess = (effect) => Effect.map(Effect.cachedInvalidateWithTTL(effect, Duration.infinity), ([once, invalidate]) => Effect.onExit(once, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate));
|
|
285
|
+
/**
|
|
263
286
|
* Resolve every declared config dependency at its declared version, in
|
|
264
287
|
* declaration order. A dependency that ships no pnpmfile is still returned
|
|
265
288
|
* (resolved, `path` undefined — the one legitimate skip at replay time); a
|
|
266
289
|
* `..` segment in a name, an unresolvable declared version, or any
|
|
267
290
|
* non-absent IO failure fails typed as a `hooks`-source
|
|
268
|
-
* `CatalogAssemblyError`.
|
|
291
|
+
* `CatalogAssemblyError`. `options.fetch`, when wired, is tried for a version
|
|
292
|
+
* found nowhere before that fails.
|
|
269
293
|
*/
|
|
270
|
-
const resolvePnpmfiles = (root, configDependencies) => Effect.gen(function* () {
|
|
294
|
+
const resolvePnpmfiles = (root, configDependencies, options = {}) => Effect.gen(function* () {
|
|
271
295
|
const entries = yield* declaredEntries(configDependencies);
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
const stores = yield* memoizeSuccess(discoverStores(root));
|
|
297
|
+
const lockfile = options.side?.lockfile;
|
|
298
|
+
const locks = lockfile === void 0 ? Effect.succeed(void 0) : yield* memoizeSuccess(PnpmEnvLockfile.configDependencies(lockfile));
|
|
299
|
+
return yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
|
|
300
|
+
const { name, version } = entry;
|
|
301
|
+
const { dir, source } = yield* resolveDirectory(root, entry, stores, locks, options);
|
|
276
302
|
const pnpmfile = yield* pnpmfileIn(name, dir);
|
|
277
303
|
return {
|
|
278
304
|
name,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Effect, Option, Predicate } from "effect";
|
|
2
|
+
import { CatalogAssemblyError } from "@effected/npm";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/internal/configDependencyShared.ts
|
|
7
|
+
/** The typed `hooks`-source failure every rung of the ladder reports through. */
|
|
8
|
+
const hooksError = (path, cause, reason) => new CatalogAssemblyError({
|
|
9
|
+
source: "hooks",
|
|
10
|
+
path,
|
|
11
|
+
cause,
|
|
12
|
+
...reason === void 0 ? {} : { reason }
|
|
13
|
+
});
|
|
14
|
+
/** The message of a cause, for splicing into ours. */
|
|
15
|
+
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
16
|
+
/** Where the declared spec came from, for messages: `the working tree` or `ref <ref>`. */
|
|
17
|
+
const sideLabel = (side) => side.ref === void 0 ? "the working tree" : `ref ${side.ref}`;
|
|
18
|
+
/** Whether a `node:fs` rejection means "nothing there" (as opposed to a real IO failure). */
|
|
19
|
+
const isAbsent = (cause) => Predicate.isObject(cause) && (cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
|
20
|
+
/**
|
|
21
|
+
* Run a `node:fs/promises` call, mapping an absent target to `Option.none()`
|
|
22
|
+
* and ANY other rejection (`EACCES`, `EIO`, …) to a typed `hooks` error
|
|
23
|
+
* attributed to `path` — never a silent skip.
|
|
24
|
+
*/
|
|
25
|
+
const ioOrNone = (path, run) => Effect.tryPromise({
|
|
26
|
+
try: run,
|
|
27
|
+
catch: (cause) => cause
|
|
28
|
+
}).pipe(Effect.map(Option.some), Effect.catch((cause) => isAbsent(cause) ? Effect.succeed(Option.none()) : Effect.fail(hooksError(path, cause))));
|
|
29
|
+
const ABSENT = { _tag: "absent" };
|
|
30
|
+
const UNVERSIONED = { _tag: "unversioned" };
|
|
31
|
+
/** Whether a manifest carries exactly `declared`. Only a `version` state can. */
|
|
32
|
+
const carries = (manifest, declared) => manifest._tag === "version" && manifest.version === declared;
|
|
33
|
+
/**
|
|
34
|
+
* The version state of the `package.json` in `dir`: `absent` when there is
|
|
35
|
+
* no manifest, `unversioned` when it carries no non-empty string `version`,
|
|
36
|
+
* typed on any other IO failure or on unparseable JSON — a manifest that
|
|
37
|
+
* exists but cannot be read is not evidence of absence.
|
|
38
|
+
*/
|
|
39
|
+
const manifestVersion = (name, dir) => ioOrNone(name, () => readFile(join(dir, "package.json"), "utf8")).pipe(Effect.flatMap((text) => {
|
|
40
|
+
if (Option.isNone(text)) return Effect.succeed(ABSENT);
|
|
41
|
+
return Effect.try({
|
|
42
|
+
try: () => JSON.parse(text.value),
|
|
43
|
+
catch: (cause) => hooksError(name, cause)
|
|
44
|
+
}).pipe(Effect.map((parsed) => Predicate.isObject(parsed) && typeof parsed.version === "string" && parsed.version !== "" ? {
|
|
45
|
+
_tag: "version",
|
|
46
|
+
version: parsed.version
|
|
47
|
+
} : UNVERSIONED));
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { carries, hooksError, ioOrNone, manifestVersion, messageOf, sideLabel };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/internal/configDependencySpecGrammar.ts
|
|
2
|
+
/** Split a spec on its first `+`: `0.9.0+sha512-…` → `{ version: "0.9.0", integrity: "sha512-…" }`. */
|
|
3
|
+
const splitConfigDependencySpec = (spec) => {
|
|
4
|
+
const plus = spec.indexOf("+");
|
|
5
|
+
return plus === -1 ? {
|
|
6
|
+
version: spec,
|
|
7
|
+
integrity: void 0
|
|
8
|
+
} : {
|
|
9
|
+
version: spec.slice(0, plus),
|
|
10
|
+
integrity: spec.slice(plus + 1)
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { splitConfigDependencySpec };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0-next-20260927071821",
|
|
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": [
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"@effected/git": "^0.17.0",
|
|
56
56
|
"@effected/glob": "^0.8.0",
|
|
57
57
|
"@effected/jsonc": "^0.13.0",
|
|
58
|
-
"@effected/lockfiles": "^0.
|
|
59
|
-
"@effected/npm": "^0.
|
|
60
|
-
"@effected/package-json": "^0.
|
|
58
|
+
"@effected/lockfiles": "^0.12.0-next-20260927071821",
|
|
59
|
+
"@effected/npm": "^0.17.0-next-20260927071821",
|
|
60
|
+
"@effected/package-json": "^0.18.0-next-20260927071821",
|
|
61
61
|
"@effected/semver": "^0.9.0",
|
|
62
62
|
"@effected/walker": "^0.12.0",
|
|
63
63
|
"@effected/yaml": "^0.17.0",
|
package/testing.d.ts
CHANGED
|
@@ -633,6 +633,28 @@ interface WorkspaceDiscoveryOptions {
|
|
|
633
633
|
* honoured.
|
|
634
634
|
*/
|
|
635
635
|
readonly cwd?: string;
|
|
636
|
+
/**
|
|
637
|
+
* A ceiling for the layer-bound root ascent from `cwd`, passed straight
|
|
638
|
+
* through to the `stopAt` of {@link WorkspaceRoot}'s `find`.
|
|
639
|
+
*
|
|
640
|
+
* @remarks
|
|
641
|
+
* Inclusive: the ceiling itself is probed, so a `cwd` that is its own
|
|
642
|
+
* workspace root still resolves with `stopAt: cwd`. A relative ceiling is
|
|
643
|
+
* resolved against the process working directory, exactly as `cwd` is. When
|
|
644
|
+
* no root is found at or below the ceiling, the methods fail with
|
|
645
|
+
* {@link WorkspaceRootNotFoundError} carrying the resolved `stopAt`, rather
|
|
646
|
+
* than adopting an enclosing directory's workspace — pass `stopAt: cwd` for
|
|
647
|
+
* a checkout nested inside someone else's monorepo.
|
|
648
|
+
*
|
|
649
|
+
* Applies ONLY to the layer-bound methods (`info`, `listPackages`,
|
|
650
|
+
* `importerMap`, `getPackage`, `resolveFile`, `resolveFiles`). The per-call
|
|
651
|
+
* `infoIn`, `listPackagesIn` and `refreshIn` resolve from an arbitrary
|
|
652
|
+
* directory the caller names, which a single layer-level ceiling cannot
|
|
653
|
+
* sensibly bound, so they ascend unbounded as before.
|
|
654
|
+
*
|
|
655
|
+
* @defaultValue no ceiling — the ascent runs to the filesystem root.
|
|
656
|
+
*/
|
|
657
|
+
readonly stopAt?: string | undefined;
|
|
636
658
|
/** Descent cap for segment-crossing patterns. Defaults to 32. */
|
|
637
659
|
readonly maxDepth?: number;
|
|
638
660
|
}
|