@effected/workspaces 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ import { WorkspaceRoot } from "./WorkspaceRoot.js";
2
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
3
+ import { PackageManagerDetector } from "./PackageManagerName.js";
4
+ import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect";
5
+ import { Lockfile, LockfileFormat, LockfileIntegrity, filenameFor } from "@effected/lockfiles";
6
+
7
+ //#region src/LockfileReader.ts
8
+ /**
9
+ * Raised when the workspace's lockfile cannot be read off disk.
10
+ *
11
+ * @remarks
12
+ * Parse failures are `@effected/lockfiles`' `LockfileParseError`, not this —
13
+ * this is strictly the IO half.
14
+ *
15
+ * @public
16
+ */
17
+ var LockfileReadError = class extends Schema.TaggedErrorClass()("LockfileReadError", {
18
+ /** Absolute path to the lockfile that could not be read. */
19
+ lockfilePath: Schema.String,
20
+ /** The format the detected package manager implies. */
21
+ format: LockfileFormat,
22
+ /** The originating failure. */
23
+ cause: Schema.Defect()
24
+ }) {
25
+ /** Renders the unreadable path into a one-line message. */
26
+ get message() {
27
+ return `Cannot read ${this.format} lockfile at ${this.lockfilePath}`;
28
+ }
29
+ };
30
+ /**
31
+ * Reads and parses the workspace's lockfile.
32
+ *
33
+ * @remarks
34
+ * Layer construction is O(1). The root walk, package-manager detection, file
35
+ * read, parse and pnpm name resolution all happen on the first method call and
36
+ * are memoized success-only for the lifetime of the layer.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { LockfileReader } from "@effected/workspaces";
41
+ * import { Effect } from "effect";
42
+ *
43
+ * const program = Effect.gen(function* () {
44
+ * const reader = yield* LockfileReader;
45
+ * const lockfile = yield* reader.read();
46
+ * return lockfile.packages.length;
47
+ * });
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ var LockfileReader = class LockfileReader extends Context.Service()("@effected/workspaces/LockfileReader") {
53
+ /** Builds the service. */
54
+ static make = (options) => Effect.gen(function* () {
55
+ const roots = yield* WorkspaceRoot;
56
+ const detector = yield* PackageManagerDetector;
57
+ const discovery = yield* WorkspaceDiscovery;
58
+ const fs = yield* FileSystem.FileSystem;
59
+ const path = yield* Path.Path;
60
+ const init = Effect.gen(function* () {
61
+ const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
62
+ const format = (yield* detector.detect(root)).name;
63
+ const lockfilePath = path.join(root, filenameFor(format));
64
+ const content = yield* fs.readFileString(lockfilePath).pipe(Effect.mapError((cause) => new LockfileReadError({
65
+ lockfilePath,
66
+ format,
67
+ cause
68
+ })));
69
+ const lockfile = yield* Lockfile.parse(content, { format });
70
+ if (format !== "pnpm") return lockfile;
71
+ const importers = lockfile.packages.filter((pkg) => pkg.isWorkspace && pkg.relativePath !== void 0);
72
+ const resolved = yield* Effect.forEach(importers, (pkg) => readName(path.join(root, pkg.relativePath, "package.json")).pipe(Effect.map((name) => [pkg.relativePath, name])), { concurrency: 10 });
73
+ const names = /* @__PURE__ */ new Map();
74
+ for (const [relativePath, name] of resolved) if (Option.isSome(name)) names.set(relativePath, name.value);
75
+ return lockfile.withImporterNames(names);
76
+ });
77
+ /** A workspace member's name, or none — an unreadable manifest is a miss, not a failure. */
78
+ const readName = (manifestPath) => Effect.gen(function* () {
79
+ const content = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => ""));
80
+ if (content === "") return Option.none();
81
+ const parsed = yield* Effect.try({
82
+ try: () => JSON.parse(content),
83
+ catch: () => void 0
84
+ }).pipe(Effect.orElseSucceed(() => void 0));
85
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return Option.none();
86
+ const name = parsed.name;
87
+ return typeof name === "string" && name.length > 0 ? Option.some(name) : Option.none();
88
+ });
89
+ const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(init, Duration.infinity);
90
+ const memo = Effect.onExit(resolveOnce, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate);
91
+ return {
92
+ read: Effect.fn("LockfileReader.read")(function* () {
93
+ return yield* memo;
94
+ }),
95
+ resolvedVersion: Effect.fn("LockfileReader.resolvedVersion")(function* (packageName) {
96
+ const matches = (yield* memo).packagesNamed(packageName);
97
+ return Option.fromUndefinedOr(matches[0]);
98
+ }),
99
+ integrity: Effect.fn("LockfileReader.integrity")(function* () {
100
+ const lockfile = yield* memo;
101
+ const packages = yield* discovery.listPackages();
102
+ return LockfileIntegrity.compare(lockfile, packages.map((pkg) => pkg.toWorkspaceManifest()));
103
+ }),
104
+ refresh: () => invalidate
105
+ };
106
+ });
107
+ /**
108
+ * The live layer.
109
+ *
110
+ * @remarks
111
+ * Parameterized, so it mints a fresh reference per call — bind it to a
112
+ * `const` and reuse it.
113
+ */
114
+ static layer = (options) => Layer.effect(LockfileReader, LockfileReader.make(options));
115
+ };
116
+
117
+ //#endregion
118
+ export { LockfileReadError, LockfileReader };
@@ -0,0 +1,248 @@
1
+ import { WorkspaceManifestError } from "./WorkspacePackage.js";
2
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
3
+ import { PackageManager } from "@effected/package-json";
4
+
5
+ //#region src/PackageManagerName.ts
6
+ /**
7
+ * The four package managers this package understands.
8
+ *
9
+ * @public
10
+ */
11
+ const PackageManagerName = Schema.Literals([
12
+ "npm",
13
+ "pnpm",
14
+ "yarn",
15
+ "bun"
16
+ ]);
17
+ /**
18
+ * The outcome of package-manager detection at a workspace root.
19
+ *
20
+ * @remarks
21
+ * `version` is `Option.none()` unless a manifest field naming the *same* manager
22
+ * that was detected also carries a version — a `packageManager: "yarn@4"` in a
23
+ * pnpm workspace tells us nothing about pnpm's version, so it is not reported as
24
+ * one. The two fields consulted are the corepack top-level `packageManager` and
25
+ * `devEngines.packageManager`; see {@link PackageManagerDetector} for the
26
+ * precedence between them.
27
+ *
28
+ * @public
29
+ */
30
+ var DetectedPackageManager = class extends Schema.Class("DetectedPackageManager")({
31
+ /** The detected manager. */
32
+ name: PackageManagerName,
33
+ /** Its version, when a manifest field agrees on the manager and carries one. */
34
+ version: Schema.Option(Schema.String),
35
+ /** The JavaScript runtime the manager implies. */
36
+ runtime: Schema.Literals(["node", "bun"])
37
+ }) {};
38
+ /** Whether `value` is a non-null, non-array object — corepack's own shape test. */
39
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
40
+ /**
41
+ * The exact version a `name` + `version` pair denotes, or none when the version
42
+ * is not an exact version.
43
+ *
44
+ * Reuses the corepack `name@version+integrity` grammar rather than a second
45
+ * parser, so a `devEngines` version carrying a hash (`11.11.0+sha512.…`, which
46
+ * this repo's own root manifest does) normalizes to the same `11.11.0` the
47
+ * top-level field reports — and a *range* (`^11`, `11.x`) yields none, because a
48
+ * range is not a version and corepack will not run one either.
49
+ */
50
+ const exactVersionOf = (name, version) => Schema.decodeUnknownOption(PackageManager.FromString)(`${name}@${version}`).pipe(Option.map((pm) => pm.version));
51
+ /**
52
+ * The `devEngines.packageManager` hint, or none.
53
+ *
54
+ * Every malformed shape corepack itself tolerates is tolerated here by *ignoring
55
+ * the field*, never by failing detection: a non-object `devEngines`, a
56
+ * non-object `packageManager`, an **array** of them (corepack does not support
57
+ * arrays in this slot and falls back), a missing or non-string `name`, and a
58
+ * `name` containing `@`. A version that is not an exact version is dropped on its
59
+ * own, keeping the name — the name is still a valid disambiguator.
60
+ */
61
+ const devEnginesHint = (manifest) => {
62
+ const devEngines = manifest.devEngines;
63
+ if (!isPlainObject(devEngines)) return Option.none();
64
+ const slot = devEngines.packageManager;
65
+ if (!isPlainObject(slot)) return Option.none();
66
+ const name = slot.name;
67
+ if (typeof name !== "string" || name === "" || name.includes("@")) return Option.none();
68
+ const version = slot.version;
69
+ return Option.some({
70
+ name,
71
+ version: typeof version === "string" && version !== "" ? exactVersionOf(name, version) : Option.none()
72
+ });
73
+ };
74
+ /** The corepack top-level `packageManager` hint, or none when absent or malformed. */
75
+ const corepackHint = (manifest) => {
76
+ const raw = manifest.packageManager;
77
+ if (typeof raw !== "string") return Option.none();
78
+ return Schema.decodeUnknownOption(PackageManager.FromString)(raw).pipe(Option.map((pm) => ({
79
+ name: pm.name,
80
+ version: Option.some(pm.version)
81
+ })));
82
+ };
83
+ /**
84
+ * Raised when a directory carries no lockfile and no workspace configuration,
85
+ * so no package manager can be attributed to it.
86
+ *
87
+ * @public
88
+ */
89
+ var PackageManagerDetectionError = class extends Schema.TaggedErrorClass()("PackageManagerDetectionError", {
90
+ /** The workspace root that was probed. */
91
+ root: Schema.String,
92
+ /** The marker files probed, in the order they were probed. */
93
+ checked: Schema.Array(Schema.String)
94
+ }) {
95
+ /** Renders the root and probed markers into a one-line message. */
96
+ get message() {
97
+ return `No package manager detected at ${this.root} (checked ${this.checked.join(", ")})`;
98
+ }
99
+ };
100
+ /** The markers probed, in priority order. Exposed on the error so the contract is not prose-only. */
101
+ const CHECKED = [
102
+ "pnpm-workspace.yaml",
103
+ "bun.lock",
104
+ "bun.lockb",
105
+ "yarn.lock",
106
+ "package.json#workspaces"
107
+ ];
108
+ /**
109
+ * Detects which package manager owns a workspace root.
110
+ *
111
+ * @remarks
112
+ * **Lockfile evidence is the primary signal** — it is what says which manager
113
+ * actually ran. Priority, first match wins: a `pnpm-workspace.yaml` means pnpm;
114
+ * a bun lockfile *plus* a manifest field naming bun means bun; a `yarn.lock`
115
+ * *plus* a manifest field naming yarn means yarn; a root `package.json` with a
116
+ * `workspaces` field falls back to npm.
117
+ *
118
+ * The manifest conjunction is deliberate: a stray `yarn.lock` in an npm repo is
119
+ * common, and only a declared manager name disambiguates it.
120
+ *
121
+ * **Two manifest fields declare a manager, and they are not interchangeable.**
122
+ * Corepack reads both, and this is the rule that falls out of how it treats
123
+ * them:
124
+ *
125
+ * - `devEngines.packageManager.name` is authoritative for the **name**.
126
+ * Corepack *errors* when a top-level `packageManager` disagrees with it (per
127
+ * `devEngines.packageManager.onFail`), so where both are present and disagree,
128
+ * `devEngines` is the one to believe. When `devEngines` names a manager, the
129
+ * top-level field's name is not consulted as a disambiguator at all.
130
+ * - The top-level `packageManager` is authoritative for the exact **version**:
131
+ * it is the field that carries the integrity hash. Where both name the same
132
+ * manager, its version wins; where it is absent, `devEngines.packageManager.version`
133
+ * supplies the version instead.
134
+ *
135
+ * A version is reported **only when the field it came from names the manager
136
+ * that was actually detected**. A `packageManager: "yarn@4"` in a pnpm workspace
137
+ * says nothing about pnpm's version, so no version is reported — and the same
138
+ * discipline applies to `devEngines`.
139
+ *
140
+ * Malformed manifest *hints* are ignored rather than fatal, matching corepack: a
141
+ * non-object or array `devEngines.packageManager`, a `name` containing `@`, or a
142
+ * version that is not an exact version cannot turn a detectable workspace into a
143
+ * detection failure. A manifest that exists but cannot be **read or parsed** is a
144
+ * different thing entirely and fails with a `WorkspaceManifestError` — a corrupt
145
+ * root manifest is a real problem, not a missing hint.
146
+ *
147
+ * @public
148
+ */
149
+ var PackageManagerDetector = class PackageManagerDetector extends Context.Service()("@effected/workspaces/PackageManagerDetector") {
150
+ /** Builds the service over core `FileSystem` and `Path`. */
151
+ static make = Effect.gen(function* () {
152
+ const fs = yield* FileSystem.FileSystem;
153
+ const path = yield* Path.Path;
154
+ const has = (root, file) => fs.exists(path.join(root, file)).pipe(Effect.orElseSucceed(() => false));
155
+ /**
156
+ * The root manifest, read and parsed **once** per detection.
157
+ *
158
+ * An absent manifest is `Option.none()` — a bun or yarn repo with no root
159
+ * `package.json` is unusual but not an error. A manifest that is present but
160
+ * unreadable, unparseable, or not a JSON object fails typed: those are
161
+ * corrupt-manifest conditions, and swallowing them would report "no manager
162
+ * declared" for a repo whose manifest is simply broken.
163
+ */
164
+ const manifestOf = (root) => Effect.gen(function* () {
165
+ const packageJsonPath = path.join(root, "package.json");
166
+ if (!(yield* fs.exists(packageJsonPath).pipe(Effect.orElseSucceed(() => false)))) return Option.none();
167
+ const content = yield* fs.readFileString(packageJsonPath).pipe(Effect.mapError((cause) => new WorkspaceManifestError({
168
+ packageJsonPath,
169
+ kind: "read",
170
+ cause
171
+ })));
172
+ const parsed = yield* Effect.try({
173
+ try: () => JSON.parse(content),
174
+ catch: (cause) => new WorkspaceManifestError({
175
+ packageJsonPath,
176
+ kind: "decode",
177
+ cause
178
+ })
179
+ });
180
+ if (!isPlainObject(parsed)) return yield* Effect.fail(new WorkspaceManifestError({
181
+ packageJsonPath,
182
+ kind: "decode",
183
+ cause: /* @__PURE__ */ new Error("package.json is not a JSON object")
184
+ }));
185
+ return Option.some(parsed);
186
+ });
187
+ /**
188
+ * The manager name the manifest declares, if any.
189
+ *
190
+ * `devEngines` first — it is authoritative for the name, and corepack errors
191
+ * when the top-level field contradicts it.
192
+ */
193
+ const declaredName = (hints) => Option.map(Option.orElse(hints.devEngines, () => hints.corepack), (hint) => hint.name);
194
+ /** Whether the manifest declares `name` as its manager. */
195
+ const namesManager = (hints, name) => Option.contains(declaredName(hints), name);
196
+ /**
197
+ * The version to report for the manager that was detected — none unless a
198
+ * field naming *that* manager carries one.
199
+ *
200
+ * The top-level `packageManager` wins when it names the manager, because it
201
+ * is the field carrying the integrity hash; `devEngines` supplies the version
202
+ * when it does not.
203
+ */
204
+ const versionFor = (hints, name) => {
205
+ if (!namesManager(hints, name)) return Option.none();
206
+ const fromCorepack = Option.flatMap(hints.corepack, (hint) => hint.name === name ? hint.version : Option.none());
207
+ return Option.orElse(fromCorepack, () => Option.flatMap(hints.devEngines, (hint) => hint.name === name ? hint.version : Option.none()));
208
+ };
209
+ const detect = Effect.fn("PackageManagerDetector.detect")(function* (root) {
210
+ const manifest = yield* manifestOf(root);
211
+ const hints = {
212
+ devEngines: Option.flatMap(manifest, devEnginesHint),
213
+ corepack: Option.flatMap(manifest, corepackHint)
214
+ };
215
+ if (yield* has(root, "pnpm-workspace.yaml")) return DetectedPackageManager.make({
216
+ name: "pnpm",
217
+ version: versionFor(hints, "pnpm"),
218
+ runtime: "node"
219
+ });
220
+ if (((yield* has(root, "bun.lock")) || (yield* has(root, "bun.lockb"))) && namesManager(hints, "bun")) return DetectedPackageManager.make({
221
+ name: "bun",
222
+ version: versionFor(hints, "bun"),
223
+ runtime: "bun"
224
+ });
225
+ if ((yield* has(root, "yarn.lock")) && namesManager(hints, "yarn")) return DetectedPackageManager.make({
226
+ name: "yarn",
227
+ version: versionFor(hints, "yarn"),
228
+ runtime: "node"
229
+ });
230
+ const workspaces = Option.map(manifest, (fields) => fields.workspaces);
231
+ if (Option.isSome(workspaces) && workspaces.value !== void 0 && workspaces.value !== null) return DetectedPackageManager.make({
232
+ name: "npm",
233
+ version: versionFor(hints, "npm"),
234
+ runtime: "node"
235
+ });
236
+ return yield* Effect.fail(new PackageManagerDetectionError({
237
+ root,
238
+ checked: CHECKED
239
+ }));
240
+ });
241
+ return { detect: (root) => detect(root).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path)) };
242
+ });
243
+ /** The live layer. */
244
+ static layer = Layer.effect(PackageManagerDetector, PackageManagerDetector.make);
245
+ };
246
+
247
+ //#endregion
248
+ export { DetectedPackageManager, PackageManagerDetectionError, PackageManagerDetector, PackageManagerName };
@@ -0,0 +1,73 @@
1
+ import { Context, Effect, Layer, Schema } from "effect";
2
+
3
+ //#region src/Publishability.ts
4
+ /** The public npm registry, used when `publishConfig.registry` says nothing. */
5
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
6
+ /**
7
+ * A resolved publish destination for a workspace package.
8
+ *
9
+ * @public
10
+ */
11
+ var PublishTarget = class extends Schema.Class("PublishTarget")({
12
+ /** The package name being published. */
13
+ name: Schema.NonEmptyString,
14
+ /** The registry URL. */
15
+ registry: Schema.NonEmptyString,
16
+ /** The directory to publish, relative to the package root; `"."` for the root itself. */
17
+ directory: Schema.String,
18
+ /** Scoped-package visibility. */
19
+ access: Schema.Literals(["public", "restricted"]),
20
+ /** Whether to publish with a provenance attestation. */
21
+ provenance: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)), Schema.withConstructorDefault(Effect.succeed(false)))
22
+ }) {};
23
+ /**
24
+ * Decides whether a workspace package publishes, and to where.
25
+ *
26
+ * @remarks
27
+ * The default layer implements standard npm semantics: a `private` package with
28
+ * no `publishConfig.access` publishes nowhere; an explicit
29
+ * `publishConfig.access` overrides `private`; anything else publishes to the
30
+ * public registry with defaults.
31
+ *
32
+ * Those are *npm's* semantics, not necessarily yours. Swap the layer:
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { PublishabilityDetector, PublishTarget } from "@effected/workspaces";
37
+ * import { Effect, Layer } from "effect";
38
+ *
39
+ * const internalOnly = Layer.succeed(PublishabilityDetector, {
40
+ * detect: (pkg) =>
41
+ * Effect.succeed(
42
+ * pkg.name.startsWith("@acme/")
43
+ * ? [PublishTarget.make({
44
+ * name: pkg.name,
45
+ * registry: "https://npm.acme.internal/",
46
+ * directory: ".",
47
+ * access: "restricted",
48
+ * })]
49
+ * : [],
50
+ * ),
51
+ * });
52
+ * ```
53
+ *
54
+ * @public
55
+ */
56
+ var PublishabilityDetector = class PublishabilityDetector extends Context.Service()("@effected/workspaces/PublishabilityDetector") {
57
+ /** Standard npm publishing semantics. Pure — no filesystem, no platform services. */
58
+ static layer = Layer.succeed(PublishabilityDetector, { detect: (pkg) => Effect.sync(() => {
59
+ const config = pkg.publishConfig;
60
+ const access = config?.access;
61
+ if (pkg.private && access === void 0) return [];
62
+ return [PublishTarget.make({
63
+ name: pkg.name,
64
+ registry: config?.registry ?? DEFAULT_REGISTRY,
65
+ directory: config?.directory ?? ".",
66
+ access: access ?? "public",
67
+ provenance: false
68
+ })];
69
+ }) });
70
+ };
71
+
72
+ //#endregion
73
+ export { PublishTarget, PublishabilityDetector };
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @effected/workspaces
2
+
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fworkspaces?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/workspaces)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Monorepo workspace tooling for [Effect](https://effect.website) v4: find the workspace root, enumerate its packages, walk the dependency graph, detect the package manager, resolve pnpm catalogs, read the lockfile and work out which packages a git range touches. Every capability is a service you provide at the edge and swap in tests. Works with npm, pnpm, yarn Berry and bun.
9
+
10
+ > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
+ > development against a single pinned Effect v4 beta. Packages graduate to
12
+ > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
+ > exactly the ones the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Why @effected/workspaces
23
+
24
+ Monorepo tooling keeps re-deriving the same facts: where the root is, which directories are packages, what depends on what, what a `catalog:` specifier means and which packages a change affects. Each tool re-derives them slightly differently, and the differences show up as bugs. This package answers those questions once.
25
+
26
+ Discovery is honest about what a glob means. A `packages/**` pattern finds packages nested more than one level deep, because the enumerator does a bounded descent rather than the one-level approximation that a trailing-`**` rewrite quietly turns it into — and a package that goes undiscovered with no diagnostic is the worst kind of wrong, because an empty result is indistinguishable from a legitimately empty workspace. The same discipline runs through the error model: a malformed `package.json`, an unenumerable pattern, a missing lockfile and a failed git command all fail through the typed channel with structured fields, while a developer wiring mistake (an uncompilable glob literal, a fractional `maxDepth`) stays a defect. The typed channel is exactly the set of things a caller can branch on.
27
+
28
+ Git runs through `@effected/git`'s `Git` service rather than a hard-coded subprocess call, so change detection is testable with no repository on disk and portable to a runtime that spawns processes differently. And where `@effected/npm` declares the `CatalogResolver` and `WorkspaceResolver` seams — contracts that `@effected/package-json` consumes but no pure package can fill — this is the package that fills them.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install @effected/workspaces effect
34
+ ```
35
+
36
+ ```bash
37
+ pnpm add @effected/workspaces effect
38
+ ```
39
+
40
+ Requires Node.js >=24.11.0. `effect` v4 is a peer dependency. You provide a `FileSystem` and `Path` implementation at the edge — `@effect/platform-node` or `@effect/platform-bun`.
41
+
42
+ pnpm's catalog semantics come from pnpm's own `@pnpm/catalogs.*` packages, which install as regular dependencies. Reimplementing them would mean owning a moving spec with no oracle, so they are used directly and confined to a single internal module.
43
+
44
+ ## Quick start
45
+
46
+ ```ts
47
+ import { NodeFileSystem, NodePath } from "@effect/platform-node";
48
+ import { DependencyGraph, WorkspaceDiscovery, Workspaces } from "@effected/workspaces";
49
+ import { Effect, Layer } from "effect";
50
+
51
+ // Bind the layer to a const: layers memoize by reference, so calling
52
+ // Workspaces.layer() twice builds the whole stack twice.
53
+ const Platform = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
54
+ const WorkspacesLayer = Workspaces.layer().pipe(Layer.provide(Platform));
55
+
56
+ const program = Effect.gen(function* () {
57
+ const discovery = yield* WorkspaceDiscovery;
58
+
59
+ const packages = yield* discovery.listPackages();
60
+ const graph = DependencyGraph.make({ packages });
61
+
62
+ // Parallel build tiers: level 0 depends on nothing in the workspace,
63
+ // level n depends only on the levels below it.
64
+ return yield* graph.levels();
65
+ });
66
+
67
+ Effect.runPromise(program.pipe(Effect.provide(WorkspacesLayer))).then(console.log);
68
+ // [ [ ...names with no workspace dependencies ], [ ...names that depend only on level 0 ], ... ]
69
+ ```
70
+
71
+ `DependencyGraph` is a value class, not a service: build it from packages you already have. A cycle fails with `CyclicDependencyError` naming the packages that could not be ordered.
72
+
73
+ ## Change detection
74
+
75
+ `ChangeDetector` offers three depths of analysis on one service — `changedFiles` (raw paths from a git range), `changedPackages` (the packages owning them) and `affectedPackages` (the transitive blast radius through the dependency graph).
76
+
77
+ ```ts
78
+ import { NodeServices } from "@effect/platform-node";
79
+ import { ChangeDetectionOptions, ChangeDetector, Workspaces } from "@effected/workspaces";
80
+ import { Effect, Layer } from "effect";
81
+
82
+ // layerWithGit runs ChangeDetector over @effected/git's Git service; NodeServices
83
+ // provides the ChildProcessSpawner it needs, alongside FileSystem and Path.
84
+ const WorkspacesLayer = Workspaces.layerWithGit().pipe(Layer.provide(NodeServices.layer));
85
+
86
+ const program = Effect.gen(function* () {
87
+ const detector = yield* ChangeDetector;
88
+ const affected = yield* detector.affectedPackages(ChangeDetectionOptions.make({ base: "origin/main" }));
89
+ return affected.map((pkg) => pkg.name);
90
+ });
91
+
92
+ Effect.runPromise(program.pipe(Effect.provide(WorkspacesLayer))).then(console.log);
93
+ // [ ...names of packages the range touched, plus everything downstream of them ]
94
+ ```
95
+
96
+ Git is a separate layer rather than a flag, because the extra requirement is a subprocess: a consumer that never detects changes should not have to be able to spawn one. A test provides the `Git` service with a `Layer.succeed` stub and needs no repository at all.
97
+
98
+ ## pnpm catalogs
99
+
100
+ `WorkspaceCatalogs` assembles a workspace's catalogs with pnpm's precedence (the lockfile's record first, the inline `pnpm-workspace.yaml` declaration wins) and resolves `catalog:` specifiers against the result.
101
+
102
+ It also supplies the real implementations of `@effected/npm`'s `CatalogResolver` and `WorkspaceResolver` contracts — the seams `@effected/package-json` reads through, which without a workspace under them can only answer `Option.none()`. Provide `Workspaces.resolvers` and `Package.resolve` rewrites `catalog:` and `workspace:` specifiers to concrete ranges:
103
+
104
+ ```ts
105
+ import { Workspaces } from "@effected/workspaces";
106
+ import { Layer } from "effect";
107
+
108
+ const WorkspacesLayer = Workspaces.layer();
109
+ const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
110
+ // Layer<CatalogResolver | WorkspaceResolver, never, FileSystem | Path>
111
+ ```
112
+
113
+ ## The synchronous escape hatch
114
+
115
+ Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they are **Node-only and synchronous**:
116
+
117
+ ```ts
118
+ import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
119
+
120
+ const root = findWorkspaceRootSync();
121
+ const packages = root === null ? [] : getWorkspacePackagesSync(root);
122
+ // root: the workspace root path, or null when none is found above the cwd
123
+ // packages: the discovered workspace packages, empty when there is no root
124
+ ```
125
+
126
+ Both entry points drive one traversal state machine — the same dequeue order, depth rule, visit budget and `node_modules` prune — so the sync and Effect surfaces can never disagree about what a pattern means. The one deliberate difference is at a bound: the Effect enumerator fails typed, the sync one truncates. Prefer the Effect API everywhere you can run one.
127
+
128
+ ## Error handling
129
+
130
+ Every failure is a `Schema.TaggedErrorClass` with structured fields you can branch on, not a prose string:
131
+
132
+ ```ts
133
+ import { WorkspaceDiscovery, WorkspacePatternError } from "@effected/workspaces";
134
+ import { Effect } from "effect";
135
+
136
+ const program = Effect.gen(function* () {
137
+ const discovery = yield* WorkspaceDiscovery;
138
+ return yield* discovery.listPackages();
139
+ }).pipe(
140
+ Effect.catchTag("WorkspacePatternError", (error: WorkspacePatternError) =>
141
+ // kind: "missingBaseDir" | "uncompilable" | "depthExceeded" | "budgetExceeded"
142
+ Effect.logError(`pattern ${error.pattern} failed: ${error.kind}`).pipe(Effect.as([])),
143
+ ),
144
+ );
145
+ ```
146
+
147
+ `WorkspaceRootNotFoundError`, `WorkspaceDiscoveryError`, `WorkspacePatternError`, `PackageNotFoundError`, `WorkspaceManifestError`, `PackageManagerDetectionError`, `CatalogAssemblyError`, `LockfileReadError`, `CyclicDependencyError` and `ChangeDetectionError` each name one thing that can actually go wrong, and each method's error channel is narrowed to the ones it can produce. Change detection additionally surfaces `@effected/git`'s typed git errors, such as `NotARepositoryError`.
148
+
149
+ ## Features
150
+
151
+ - `Workspaces.layer` / `Workspaces.layerWithGit` / `Workspaces.resolvers` — the composite layers, split on requirements rather than feature flags: a filesystem, a filesystem plus a subprocess, and the two `@effected/npm` resolver contracts.
152
+ - `WorkspaceRoot` — root discovery from a `cwd`, over `WORKSPACE_MARKERS`.
153
+ - `WorkspaceDiscovery` — package enumeration with a bounded descent for segment-crossing `packages/**` patterns, plus per-package lookup.
154
+ - `WorkspacePackage` — a deliberately tolerant manifest model, so one member with an odd version cannot fail discovery for the whole repo. `WorkspacePackage.manifest(pkg)` is the opt-in bridge to `@effected/package-json`'s strict `Package`.
155
+ - `DependencyGraph` — a value class over discovered packages: `levels()` for parallel build tiers, the flattened topological order, and `CyclicDependencyError` when there isn't one.
156
+ - `PackageManagerDetector` — npm, pnpm, yarn or bun from lockfiles and the `packageManager` field.
157
+ - `WorkspaceCatalogs` — pnpm catalog assembly and `catalog:` resolution, on pnpm's own catalog packages.
158
+ - `LockfileReader` — locate and parse the workspace's lockfile through `@effected/lockfiles`.
159
+ - `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
160
+ - `PublishabilityDetector` — whether a package publishes and to where, as a `PublishTarget` (registry, directory, access, provenance). The default layer implements npm's semantics; swap the layer if yours differ.
161
+ - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the Node-only synchronous escape hatch for config-time callers that cannot await.
162
+
163
+ ## License
164
+
165
+ [MIT](LICENSE)