@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.
- package/CatalogAssemblyError.js +43 -0
- package/ChangeDetector.js +154 -0
- package/ConfigDependencyHooks.js +153 -0
- package/DependencyGraph.js +241 -0
- package/LICENSE +21 -0
- package/LockfileReader.js +118 -0
- package/PackageManagerName.js +248 -0
- package/Publishability.js +73 -0
- package/README.md +165 -0
- package/WorkspaceCatalogs.js +392 -0
- package/WorkspaceDiscovery.js +338 -0
- package/WorkspacePackage.js +247 -0
- package/WorkspaceRoot.js +90 -0
- package/WorkspaceSnapshots.js +0 -0
- package/WorkspaceStateSnapshot.js +165 -0
- package/Workspaces.js +113 -0
- package/WorkspacesSync.js +205 -0
- package/index.d.ts +1559 -0
- package/index.js +17 -0
- package/internal/catalogs.js +70 -0
- package/internal/enumerate.js +75 -0
- package/internal/limits.js +20 -0
- package/internal/patterns.js +62 -0
- package/internal/traverse.js +94 -0
- package/package.json +61 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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
|
+
[](https://www.npmjs.com/package/@effected/workspaces)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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)
|