@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.
@@ -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 declared version, what is
385
- * installed, the stores searched, and the remediation
386
- * (`pnpm add --config <name>@<version>` in a throwaway workspace). In the
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, else a typed fail-closed error) runs here, and the child
467
- * receives the resolved `[name, fileUrl]` pairs as one JSON argv
468
- * argument. An empty `configDependencies`, or one whose dependencies all
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
- return { inject: (root, configDependencies, seed, rules) => Effect.gen(function* () {
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 };