@effected/workspaces 0.26.0 → 0.28.0-next-20260927062352
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/PackedInstall.js +530 -51
- package/README.md +67 -18
- package/SourceBoundary.js +87 -3
- package/WorkspaceCatalogs.js +9 -4
- package/WorkspaceSnapshots.js +11 -5
- package/index.d.ts +217 -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/internal/packedInstallPlan.js +73 -8
- package/internal/sourceText.js +32 -6
- package/package.json +4 -4
- package/testing.d.ts +484 -29
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 };
|