@effected/workspaces 0.8.0 → 0.9.1

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/Workspaces.js CHANGED
@@ -3,183 +3,262 @@ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
3
3
  import { ChangeDetector } from "./ChangeDetector.js";
4
4
  import { PackageManagerDetector } from "./PackageManagerName.js";
5
5
  import { LockfileReader } from "./LockfileReader.js";
6
- import { PublishabilityDetector } from "./Publishability.js";
7
6
  import { WorkspaceCatalogs } from "./WorkspaceCatalogs.js";
8
7
  import { WorkspaceSnapshots } from "./WorkspaceSnapshots.js";
9
8
  import { Git } from "@effected/git";
10
- import { Effect, Layer } from "effect";
9
+ import { Effect, Layer, Option } from "effect";
10
+ import { ExecContext, LocalExec, LocalExecError } from "@effected/commands";
11
11
 
12
12
  //#region src/Workspaces.ts
13
- /**
14
- * Every service that needs only a filesystem: root, package-manager detection,
15
- * discovery, lockfile reading, catalogs and publishability.
16
- *
17
- * @remarks
18
- * Requires core `FileSystem` and `Path`, which the consumer provides at the
19
- * edge (`@effect/platform-node`, `@effect/platform-bun`, or a test's
20
- * `FileSystem.layerNoop`).
21
- *
22
- * **Bind the result to a `const`.** This is a parameterized factory and layers
23
- * memoize by reference, so calling it twice builds everything twice.
24
- *
25
- * @example
26
- * ```ts
27
- * import { Workspaces } from "@effected/workspaces";
28
- * import { Layer } from "effect";
29
- *
30
- * const WorkspacesLayer = Workspaces.layer();
31
- * const AppLayer = Layer.provide(WorkspacesLayer, PlatformLayer);
32
- * ```
33
- *
34
- * @public
35
- */
36
13
  const compose = (options, catalogsFactory) => {
37
14
  const roots = WorkspaceRoot.layer;
38
15
  const detector = PackageManagerDetector.layer;
39
16
  const discovery = WorkspaceDiscovery.layer(options).pipe(Layer.provide(roots));
40
17
  const lockfiles = LockfileReader.layer(options).pipe(Layer.provide(roots), Layer.provide(detector), Layer.provide(discovery));
41
18
  const catalogs = catalogsFactory(options).pipe(Layer.provide(roots), Layer.provide(lockfiles));
42
- return Layer.mergeAll(roots, detector, discovery, lockfiles, catalogs, PublishabilityDetector.layer);
19
+ return Layer.mergeAll(roots, detector, discovery, lockfiles, catalogs);
43
20
  };
44
21
  const layer = (options) => compose(options, WorkspaceCatalogs.layer);
45
- /**
46
- * The git-free composite plus {@link ChangeDetector} and
47
- * {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
48
- *
49
- * @remarks
50
- * The extra requirement is core's `ChildProcessSpawner` (behind `Git`), which
51
- * is why it is a separate layer rather than a flag: a consumer that never
52
- * detects changes or reads at a ref should not have to be able to spawn a
53
- * subprocess. The consumer provides `ChildProcessSpawner` once at the edge
54
- * (`@effect/platform-node`'s `NodeServices.layer`); a test provides
55
- * `Layer.succeed(Git, …)` and needs no repository on disk.
56
- *
57
- * @public
58
- */
59
22
  const layerWithGit = (options) => {
60
23
  const core = layer(options);
61
24
  const git = Git.layer;
62
25
  return Layer.mergeAll(core, git, ChangeDetector.layer.pipe(Layer.provide(git), Layer.provide(core)), WorkspaceSnapshots.layer(options).pipe(Layer.provide(git), Layer.provide(core)));
63
26
  };
64
- /**
65
- * The two `@effected/npm` resolver contracts, implemented for real.
66
- *
67
- * @remarks
68
- * Provide this alongside `@effected/package-json`'s `Package.resolve` and a
69
- * manifest's `catalog:` and `workspace:` specifiers resolve against the actual
70
- * workspace instead of the no-op layers' `Option.none()`.
71
- *
72
- * @example
73
- * ```ts
74
- * import { Package } from "@effected/package-json";
75
- * import { Workspaces } from "@effected/workspaces";
76
- * import { Layer } from "effect";
77
- *
78
- * const WorkspacesLayer = Workspaces.layer();
79
- * const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
80
- * ```
81
- *
82
- * @public
83
- */
84
27
  const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDiscovery.workspaceResolver);
85
- /**
86
- * The git-free composite, but with catalog assembly that **replays config
87
- * dependency `pnpmfile.cjs` hooks** — {@link WorkspaceCatalogs.layerWithConfigDependencies}
88
- * in place of the default no-op catalogs layer.
89
- *
90
- * @remarks
91
- * Identical requirement set to {@link Workspaces.layer}; the only difference is
92
- * that config-dependency code is executed in process. Opt in deliberately — the
93
- * default {@link Workspaces.layer} never executes config-dependency code.
94
- *
95
- * **Bind the result to a `const`.**
96
- *
97
- * @public
98
- */
99
28
  const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
100
- /**
101
- * The one-call resolver factory: {@link Workspaces.resolvers} pre-wired over
102
- * {@link Workspaces.layerWithConfigDependencies}, so the two `@effected/npm`
103
- * contracts (`CatalogResolver`, `WorkspaceResolver`) need only a platform
104
- * (`FileSystem` + `Path`) from the consumer.
105
- *
106
- * @remarks
107
- * This is deliberately a **parameterized layer function, and the fresh layer
108
- * per call is the feature**: layers memoize by reference, so each call mints
109
- * an unmemoized layer whose root discovery re-runs — including a per-call
110
- * `process.cwd()` read when `options.cwd` is omitted. A build tool that
111
- * changes directory between manifests gets a correct re-discovery each time
112
- * precisely because nothing is shared across calls. When you *want* sharing,
113
- * bind one call's result to a `const` and provide that; the memoization rule
114
- * is unchanged, this factory just refuses to hide it.
115
- *
116
- * Catalog assembly replays config-dependency `pnpmfile` hooks (the
117
- * `layerWithConfigDependencies` path) — the semantics a real pnpm install
118
- * has. Compose {@link Workspaces.resolvers} with {@link Workspaces.layer}
119
- * yourself if config-dependency code must not run in process.
120
- *
121
- * @example
122
- * ```ts
123
- * import { Workspaces } from "@effected/workspaces";
124
- * import { Effect } from "effect";
125
- *
126
- * const program = doSomethingWithResolvers.pipe(
127
- * Effect.provide(Workspaces.resolverLayer()),
128
- * );
129
- * ```
130
- *
131
- * @public
132
- */
133
29
  const resolverLayer = (options) => resolvers.pipe(Layer.provide(layerWithConfigDependencies(options)));
134
- /**
135
- * Resolve every `catalog:` and `workspace:` specifier in one `Manifest`
136
- * against the real workspace, in one call — the 90% path. Decode stays at the
137
- * consumer's edge: build the `Manifest` with `Manifest.decode` (from
138
- * `@effected/npm`), hand it here, and get a new `Manifest` back with concrete
139
- * ranges; `toRecord()` returns to the wire shape.
140
- *
141
- * @remarks
142
- * Composes `manifest.resolve()` with a fresh {@link Workspaces.resolverLayer}
143
- * per call, so the workspace root is re-discovered from `options.cwd` (or the
144
- * current `process.cwd()`) on every invocation. Consumers processing many
145
- * manifests should check `manifest.needsResolution` first and skip the call
146
- * entirely when no dependency field carries a `catalog:`/`workspace:`
147
- * specifier — that predicate is pure and avoids catalog assembly altogether.
148
- *
149
- * A specifier the workspace cannot answer fails typed as
150
- * `UnresolvedDependencyError`; assembly and mechanism failures surface as
151
- * `CatalogAssemblyError` / `DependencyResolutionError`.
152
- *
153
- * @example
154
- * ```ts
155
- * import { Manifest } from "@effected/npm";
156
- * import { Workspaces } from "@effected/workspaces";
157
- * import { Effect } from "effect";
158
- *
159
- * const program = Effect.gen(function* () {
160
- * const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
161
- * const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
162
- * return resolved.toRecord();
163
- * });
164
- * ```
165
- *
166
- * @public
167
- */
168
30
  const resolveManifest = Effect.fn("Workspaces.resolveManifest")(function* (manifest, options) {
169
31
  return yield* manifest.resolve().pipe(Effect.provide(resolverLayer(options)));
170
32
  });
33
+ const localExecLayer = (options) => Layer.effect(LocalExec, Effect.gen(function* () {
34
+ const roots = yield* WorkspaceRoot;
35
+ const detector = yield* PackageManagerDetector;
36
+ return { context: Effect.gen(function* () {
37
+ const cwd = options?.cwd ?? globalThis.process?.cwd?.() ?? "/";
38
+ const root = yield* roots.find(cwd).pipe(Effect.asSome, Effect.orElseSucceed(Option.none));
39
+ if (Option.isNone(root)) return Option.none();
40
+ const detected = yield* detector.detect(root.value).pipe(Effect.asSome, Effect.catchTag("PackageManagerDetectionError", () => Effect.succeed(Option.none())), Effect.mapError((cause) => new LocalExecError({
41
+ directory: root.value,
42
+ cause
43
+ })));
44
+ if (Option.isNone(detected)) return Option.none();
45
+ const { prefix, dlxPrefix, scriptPrefix } = LocalExec.prefixes(detected.value.name);
46
+ return Option.some(ExecContext.make({
47
+ label: detected.value.name,
48
+ prefix,
49
+ dlxPrefix,
50
+ scriptPrefix,
51
+ directory: root.value
52
+ }));
53
+ }) };
54
+ }));
171
55
  /**
172
56
  * The composite layers.
173
57
  *
174
58
  * @public
175
59
  */
176
- const Workspaces = {
177
- layer,
178
- layerWithConfigDependencies,
179
- layerWithGit,
180
- resolveManifest,
181
- resolverLayer,
182
- resolvers
60
+ var Workspaces = class {
61
+ constructor() {}
62
+ /**
63
+ * Every service that needs only a filesystem: root, package-manager
64
+ * detection, discovery, lockfile reading and catalogs.
65
+ *
66
+ * @remarks
67
+ * Requires core `FileSystem` and `Path`, which the consumer provides at the
68
+ * edge (`@effect/platform-node`, `@effect/platform-bun`, or a test's
69
+ * `FileSystem.layerNoop`).
70
+ *
71
+ * **`PublishabilityDetector` is neither provided nor required here.** The
72
+ * composite used to bake in npm semantics, which a naively-ordered override
73
+ * silently lost to; now it supplies no default, and — because nothing inside
74
+ * the composite asks a publishability question — it does not require one in
75
+ * `R` either. The requirement surfaces in the `R` of each operation that
76
+ * asks (`VersioningStrategy.detect`, e.g.), so a program that asks and never
77
+ * wires a detector fails to compile at that operation, and a program that
78
+ * never asks never supplies a publish policy. Wire one explicitly where
79
+ * needed: `Layer.mergeAll(Workspaces.layer(), PublishabilityDetector.layerNpm)`.
80
+ *
81
+ * **Bind the result to a `const`.** This is a parameterized factory and
82
+ * layers memoize by reference, so calling it twice builds everything twice.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { Workspaces } from "@effected/workspaces";
87
+ * import { Layer } from "effect";
88
+ *
89
+ * const WorkspacesLayer = Workspaces.layer();
90
+ * const AppLayer = Layer.provide(WorkspacesLayer, PlatformLayer);
91
+ * ```
92
+ */
93
+ static layer = layer;
94
+ /**
95
+ * The git-free composite, but with catalog assembly that **replays config
96
+ * dependency `pnpmfile.cjs` hooks** —
97
+ * {@link WorkspaceCatalogs.layerWithConfigDependencies} in place of the
98
+ * default no-op catalogs layer.
99
+ *
100
+ * @remarks
101
+ * Identical requirement set to {@link Workspaces.layer}; the only
102
+ * difference is that config-dependency code is executed in process. Opt in
103
+ * deliberately — the default {@link Workspaces.layer} never executes
104
+ * config-dependency code.
105
+ *
106
+ * **Bind the result to a `const`.**
107
+ */
108
+ static layerWithConfigDependencies = layerWithConfigDependencies;
109
+ /**
110
+ * The git-free composite plus {@link ChangeDetector} and
111
+ * {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
112
+ *
113
+ * @remarks
114
+ * The extra requirement is core's `ChildProcessSpawner` (behind `Git`),
115
+ * which is why it is a separate layer rather than a flag: a consumer that
116
+ * never detects changes or reads at a ref should not have to be able to
117
+ * spawn a subprocess. The consumer provides `ChildProcessSpawner` once at
118
+ * the edge (`@effect/platform-node`'s `NodeServices.layer`); a test
119
+ * provides `Layer.succeed(Git, …)` and needs no repository on disk.
120
+ */
121
+ static layerWithGit = layerWithGit;
122
+ /**
123
+ * This package's implementation of `@effected/commands`' `LocalExec`
124
+ * contract: how to run a project-local binary here.
125
+ *
126
+ * @remarks
127
+ * **An inverted contract, the `@effected/npm` `CatalogResolver`
128
+ * precedent.** Tool discovery needs package-manager detection and
129
+ * workspace-root resolution, both of which live here — but a direct edge
130
+ * from `@effected/commands` to this package would make that boundary-tier
131
+ * package integrated, and through the planned `npm` → `commands` edge
132
+ * would drag `npm`, `lockfiles` (pure!) and `package-json` up a tier with
133
+ * it. So `commands` declares the narrow contract and we ship the layer.
134
+ *
135
+ * **The argv knowledge is not duplicated.** `LocalExec.prefixes(name)` is
136
+ * the one home of the four managers' `exec`/`dlx`/script-runner prefixes;
137
+ * this layer
138
+ * detects *which* manager owns the directory and asks `commands` what that
139
+ * manager's argv looks like. Neither package reimplements the other's
140
+ * half.
141
+ *
142
+ * **`None` is success.** Outside any workspace — and inside one whose
143
+ * manager cannot be identified — the answer is `Option.none()`: "there is
144
+ * no project-local way to run tools here" is an ordinary fact, not an
145
+ * exceptional one, and a consumer running in a bare directory should not
146
+ * have to catch an error to learn it. The contract's typed
147
+ * `LocalExecError` is reserved for **mechanism** failure — a manifest that
148
+ * exists but cannot be read or parsed, which means something is broken
149
+ * rather than absent. That is npm's resolver convention, adopted
150
+ * verbatim.
151
+ *
152
+ * `directory` is the resolved **workspace root**, not the caller's cwd: a
153
+ * project-local launcher has to run where the workspace is.
154
+ *
155
+ * A consumer with no monorepo never needs this layer, and therefore never
156
+ * installs this package — `LocalExec.layerNone` and `LocalExec.layerFor`
157
+ * are one-liners in `@effected/commands`.
158
+ *
159
+ * **Bind the result to a `const`** — a parameterized layer factory mints a
160
+ * fresh reference per call and layers memoize by reference.
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * import { ToolDiscovery } from "@effected/commands";
165
+ * import { Workspaces } from "@effected/workspaces";
166
+ * import { Layer } from "effect";
167
+ *
168
+ * const AppLayer = ToolDiscovery.layer.pipe(
169
+ * Layer.provide(Workspaces.localExecLayer()),
170
+ * Layer.provide(Workspaces.layer()),
171
+ * Layer.provide(NodeServices.layer),
172
+ * );
173
+ * ```
174
+ */
175
+ static localExecLayer = localExecLayer;
176
+ /**
177
+ * Resolve every `catalog:` and `workspace:` specifier in one `Manifest`
178
+ * against the real workspace, in one call — the 90% path. Decode stays at
179
+ * the consumer's edge: build the `Manifest` with `Manifest.decode` (from
180
+ * `@effected/npm`), hand it here, and get a new `Manifest` back with
181
+ * concrete ranges; `toRecord()` returns to the wire shape.
182
+ *
183
+ * @remarks
184
+ * Composes `manifest.resolve()` with a fresh {@link Workspaces.resolverLayer}
185
+ * per call, so the workspace root is re-discovered from `options.cwd` (or
186
+ * the current `process.cwd()`) on every invocation. Consumers processing
187
+ * many manifests should check `manifest.needsResolution` first and skip
188
+ * the call entirely when no dependency field carries a
189
+ * `catalog:`/`workspace:` specifier — that predicate is pure and avoids
190
+ * catalog assembly altogether.
191
+ *
192
+ * A specifier the workspace cannot answer fails typed as
193
+ * `UnresolvedDependencyError`; assembly and mechanism failures surface as
194
+ * `CatalogAssemblyError` / `DependencyResolutionError`.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * import { Manifest } from "@effected/npm";
199
+ * import { Workspaces } from "@effected/workspaces";
200
+ * import { Effect } from "effect";
201
+ *
202
+ * const program = Effect.gen(function* () {
203
+ * const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
204
+ * const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
205
+ * return resolved.toRecord();
206
+ * });
207
+ * ```
208
+ */
209
+ static resolveManifest = resolveManifest;
210
+ /**
211
+ * The one-call resolver factory: {@link Workspaces.resolvers} pre-wired
212
+ * over {@link Workspaces.layerWithConfigDependencies}, so the two
213
+ * `@effected/npm` contracts (`CatalogResolver`, `WorkspaceResolver`) need
214
+ * only a platform (`FileSystem` + `Path`) from the consumer.
215
+ *
216
+ * @remarks
217
+ * This is deliberately a **parameterized layer function, and the fresh
218
+ * layer per call is the feature**: layers memoize by reference, so each
219
+ * call mints an unmemoized layer whose root discovery re-runs — including
220
+ * a per-call `process.cwd()` read when `options.cwd` is omitted. A build
221
+ * tool that changes directory between manifests gets a correct
222
+ * re-discovery each time precisely because nothing is shared across
223
+ * calls. When you *want* sharing, bind one call's result to a `const` and
224
+ * provide that; the memoization rule is unchanged, this factory just
225
+ * refuses to hide it.
226
+ *
227
+ * Catalog assembly replays config-dependency `pnpmfile` hooks (the
228
+ * `layerWithConfigDependencies` path) — the semantics a real pnpm install
229
+ * has. Compose {@link Workspaces.resolvers} with {@link Workspaces.layer}
230
+ * yourself if config-dependency code must not run in process.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * import { Workspaces } from "@effected/workspaces";
235
+ * import { Effect } from "effect";
236
+ *
237
+ * const program = doSomethingWithResolvers.pipe(
238
+ * Effect.provide(Workspaces.resolverLayer()),
239
+ * );
240
+ * ```
241
+ */
242
+ static resolverLayer = resolverLayer;
243
+ /**
244
+ * The two `@effected/npm` resolver contracts, implemented for real.
245
+ *
246
+ * @remarks
247
+ * Provide this alongside `@effected/package-json`'s `Package.resolve` and
248
+ * a manifest's `catalog:` and `workspace:` specifiers resolve against the
249
+ * actual workspace instead of the no-op layers' `Option.none()`.
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * import { Package } from "@effected/package-json";
254
+ * import { Workspaces } from "@effected/workspaces";
255
+ * import { Layer } from "effect";
256
+ *
257
+ * const WorkspacesLayer = Workspaces.layer();
258
+ * const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
259
+ * ```
260
+ */
261
+ static resolvers = resolvers;
183
262
  };
184
263
 
185
264
  //#endregion