@effected/workspaces 0.7.0 → 0.9.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.
- package/LockfileReader.js +70 -0
- package/PackageManagerName.js +83 -2
- package/Publishability.js +56 -4
- package/README.md +17 -2
- package/ReleaseTag.js +260 -0
- package/VersioningStrategy.js +122 -0
- package/WorkspaceCatalogs.js +66 -0
- package/WorkspaceSnapshots.js +0 -0
- package/Workspaces.js +215 -148
- package/index.d.ts +840 -14
- package/index.js +3 -1
- package/package.json +8 -7
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
|
|
2
|
+
import { PublishabilityDetector } from "./Publishability.js";
|
|
3
|
+
import { ReleaseTag } from "./ReleaseTag.js";
|
|
4
|
+
import { Effect, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/VersioningStrategy.ts
|
|
7
|
+
/**
|
|
8
|
+
* How a workspace assigns versions across its publishable packages.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* - `single` — zero or one publishable package, so one tag names the release.
|
|
12
|
+
* - `fixed-group` — every publishable package sits inside one group that
|
|
13
|
+
* versions in lockstep, so one tag still names the release.
|
|
14
|
+
* - `independent` — publishable packages version separately, so a shared tag
|
|
15
|
+
* would be ambiguous and each package needs its own.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const VersioningStrategyType = Schema.Literals([
|
|
20
|
+
"single",
|
|
21
|
+
"fixed-group",
|
|
22
|
+
"independent"
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* How a workspace versions, and the tagging that follows from it.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Built either purely with {@link VersioningStrategy.classify}, or from a live
|
|
29
|
+
* workspace with {@link VersioningStrategy.detect}.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { VersioningStrategy } from "@effected/workspaces";
|
|
34
|
+
* import { Effect } from "effect";
|
|
35
|
+
*
|
|
36
|
+
* const program = Effect.gen(function* () {
|
|
37
|
+
* const strategy = yield* VersioningStrategy.detect({ fixedGroups });
|
|
38
|
+
* return strategy.tagsFor(released).map((tag) => tag.value);
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
var VersioningStrategy = class VersioningStrategy extends Schema.Class("VersioningStrategy")({
|
|
45
|
+
/** The classification. */
|
|
46
|
+
type: VersioningStrategyType,
|
|
47
|
+
/** The groups classification was performed against, as supplied. */
|
|
48
|
+
fixedGroups: Schema.Array(Schema.Array(Schema.String)),
|
|
49
|
+
/** The publishable package names, sorted and de-duplicated. */
|
|
50
|
+
publishablePackages: Schema.Array(Schema.String)
|
|
51
|
+
}) {
|
|
52
|
+
/**
|
|
53
|
+
* Whether a release needs one tag per package rather than one shared tag.
|
|
54
|
+
*/
|
|
55
|
+
get perPackageTags() {
|
|
56
|
+
return this.type === "independent";
|
|
57
|
+
}
|
|
58
|
+
/** The tag style this strategy implies. */
|
|
59
|
+
get tagStyle() {
|
|
60
|
+
return this.perPackageTags ? "scoped" : "single";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Classify a workspace from its publishable package names and fixed groups.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Pure and total — no IO, no error channel. `packages` is sorted and
|
|
67
|
+
* de-duplicated first, so a name listed twice cannot inflate a one-package
|
|
68
|
+
* repo into an independent one.
|
|
69
|
+
*/
|
|
70
|
+
static classify(options) {
|
|
71
|
+
const fixedGroups = options.fixedGroups ?? [];
|
|
72
|
+
const packages = [...new Set(options.packages)].sort();
|
|
73
|
+
const type = packages.length <= 1 ? "single" : fixedGroups.some((group) => packages.every((name) => group.includes(name))) ? "fixed-group" : "independent";
|
|
74
|
+
return VersioningStrategy.make({
|
|
75
|
+
type,
|
|
76
|
+
fixedGroups,
|
|
77
|
+
publishablePackages: packages
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Classify the ambient workspace: enumerate its packages, keep the ones the
|
|
82
|
+
* {@link PublishabilityDetector} says publish somewhere, and classify those.
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* The publishability question is asked through the service precisely so a
|
|
86
|
+
* consumer with its own rules — honouring a release tool's ignore list, say —
|
|
87
|
+
* swaps the layer instead of filtering afterwards.
|
|
88
|
+
*/
|
|
89
|
+
static detect = Effect.fn("VersioningStrategy.detect")(function* (options) {
|
|
90
|
+
const discovery = yield* WorkspaceDiscovery;
|
|
91
|
+
const publishability = yield* PublishabilityDetector;
|
|
92
|
+
const packages = yield* discovery.listPackages();
|
|
93
|
+
const publishable = [];
|
|
94
|
+
for (const candidate of packages) if ((yield* publishability.detect(candidate)).length > 0) publishable.push(candidate.name);
|
|
95
|
+
return VersioningStrategy.classify({
|
|
96
|
+
packages: publishable,
|
|
97
|
+
...options?.fixedGroups !== void 0 && { fixedGroups: options.fixedGroups }
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* The tags a release of `releases` produces under this strategy.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* Under `independent` this is one {@link ReleaseTag} per release, in the
|
|
105
|
+
* order given. Under `single` and `fixed-group` it is exactly one shared tag
|
|
106
|
+
* carrying the **first** release's version — every release in a lockstep
|
|
107
|
+
* batch shares a version by construction, so the choice is only visible on a
|
|
108
|
+
* batch that should not exist. Whether a batch actually agreed is a property
|
|
109
|
+
* of that batch rather than of the workspace, so it stays the caller's
|
|
110
|
+
* one-line check rather than a field here.
|
|
111
|
+
*
|
|
112
|
+
* An empty batch produces no tags under either style.
|
|
113
|
+
*/
|
|
114
|
+
tagsFor(releases, options) {
|
|
115
|
+
if (releases.length === 0) return [];
|
|
116
|
+
if (this.perPackageTags) return releases.map((release) => ReleaseTag.scoped(release.name, release.version, options));
|
|
117
|
+
return [ReleaseTag.single(releases[0].version, options)];
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
//#endregion
|
|
122
|
+
export { VersioningStrategy, VersioningStrategyType };
|
package/WorkspaceCatalogs.js
CHANGED
|
@@ -283,6 +283,8 @@ const validatePnpmWorkspaceCatalogs = (document) => {
|
|
|
283
283
|
catalogs: "catalogs"
|
|
284
284
|
}));
|
|
285
285
|
};
|
|
286
|
+
/** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
|
|
287
|
+
const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceCatalogs.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
|
|
286
288
|
/**
|
|
287
289
|
* Assembles a workspace's catalogs, package-manager-aware.
|
|
288
290
|
*
|
|
@@ -426,6 +428,70 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
426
428
|
*/
|
|
427
429
|
static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
|
|
428
430
|
/**
|
|
431
|
+
* A test double satisfying the full {@link WorkspaceCatalogsShape} with no
|
|
432
|
+
* filesystem, lockfile read, or hook replay.
|
|
433
|
+
*
|
|
434
|
+
* @remarks
|
|
435
|
+
* There is **no honest default catalog set**: an empty `CatalogSet` that
|
|
436
|
+
* looks like a legitimate answer is the "every dependency looks newly added"
|
|
437
|
+
* failure class the live assembler hard-fails to prevent, so every method
|
|
438
|
+
* **dies** with an instructive defect until stubbed — a test-wiring mistake
|
|
439
|
+
* fails loudly as a defect rather than succeeding with a lie or failing with
|
|
440
|
+
* a dishonest typed error.
|
|
441
|
+
*
|
|
442
|
+
* The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
|
|
443
|
+
* derived-from-the-primary rule: when a `set` override is supplied,
|
|
444
|
+
* `resolveSpecifier` answers from that `CatalogSet`'s own
|
|
445
|
+
* {@link CatalogSet.resolveSpecifier} — exactly what the live service runs
|
|
446
|
+
* over its assembled set — so the two stay consistent by construction.
|
|
447
|
+
* `releaseAgeGate` and `importerVersions` are **not** derivable from a
|
|
448
|
+
* catalog set (the gate comes from release-age keys and hook contributions,
|
|
449
|
+
* the importer index from the lockfile's importer blocks — neither is in a
|
|
450
|
+
* `CatalogSet`) and always die unless stubbed.
|
|
451
|
+
*
|
|
452
|
+
* @example
|
|
453
|
+
* ```ts
|
|
454
|
+
* import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
|
|
455
|
+
* import { Effect } from "effect";
|
|
456
|
+
*
|
|
457
|
+
* const double = WorkspaceCatalogs.makeTest({
|
|
458
|
+
* set: () => Effect.succeed(CatalogSet.fromCatalogs({ default: { effect: "4.0.0" } })),
|
|
459
|
+
* });
|
|
460
|
+
* // `resolveSpecifier` now answers consistently from that set.
|
|
461
|
+
* ```
|
|
462
|
+
*/
|
|
463
|
+
static makeTest = (overrides = {}) => {
|
|
464
|
+
const set = overrides.set;
|
|
465
|
+
return {
|
|
466
|
+
set: () => unstubbed("set"),
|
|
467
|
+
resolveSpecifier: set !== void 0 ? (dependency, specifier) => Effect.map(set(), (catalogs) => catalogs.resolveSpecifier(dependency, specifier)) : () => unstubbed("resolveSpecifier"),
|
|
468
|
+
releaseAgeGate: () => unstubbed("releaseAgeGate"),
|
|
469
|
+
importerVersions: () => unstubbed("importerVersions"),
|
|
470
|
+
...overrides
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The test layer: {@link WorkspaceCatalogs.makeTest} behind `Layer.succeed`,
|
|
475
|
+
* so a suite provides only the methods it exercises.
|
|
476
|
+
*
|
|
477
|
+
* @remarks
|
|
478
|
+
* A parameterized layer factory mints a **fresh reference per call**, and
|
|
479
|
+
* layers memoize by reference — bind the result to a `const` and reuse it
|
|
480
|
+
* rather than calling `layerTest(...)` at each composition site.
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* ```ts
|
|
484
|
+
* import { CatalogSet, WorkspaceCatalogs } from "@effected/workspaces";
|
|
485
|
+
* import { Effect } from "effect";
|
|
486
|
+
*
|
|
487
|
+
* const TestCatalogs = WorkspaceCatalogs.layerTest({
|
|
488
|
+
* set: () => Effect.succeed(CatalogSet.empty()),
|
|
489
|
+
* });
|
|
490
|
+
* // program.pipe(Effect.provide(TestCatalogs))
|
|
491
|
+
* ```
|
|
492
|
+
*/
|
|
493
|
+
static layerTest = (overrides = {}) => Layer.succeed(WorkspaceCatalogs, WorkspaceCatalogs.makeTest(overrides));
|
|
494
|
+
/**
|
|
429
495
|
* The real implementation of `@effected/npm`'s `CatalogResolver` contract —
|
|
430
496
|
* the one `@effected/package-json` declares but cannot fill.
|
|
431
497
|
*
|
package/WorkspaceSnapshots.js
CHANGED
|
Binary file
|
package/Workspaces.js
CHANGED
|
@@ -3,183 +3,250 @@ 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
|
|
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 } = LocalExec.prefixes(detected.value.name);
|
|
46
|
+
return Option.some(ExecContext.make({
|
|
47
|
+
label: detected.value.name,
|
|
48
|
+
prefix,
|
|
49
|
+
dlxPrefix,
|
|
50
|
+
directory: root.value
|
|
51
|
+
}));
|
|
52
|
+
}) };
|
|
53
|
+
}));
|
|
171
54
|
/**
|
|
172
55
|
* The composite layers.
|
|
173
56
|
*
|
|
174
57
|
* @public
|
|
175
58
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
59
|
+
var Workspaces = class {
|
|
60
|
+
constructor() {}
|
|
61
|
+
/**
|
|
62
|
+
* Every service that needs only a filesystem: root, package-manager
|
|
63
|
+
* detection, discovery, lockfile reading, catalogs and publishability.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Requires core `FileSystem` and `Path`, which the consumer provides at the
|
|
67
|
+
* edge (`@effect/platform-node`, `@effect/platform-bun`, or a test's
|
|
68
|
+
* `FileSystem.layerNoop`).
|
|
69
|
+
*
|
|
70
|
+
* **Bind the result to a `const`.** This is a parameterized factory and
|
|
71
|
+
* layers memoize by reference, so calling it twice builds everything twice.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
76
|
+
* import { Layer } from "effect";
|
|
77
|
+
*
|
|
78
|
+
* const WorkspacesLayer = Workspaces.layer();
|
|
79
|
+
* const AppLayer = Layer.provide(WorkspacesLayer, PlatformLayer);
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
static layer = layer;
|
|
83
|
+
/**
|
|
84
|
+
* The git-free composite, but with catalog assembly that **replays config
|
|
85
|
+
* dependency `pnpmfile.cjs` hooks** —
|
|
86
|
+
* {@link WorkspaceCatalogs.layerWithConfigDependencies} in place of the
|
|
87
|
+
* default no-op catalogs layer.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* Identical requirement set to {@link Workspaces.layer}; the only
|
|
91
|
+
* difference is that config-dependency code is executed in process. Opt in
|
|
92
|
+
* deliberately — the default {@link Workspaces.layer} never executes
|
|
93
|
+
* config-dependency code.
|
|
94
|
+
*
|
|
95
|
+
* **Bind the result to a `const`.**
|
|
96
|
+
*/
|
|
97
|
+
static layerWithConfigDependencies = layerWithConfigDependencies;
|
|
98
|
+
/**
|
|
99
|
+
* The git-free composite plus {@link ChangeDetector} and
|
|
100
|
+
* {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* The extra requirement is core's `ChildProcessSpawner` (behind `Git`),
|
|
104
|
+
* which is why it is a separate layer rather than a flag: a consumer that
|
|
105
|
+
* never detects changes or reads at a ref should not have to be able to
|
|
106
|
+
* spawn a subprocess. The consumer provides `ChildProcessSpawner` once at
|
|
107
|
+
* the edge (`@effect/platform-node`'s `NodeServices.layer`); a test
|
|
108
|
+
* provides `Layer.succeed(Git, …)` and needs no repository on disk.
|
|
109
|
+
*/
|
|
110
|
+
static layerWithGit = layerWithGit;
|
|
111
|
+
/**
|
|
112
|
+
* This package's implementation of `@effected/commands`' `LocalExec`
|
|
113
|
+
* contract: how to run a project-local binary here.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* **An inverted contract, the `@effected/npm` `CatalogResolver`
|
|
117
|
+
* precedent.** Tool discovery needs package-manager detection and
|
|
118
|
+
* workspace-root resolution, both of which live here — but a direct edge
|
|
119
|
+
* from `@effected/commands` to this package would make that boundary-tier
|
|
120
|
+
* package integrated, and through the planned `npm` → `commands` edge
|
|
121
|
+
* would drag `npm`, `lockfiles` (pure!) and `package-json` up a tier with
|
|
122
|
+
* it. So `commands` declares the narrow contract and we ship the layer.
|
|
123
|
+
*
|
|
124
|
+
* **The argv knowledge is not duplicated.** `LocalExec.prefixes(name)` is
|
|
125
|
+
* the one home of the four managers' `exec`/`dlx` prefixes; this layer
|
|
126
|
+
* detects *which* manager owns the directory and asks `commands` what that
|
|
127
|
+
* manager's argv looks like. Neither package reimplements the other's
|
|
128
|
+
* half.
|
|
129
|
+
*
|
|
130
|
+
* **`None` is success.** Outside any workspace — and inside one whose
|
|
131
|
+
* manager cannot be identified — the answer is `Option.none()`: "there is
|
|
132
|
+
* no project-local way to run tools here" is an ordinary fact, not an
|
|
133
|
+
* exceptional one, and a consumer running in a bare directory should not
|
|
134
|
+
* have to catch an error to learn it. The contract's typed
|
|
135
|
+
* `LocalExecError` is reserved for **mechanism** failure — a manifest that
|
|
136
|
+
* exists but cannot be read or parsed, which means something is broken
|
|
137
|
+
* rather than absent. That is npm's resolver convention, adopted
|
|
138
|
+
* verbatim.
|
|
139
|
+
*
|
|
140
|
+
* `directory` is the resolved **workspace root**, not the caller's cwd: a
|
|
141
|
+
* project-local launcher has to run where the workspace is.
|
|
142
|
+
*
|
|
143
|
+
* A consumer with no monorepo never needs this layer, and therefore never
|
|
144
|
+
* installs this package — `LocalExec.layerNone` and `LocalExec.layerFor`
|
|
145
|
+
* are one-liners in `@effected/commands`.
|
|
146
|
+
*
|
|
147
|
+
* **Bind the result to a `const`** — a parameterized layer factory mints a
|
|
148
|
+
* fresh reference per call and layers memoize by reference.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { ToolDiscovery } from "@effected/commands";
|
|
153
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
154
|
+
* import { Layer } from "effect";
|
|
155
|
+
*
|
|
156
|
+
* const AppLayer = ToolDiscovery.layer.pipe(
|
|
157
|
+
* Layer.provide(Workspaces.localExecLayer()),
|
|
158
|
+
* Layer.provide(Workspaces.layer()),
|
|
159
|
+
* Layer.provide(NodeServices.layer),
|
|
160
|
+
* );
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
static localExecLayer = localExecLayer;
|
|
164
|
+
/**
|
|
165
|
+
* Resolve every `catalog:` and `workspace:` specifier in one `Manifest`
|
|
166
|
+
* against the real workspace, in one call — the 90% path. Decode stays at
|
|
167
|
+
* the consumer's edge: build the `Manifest` with `Manifest.decode` (from
|
|
168
|
+
* `@effected/npm`), hand it here, and get a new `Manifest` back with
|
|
169
|
+
* concrete ranges; `toRecord()` returns to the wire shape.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* Composes `manifest.resolve()` with a fresh {@link Workspaces.resolverLayer}
|
|
173
|
+
* per call, so the workspace root is re-discovered from `options.cwd` (or
|
|
174
|
+
* the current `process.cwd()`) on every invocation. Consumers processing
|
|
175
|
+
* many manifests should check `manifest.needsResolution` first and skip
|
|
176
|
+
* the call entirely when no dependency field carries a
|
|
177
|
+
* `catalog:`/`workspace:` specifier — that predicate is pure and avoids
|
|
178
|
+
* catalog assembly altogether.
|
|
179
|
+
*
|
|
180
|
+
* A specifier the workspace cannot answer fails typed as
|
|
181
|
+
* `UnresolvedDependencyError`; assembly and mechanism failures surface as
|
|
182
|
+
* `CatalogAssemblyError` / `DependencyResolutionError`.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* import { Manifest } from "@effected/npm";
|
|
187
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
188
|
+
* import { Effect } from "effect";
|
|
189
|
+
*
|
|
190
|
+
* const program = Effect.gen(function* () {
|
|
191
|
+
* const manifest = yield* Manifest.decode({ dependencies: { effect: "catalog:" } });
|
|
192
|
+
* const resolved = manifest.needsResolution ? yield* Workspaces.resolveManifest(manifest) : manifest;
|
|
193
|
+
* return resolved.toRecord();
|
|
194
|
+
* });
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
static resolveManifest = resolveManifest;
|
|
198
|
+
/**
|
|
199
|
+
* The one-call resolver factory: {@link Workspaces.resolvers} pre-wired
|
|
200
|
+
* over {@link Workspaces.layerWithConfigDependencies}, so the two
|
|
201
|
+
* `@effected/npm` contracts (`CatalogResolver`, `WorkspaceResolver`) need
|
|
202
|
+
* only a platform (`FileSystem` + `Path`) from the consumer.
|
|
203
|
+
*
|
|
204
|
+
* @remarks
|
|
205
|
+
* This is deliberately a **parameterized layer function, and the fresh
|
|
206
|
+
* layer per call is the feature**: layers memoize by reference, so each
|
|
207
|
+
* call mints an unmemoized layer whose root discovery re-runs — including
|
|
208
|
+
* a per-call `process.cwd()` read when `options.cwd` is omitted. A build
|
|
209
|
+
* tool that changes directory between manifests gets a correct
|
|
210
|
+
* re-discovery each time precisely because nothing is shared across
|
|
211
|
+
* calls. When you *want* sharing, bind one call's result to a `const` and
|
|
212
|
+
* provide that; the memoization rule is unchanged, this factory just
|
|
213
|
+
* refuses to hide it.
|
|
214
|
+
*
|
|
215
|
+
* Catalog assembly replays config-dependency `pnpmfile` hooks (the
|
|
216
|
+
* `layerWithConfigDependencies` path) — the semantics a real pnpm install
|
|
217
|
+
* has. Compose {@link Workspaces.resolvers} with {@link Workspaces.layer}
|
|
218
|
+
* yourself if config-dependency code must not run in process.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
223
|
+
* import { Effect } from "effect";
|
|
224
|
+
*
|
|
225
|
+
* const program = doSomethingWithResolvers.pipe(
|
|
226
|
+
* Effect.provide(Workspaces.resolverLayer()),
|
|
227
|
+
* );
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
static resolverLayer = resolverLayer;
|
|
231
|
+
/**
|
|
232
|
+
* The two `@effected/npm` resolver contracts, implemented for real.
|
|
233
|
+
*
|
|
234
|
+
* @remarks
|
|
235
|
+
* Provide this alongside `@effected/package-json`'s `Package.resolve` and
|
|
236
|
+
* a manifest's `catalog:` and `workspace:` specifiers resolve against the
|
|
237
|
+
* actual workspace instead of the no-op layers' `Option.none()`.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* import { Package } from "@effected/package-json";
|
|
242
|
+
* import { Workspaces } from "@effected/workspaces";
|
|
243
|
+
* import { Layer } from "effect";
|
|
244
|
+
*
|
|
245
|
+
* const WorkspacesLayer = Workspaces.layer();
|
|
246
|
+
* const Resolvers = Workspaces.resolvers.pipe(Layer.provide(WorkspacesLayer));
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
static resolvers = resolvers;
|
|
183
250
|
};
|
|
184
251
|
|
|
185
252
|
//#endregion
|