@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,338 @@
|
|
|
1
|
+
import { enumerate } from "./internal/enumerate.js";
|
|
2
|
+
import { readPatterns } from "./internal/patterns.js";
|
|
3
|
+
import { WorkspacePackage } from "./WorkspacePackage.js";
|
|
4
|
+
import { WorkspaceRoot } from "./WorkspaceRoot.js";
|
|
5
|
+
import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
6
|
+
import { GlobSet } from "@effected/glob";
|
|
7
|
+
import { DependencyResolutionError, WorkspaceResolver } from "@effected/npm";
|
|
8
|
+
|
|
9
|
+
//#region src/WorkspaceDiscovery.ts
|
|
10
|
+
/**
|
|
11
|
+
* Raised when a workspace member's `package.json` cannot be read, parsed, or
|
|
12
|
+
* used — it is missing, malformed, or lacks a `name` or `version`.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* `kind` is the discriminant a caller branches on; `cause` preserves the
|
|
16
|
+
* originating failure rather than flattening it into a sentence.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var WorkspaceDiscoveryError = class extends Schema.TaggedErrorClass()("WorkspaceDiscoveryError", {
|
|
21
|
+
/** The workspace root discovery was running against. */
|
|
22
|
+
root: Schema.String,
|
|
23
|
+
/** The file that failed. */
|
|
24
|
+
path: Schema.String,
|
|
25
|
+
/** What went wrong with it. */
|
|
26
|
+
kind: Schema.Literals([
|
|
27
|
+
"read",
|
|
28
|
+
"invalidJson",
|
|
29
|
+
"invalidShape",
|
|
30
|
+
"invalidYaml",
|
|
31
|
+
"missingName",
|
|
32
|
+
"missingVersion"
|
|
33
|
+
]),
|
|
34
|
+
/** The originating failure, if there was one. */
|
|
35
|
+
cause: Schema.Defect()
|
|
36
|
+
}) {
|
|
37
|
+
/** Renders the failing file and kind into a one-line message. */
|
|
38
|
+
get message() {
|
|
39
|
+
return `Workspace discovery failed at ${this.path} (${this.kind})`;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Raised when a `packages:` pattern cannot be enumerated: its base directory is
|
|
44
|
+
* absent (usually a typo), the descent exceeded its depth cap, or the visit
|
|
45
|
+
* budget was exhausted.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
var WorkspacePatternError = class extends Schema.TaggedErrorClass()("WorkspacePatternError", {
|
|
50
|
+
/** The workspace root the patterns were expanded against. */
|
|
51
|
+
root: Schema.String,
|
|
52
|
+
/** The offending pattern, verbatim. */
|
|
53
|
+
pattern: Schema.String,
|
|
54
|
+
/** Why it could not be enumerated. */
|
|
55
|
+
kind: Schema.Literals([
|
|
56
|
+
"missingBaseDir",
|
|
57
|
+
"uncompilable",
|
|
58
|
+
"depthExceeded",
|
|
59
|
+
"budgetExceeded",
|
|
60
|
+
"unreadableDirectory"
|
|
61
|
+
]),
|
|
62
|
+
/** A short, structured detail — the missing directory, or the bound exceeded. */
|
|
63
|
+
detail: Schema.String
|
|
64
|
+
}) {
|
|
65
|
+
/** Renders the pattern and failure kind into a one-line message. */
|
|
66
|
+
get message() {
|
|
67
|
+
return `Workspace pattern "${this.pattern}" could not be enumerated (${this.kind}: ${this.detail})`;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Raised when a workspace package is requested by a name no member carries.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* `available` lists every known member, which is what makes the error
|
|
75
|
+
* actionable — a typo is obvious next to the list it missed.
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
var PackageNotFoundError = class extends Schema.TaggedErrorClass()("PackageNotFoundError", {
|
|
80
|
+
/** The name that was requested. */
|
|
81
|
+
name: Schema.String,
|
|
82
|
+
/** Every workspace package name that does exist. */
|
|
83
|
+
available: Schema.Array(Schema.String)
|
|
84
|
+
}) {
|
|
85
|
+
/** Renders the requested name into a one-line message. */
|
|
86
|
+
get message() {
|
|
87
|
+
return `No workspace package named "${this.name}"`;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Top-level facts about a workspace: where it is, what manages it, and the
|
|
92
|
+
* patterns that define its membership.
|
|
93
|
+
*
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
var WorkspaceInfo = class extends Schema.Class("WorkspaceInfo")({
|
|
97
|
+
/** Absolute path to the workspace root. */
|
|
98
|
+
root: Schema.String,
|
|
99
|
+
/** The `packages:` patterns, verbatim. */
|
|
100
|
+
patterns: Schema.Array(Schema.String)
|
|
101
|
+
}) {};
|
|
102
|
+
/** The enumeration failure kinds map straight onto the pattern-error kinds. */
|
|
103
|
+
const patternKindOf = (kind) => kind;
|
|
104
|
+
/**
|
|
105
|
+
* Discovers the packages of a workspace.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Layer construction is O(1): the root walk, pattern read, enumeration and
|
|
109
|
+
* per-package decode all happen on the first method call and are memoized for
|
|
110
|
+
* the lifetime of the layer. A Vitest reporter that builds the layer per call
|
|
111
|
+
* site and never queries it pays nothing.
|
|
112
|
+
*
|
|
113
|
+
* The memo is **success-only**. `Effect.cached` memoizes the first `Exit`,
|
|
114
|
+
* *including an interrupt* — an init interrupted by an unrelated timeout would
|
|
115
|
+
* otherwise brick the layer permanently with a cause outside its declared error
|
|
116
|
+
* channel. A failure or interrupt is therefore retried on the next call, which
|
|
117
|
+
* is a deliberate behaviour change from the v3 library.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { WorkspaceDiscovery } from "@effected/workspaces";
|
|
122
|
+
* import { Effect } from "effect";
|
|
123
|
+
*
|
|
124
|
+
* const program = Effect.gen(function* () {
|
|
125
|
+
* const discovery = yield* WorkspaceDiscovery;
|
|
126
|
+
* const packages = yield* discovery.listPackages();
|
|
127
|
+
* return packages.map((p) => p.name);
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @public
|
|
132
|
+
*/
|
|
133
|
+
var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@effected/workspaces/WorkspaceDiscovery") {
|
|
134
|
+
/**
|
|
135
|
+
* Builds the service. Root resolution is one explicit concern: `cwd` is an
|
|
136
|
+
* option here, never an ambient `process.cwd()` read inside a method.
|
|
137
|
+
*/
|
|
138
|
+
static make = (options) => Effect.gen(function* () {
|
|
139
|
+
const roots = yield* WorkspaceRoot;
|
|
140
|
+
const fs = yield* FileSystem.FileSystem;
|
|
141
|
+
const path = yield* Path.Path;
|
|
142
|
+
/** Read one `package.json` into the tolerant discovery projection. */
|
|
143
|
+
const readPackage = (root, directory, relativePath) => Effect.gen(function* () {
|
|
144
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
145
|
+
const content = yield* fs.readFileString(packageJsonPath).pipe(Effect.mapError((cause) => new WorkspaceDiscoveryError({
|
|
146
|
+
root,
|
|
147
|
+
path: packageJsonPath,
|
|
148
|
+
kind: "read",
|
|
149
|
+
cause
|
|
150
|
+
})));
|
|
151
|
+
const parsed = yield* Effect.try({
|
|
152
|
+
try: () => JSON.parse(content),
|
|
153
|
+
catch: (cause) => new WorkspaceDiscoveryError({
|
|
154
|
+
root,
|
|
155
|
+
path: packageJsonPath,
|
|
156
|
+
kind: "invalidJson",
|
|
157
|
+
cause
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return yield* Effect.fail(new WorkspaceDiscoveryError({
|
|
161
|
+
root,
|
|
162
|
+
path: packageJsonPath,
|
|
163
|
+
kind: "invalidShape",
|
|
164
|
+
cause: /* @__PURE__ */ new Error("package.json is not a JSON object")
|
|
165
|
+
}));
|
|
166
|
+
const raw = parsed;
|
|
167
|
+
const name = raw.name;
|
|
168
|
+
if (typeof name !== "string" || name.length === 0) return yield* Effect.fail(new WorkspaceDiscoveryError({
|
|
169
|
+
root,
|
|
170
|
+
path: packageJsonPath,
|
|
171
|
+
kind: "missingName",
|
|
172
|
+
cause: void 0
|
|
173
|
+
}));
|
|
174
|
+
const version = raw.version;
|
|
175
|
+
if (typeof version !== "string" || version.length === 0) return yield* Effect.fail(new WorkspaceDiscoveryError({
|
|
176
|
+
root,
|
|
177
|
+
path: packageJsonPath,
|
|
178
|
+
kind: "missingVersion",
|
|
179
|
+
cause: void 0
|
|
180
|
+
}));
|
|
181
|
+
return yield* Schema.decodeUnknownEffect(WorkspacePackage)({
|
|
182
|
+
name,
|
|
183
|
+
version,
|
|
184
|
+
path: directory,
|
|
185
|
+
packageJsonPath,
|
|
186
|
+
relativePath,
|
|
187
|
+
...typeof raw.private === "boolean" ? { private: raw.private } : {},
|
|
188
|
+
...isStringRecord(raw.dependencies) ? { dependencies: raw.dependencies } : {},
|
|
189
|
+
...isStringRecord(raw.devDependencies) ? { devDependencies: raw.devDependencies } : {},
|
|
190
|
+
...isStringRecord(raw.peerDependencies) ? { peerDependencies: raw.peerDependencies } : {},
|
|
191
|
+
...isStringRecord(raw.optionalDependencies) ? { optionalDependencies: raw.optionalDependencies } : {},
|
|
192
|
+
...raw.publishConfig !== void 0 && raw.publishConfig !== null ? { publishConfig: raw.publishConfig } : {}
|
|
193
|
+
}).pipe(Effect.catchTag("SchemaError", (cause) => new WorkspaceDiscoveryError({
|
|
194
|
+
root,
|
|
195
|
+
path: packageJsonPath,
|
|
196
|
+
kind: "invalidShape",
|
|
197
|
+
cause
|
|
198
|
+
})));
|
|
199
|
+
});
|
|
200
|
+
const discover = Effect.gen(function* () {
|
|
201
|
+
const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
|
|
202
|
+
const patterns = yield* readPatterns(root).pipe(Effect.mapError((failure) => new WorkspaceDiscoveryError({
|
|
203
|
+
root,
|
|
204
|
+
path: failure.path,
|
|
205
|
+
kind: failure.kind,
|
|
206
|
+
cause: failure.cause
|
|
207
|
+
})));
|
|
208
|
+
const directories = yield* enumerate(root, yield* GlobSet.compile(patterns).pipe(Effect.mapError((error) => new WorkspacePatternError({
|
|
209
|
+
root,
|
|
210
|
+
pattern: error.pattern,
|
|
211
|
+
kind: "uncompilable",
|
|
212
|
+
detail: error.message
|
|
213
|
+
}))), { maxDepth: options?.maxDepth ?? 32 }).pipe(Effect.mapError((failure) => new WorkspacePatternError({
|
|
214
|
+
root,
|
|
215
|
+
pattern: failure.pattern,
|
|
216
|
+
kind: patternKindOf(failure.kind),
|
|
217
|
+
detail: failure.detail
|
|
218
|
+
})));
|
|
219
|
+
const members = yield* Effect.forEach(directories.filter((entry) => entry.relativePath !== "." && entry.path !== root), (entry) => readPackage(root, entry.path, entry.relativePath), { concurrency: 10 });
|
|
220
|
+
const packages = [yield* readPackage(root, root, "."), ...members];
|
|
221
|
+
yield* Effect.logDebug("Workspace packages discovered").pipe(Effect.annotateLogs({
|
|
222
|
+
"workspace.root": root,
|
|
223
|
+
"workspace.packages.count": packages.length
|
|
224
|
+
}));
|
|
225
|
+
return {
|
|
226
|
+
info: WorkspaceInfo.make({
|
|
227
|
+
root,
|
|
228
|
+
patterns
|
|
229
|
+
}),
|
|
230
|
+
packages
|
|
231
|
+
};
|
|
232
|
+
}).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
|
|
233
|
+
const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(discover, Duration.infinity);
|
|
234
|
+
const memo = Effect.onExit(resolveOnce, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate);
|
|
235
|
+
const packages = memo.pipe(Effect.map((state) => state.packages));
|
|
236
|
+
/**
|
|
237
|
+
* The longest-prefix index, built once per package list.
|
|
238
|
+
*
|
|
239
|
+
* Keyed on the package array's identity: the memo hands back the *same*
|
|
240
|
+
* array on every call, so this is a hit for the whole life of the memo, and
|
|
241
|
+
* `refresh()` produces a fresh array — a cache miss — which is exactly the
|
|
242
|
+
* invalidation we want. No staleness is representable.
|
|
243
|
+
*/
|
|
244
|
+
const ownerIndexes = /* @__PURE__ */ new WeakMap();
|
|
245
|
+
const owners = (all) => {
|
|
246
|
+
const cached = ownerIndexes.get(all);
|
|
247
|
+
if (cached !== void 0) return cached;
|
|
248
|
+
const index = all.map((pkg) => ({
|
|
249
|
+
prefix: pkg.path.endsWith(path.sep) ? pkg.path : pkg.path + path.sep,
|
|
250
|
+
package: pkg
|
|
251
|
+
})).sort((a, b) => b.prefix.length - a.prefix.length);
|
|
252
|
+
ownerIndexes.set(all, index);
|
|
253
|
+
return index;
|
|
254
|
+
};
|
|
255
|
+
const ownerOf = (filePath, index) => {
|
|
256
|
+
for (const entry of index) if (filePath.startsWith(entry.prefix)) return Option.some(entry.package);
|
|
257
|
+
return Option.none();
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
info: Effect.fn("WorkspaceDiscovery.info")(function* () {
|
|
261
|
+
return (yield* memo).info;
|
|
262
|
+
}),
|
|
263
|
+
listPackages: Effect.fn("WorkspaceDiscovery.listPackages")(function* () {
|
|
264
|
+
return yield* packages;
|
|
265
|
+
}),
|
|
266
|
+
importerMap: Effect.fn("WorkspaceDiscovery.importerMap")(function* () {
|
|
267
|
+
const all = yield* packages;
|
|
268
|
+
return new Map(all.map((pkg) => [pkg.relativePath, pkg]));
|
|
269
|
+
}),
|
|
270
|
+
getPackage: Effect.fn("WorkspaceDiscovery.getPackage")(function* (name) {
|
|
271
|
+
const all = yield* packages;
|
|
272
|
+
const found = all.find((pkg) => pkg.name === name);
|
|
273
|
+
if (found !== void 0) return found;
|
|
274
|
+
return yield* Effect.fail(new PackageNotFoundError({
|
|
275
|
+
name,
|
|
276
|
+
available: all.map((pkg) => pkg.name)
|
|
277
|
+
}));
|
|
278
|
+
}),
|
|
279
|
+
resolveFile: Effect.fn("WorkspaceDiscovery.resolveFile")(function* (filePath) {
|
|
280
|
+
const all = yield* packages;
|
|
281
|
+
return ownerOf(filePath, owners(all));
|
|
282
|
+
}),
|
|
283
|
+
resolveFiles: Effect.fn("WorkspaceDiscovery.resolveFiles")(function* (filePaths) {
|
|
284
|
+
const all = yield* packages;
|
|
285
|
+
const index = owners(all);
|
|
286
|
+
const seen = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const filePath of filePaths) {
|
|
288
|
+
const owner = ownerOf(filePath, index);
|
|
289
|
+
if (Option.isSome(owner)) seen.set(owner.value.name, owner.value);
|
|
290
|
+
}
|
|
291
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
292
|
+
}),
|
|
293
|
+
refresh: () => invalidate
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
/**
|
|
297
|
+
* The live layer.
|
|
298
|
+
*
|
|
299
|
+
* @remarks
|
|
300
|
+
* A parameterized layer factory mints a **fresh reference per call**, and
|
|
301
|
+
* layers memoize by reference — bind the result to a `const` and reuse it
|
|
302
|
+
* rather than calling `layer(...)` at each composition site.
|
|
303
|
+
*/
|
|
304
|
+
static layer = (options) => Layer.effect(WorkspaceDiscovery, WorkspaceDiscovery.make(options));
|
|
305
|
+
/**
|
|
306
|
+
* The real implementation of `@effected/npm`'s `WorkspaceResolver` contract
|
|
307
|
+
* — the one `@effected/package-json` declares but cannot fill.
|
|
308
|
+
*
|
|
309
|
+
* @remarks
|
|
310
|
+
* `versionOf` returns `Option.none()` for a name that is not a workspace
|
|
311
|
+
* member, per the contract's convention; the `DependencyResolutionError`
|
|
312
|
+
* channel is reserved for a failure of the resolution *mechanism* (an
|
|
313
|
+
* unfindable root, an unreadable manifest), never an ordinary miss.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```ts
|
|
317
|
+
* import { Package } from "@effected/package-json";
|
|
318
|
+
* import { WorkspaceDiscovery } from "@effected/workspaces";
|
|
319
|
+
* import { Layer } from "effect";
|
|
320
|
+
*
|
|
321
|
+
* const resolvers = WorkspaceDiscovery.workspaceResolver.pipe(
|
|
322
|
+
* Layer.provide(WorkspaceDiscovery.layer()),
|
|
323
|
+
* );
|
|
324
|
+
* // `Package.resolve` now resolves `workspace:*` for real.
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
static workspaceResolver = Layer.effect(WorkspaceResolver, Effect.gen(function* () {
|
|
328
|
+
const discovery = yield* WorkspaceDiscovery;
|
|
329
|
+
return { versionOf: (packageName) => discovery.listPackages().pipe(Effect.map((all) => Option.fromUndefinedOr(all.find((pkg) => pkg.name === packageName)?.version)), Effect.mapError((cause) => new DependencyResolutionError({
|
|
330
|
+
specifier: `workspace:${packageName}`,
|
|
331
|
+
cause
|
|
332
|
+
}))) };
|
|
333
|
+
}));
|
|
334
|
+
};
|
|
335
|
+
const isStringRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
336
|
+
|
|
337
|
+
//#endregion
|
|
338
|
+
export { PackageNotFoundError, WorkspaceDiscovery, WorkspaceDiscoveryError, WorkspaceInfo, WorkspacePatternError };
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Schema } from "effect";
|
|
2
|
+
import { GlobPattern } from "@effected/glob";
|
|
3
|
+
import { WorkspaceManifest } from "@effected/lockfiles";
|
|
4
|
+
import { Package } from "@effected/package-json";
|
|
5
|
+
|
|
6
|
+
//#region src/WorkspacePackage.ts
|
|
7
|
+
const EMPTY = Object.freeze(Object.create(null));
|
|
8
|
+
/**
|
|
9
|
+
* The `publishConfig` fields workspace tooling reads.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Deliberately narrow. `@effected/package-json` models `publishConfig` as an
|
|
13
|
+
* open `Record<string, unknown>` for round-trip fidelity, which preserves every
|
|
14
|
+
* key but types none of them; this is the typed projection of the four that
|
|
15
|
+
* decide *where and whether* a package publishes. Unknown keys are ignored, not
|
|
16
|
+
* rejected.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var PublishConfig = class extends Schema.Class("PublishConfig")({
|
|
21
|
+
/** Scoped-package visibility. Its presence overrides `private`. */
|
|
22
|
+
access: Schema.optionalKey(Schema.Literals(["public", "restricted"])),
|
|
23
|
+
/** The registry to publish to. */
|
|
24
|
+
registry: Schema.optionalKey(Schema.String),
|
|
25
|
+
/** A subdirectory to publish instead of the package root. */
|
|
26
|
+
directory: Schema.optionalKey(Schema.String),
|
|
27
|
+
/** The dist-tag to publish under. */
|
|
28
|
+
tag: Schema.optionalKey(Schema.String)
|
|
29
|
+
}) {};
|
|
30
|
+
const DependencyMap = Schema.Record(Schema.String, Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(EMPTY)), Schema.withConstructorDefault(Effect.succeed(EMPTY)));
|
|
31
|
+
/**
|
|
32
|
+
* Raised when a workspace member's `package.json` cannot be read or decoded
|
|
33
|
+
* into the strict `@effected/package-json` `Package` model.
|
|
34
|
+
*
|
|
35
|
+
* @remarks
|
|
36
|
+
* Discovery itself never raises this — it uses the tolerant projection. Only
|
|
37
|
+
* `WorkspacePackage.manifest` does, so opting into the strict model is an
|
|
38
|
+
* explicit, individually recoverable step.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
var WorkspaceManifestError = class extends Schema.TaggedErrorClass()("WorkspaceManifestError", {
|
|
43
|
+
/** Absolute path to the `package.json` that failed. */
|
|
44
|
+
packageJsonPath: Schema.String,
|
|
45
|
+
/** Whether the file could not be read, or read but not decoded. */
|
|
46
|
+
kind: Schema.Literals(["read", "decode"]),
|
|
47
|
+
/** The originating failure, preserved rather than flattened to a string. */
|
|
48
|
+
cause: Schema.Defect()
|
|
49
|
+
}) {
|
|
50
|
+
/** Renders the path and failure kind into a one-line message. */
|
|
51
|
+
get message() {
|
|
52
|
+
return `Failed to ${this.kind} package.json at ${this.packageJsonPath}`;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A single package inside a workspace: the discovery-relevant slice of its
|
|
57
|
+
* `package.json` plus its filesystem location.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* Produced by `WorkspaceDiscovery` for every directory the `packages:` patterns
|
|
61
|
+
* enumerate. The root package is always present with `relativePath` `"."`.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { WorkspacePackage } from "@effected/workspaces";
|
|
66
|
+
*
|
|
67
|
+
* const pkg = WorkspacePackage.make({
|
|
68
|
+
* name: "@my-org/utils",
|
|
69
|
+
* version: "1.0.0",
|
|
70
|
+
* path: "/repo/packages/utils",
|
|
71
|
+
* packageJsonPath: "/repo/packages/utils/package.json",
|
|
72
|
+
* relativePath: "packages/utils",
|
|
73
|
+
* });
|
|
74
|
+
*
|
|
75
|
+
* pkg.isRootWorkspace; // false
|
|
76
|
+
* pkg.unscopedName; // "utils"
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
81
|
+
var WorkspacePackage = class WorkspacePackage extends Schema.Class("WorkspacePackage")({
|
|
82
|
+
/** The package name. */
|
|
83
|
+
name: Schema.NonEmptyString,
|
|
84
|
+
/** The raw `version` string — deliberately not semver-validated. */
|
|
85
|
+
version: Schema.String,
|
|
86
|
+
/** Absolute path to the package directory. */
|
|
87
|
+
path: Schema.NonEmptyString,
|
|
88
|
+
/** Absolute path to the package's `package.json`. */
|
|
89
|
+
packageJsonPath: Schema.NonEmptyString,
|
|
90
|
+
/** POSIX path relative to the workspace root; `"."` for the root package. */
|
|
91
|
+
relativePath: Schema.String,
|
|
92
|
+
/** Whether the package is marked private. */
|
|
93
|
+
private: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)), Schema.withConstructorDefault(Effect.succeed(false))),
|
|
94
|
+
/** Production dependencies. */
|
|
95
|
+
dependencies: DependencyMap,
|
|
96
|
+
/** Development dependencies. */
|
|
97
|
+
devDependencies: DependencyMap,
|
|
98
|
+
/** Peer dependencies. */
|
|
99
|
+
peerDependencies: DependencyMap,
|
|
100
|
+
/** Optional dependencies. */
|
|
101
|
+
optionalDependencies: DependencyMap,
|
|
102
|
+
/** The `publishConfig` block, when present. */
|
|
103
|
+
publishConfig: Schema.optionalKey(PublishConfig)
|
|
104
|
+
}) {
|
|
105
|
+
/** Whether this is the workspace root package. */
|
|
106
|
+
get isRootWorkspace() {
|
|
107
|
+
return this.relativePath === ".";
|
|
108
|
+
}
|
|
109
|
+
/** Whether the package is publishable in principle (not marked private). */
|
|
110
|
+
get isPublic() {
|
|
111
|
+
return !this.private;
|
|
112
|
+
}
|
|
113
|
+
/** The npm scope (`@org`), or `Option.none()` for an unscoped name. */
|
|
114
|
+
get scope() {
|
|
115
|
+
const match = /^(@[^/]+)\//.exec(this.name);
|
|
116
|
+
return match === null ? Option.none() : Option.some(match[1]);
|
|
117
|
+
}
|
|
118
|
+
/** The name with any scope stripped. */
|
|
119
|
+
get unscopedName() {
|
|
120
|
+
const slash = this.name.indexOf("/");
|
|
121
|
+
return this.name.startsWith("@") && slash !== -1 ? this.name.slice(slash + 1) : this.name;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Every dependency, merged across the four kinds.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* Precedence on a name declared in several kinds runs
|
|
128
|
+
* `dependencies` \> `devDependencies` \> `peerDependencies` \>
|
|
129
|
+
* `optionalDependencies`.
|
|
130
|
+
*/
|
|
131
|
+
get allDependencies() {
|
|
132
|
+
return {
|
|
133
|
+
...this.optionalDependencies,
|
|
134
|
+
...this.peerDependencies,
|
|
135
|
+
...this.devDependencies,
|
|
136
|
+
...this.dependencies
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Whether `name` is a production dependency. */
|
|
140
|
+
hasDependency(name) {
|
|
141
|
+
return Object.hasOwn(this.dependencies, name);
|
|
142
|
+
}
|
|
143
|
+
/** Whether `name` is a development dependency. */
|
|
144
|
+
hasDevDependency(name) {
|
|
145
|
+
return Object.hasOwn(this.devDependencies, name);
|
|
146
|
+
}
|
|
147
|
+
/** Whether `name` is a peer dependency. */
|
|
148
|
+
hasPeerDependency(name) {
|
|
149
|
+
return Object.hasOwn(this.peerDependencies, name);
|
|
150
|
+
}
|
|
151
|
+
/** Whether `name` is an optional dependency. */
|
|
152
|
+
hasOptionalDependency(name) {
|
|
153
|
+
return Object.hasOwn(this.optionalDependencies, name);
|
|
154
|
+
}
|
|
155
|
+
/** Whether `name` appears in any of the four dependency kinds. */
|
|
156
|
+
hasAnyDependencyOn(name) {
|
|
157
|
+
return this.hasDependency(name) || this.hasDevDependency(name) || this.hasPeerDependency(name) || this.hasOptionalDependency(name);
|
|
158
|
+
}
|
|
159
|
+
/** The declared specifier for `name`, searched across all four kinds. */
|
|
160
|
+
dependencyVersion(name) {
|
|
161
|
+
const own = (map) => Object.hasOwn(map, name) ? map[name] : void 0;
|
|
162
|
+
const version = own(this.dependencies) ?? own(this.devDependencies) ?? own(this.peerDependencies) ?? own(this.optionalDependencies);
|
|
163
|
+
return version === void 0 ? Option.none() : Option.some(version);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether any dependency name matches `pattern` — the `minimatch` runtime
|
|
167
|
+
* dependency's one call site, now over `@effected/glob`'s vendored engine.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* A `GlobPattern` is total and free to test. A `string` is compiled on every
|
|
171
|
+
* call and an **uncompilable** literal throws: a glob written into a call
|
|
172
|
+
* site is developer wiring, not untrusted input, so it belongs in the defect
|
|
173
|
+
* channel rather than widening the typed channel every caller must branch
|
|
174
|
+
* on. Compile once with `GlobPattern.compile` and pass the result when
|
|
175
|
+
* testing many packages.
|
|
176
|
+
*
|
|
177
|
+
* @param pattern - A compiled pattern, or a source string to compile.
|
|
178
|
+
*/
|
|
179
|
+
matchesDependency(pattern) {
|
|
180
|
+
const compiled = typeof pattern === "string" ? GlobPattern.make({ source: pattern }) : pattern;
|
|
181
|
+
return Object.keys(this.allDependencies).some((dependency) => compiled.matches(dependency));
|
|
182
|
+
}
|
|
183
|
+
/** Compare this package's dependencies against `other`'s. */
|
|
184
|
+
dependencyDiff(other) {
|
|
185
|
+
const mine = this.allDependencies;
|
|
186
|
+
const theirs = other.allDependencies;
|
|
187
|
+
const added = {};
|
|
188
|
+
const removed = {};
|
|
189
|
+
const changed = {};
|
|
190
|
+
for (const [name, version] of Object.entries(mine)) if (!Object.hasOwn(theirs, name)) added[name] = version;
|
|
191
|
+
else if (theirs[name] !== version) changed[name] = {
|
|
192
|
+
from: theirs[name],
|
|
193
|
+
to: version
|
|
194
|
+
};
|
|
195
|
+
for (const [name, version] of Object.entries(theirs)) if (!Object.hasOwn(mine, name)) removed[name] = version;
|
|
196
|
+
return {
|
|
197
|
+
added,
|
|
198
|
+
removed,
|
|
199
|
+
changed
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Project to `@effected/lockfiles`' `WorkspaceManifest` — the input shape of
|
|
204
|
+
* `LockfileIntegrity.compare`. Total.
|
|
205
|
+
*/
|
|
206
|
+
toWorkspaceManifest() {
|
|
207
|
+
return WorkspaceManifest.make({
|
|
208
|
+
name: this.name,
|
|
209
|
+
dependencies: this.dependencies,
|
|
210
|
+
devDependencies: this.devDependencies,
|
|
211
|
+
peerDependencies: this.peerDependencies,
|
|
212
|
+
optionalDependencies: this.optionalDependencies
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Read and decode this package's `package.json` into the strict
|
|
217
|
+
* `@effected/package-json` `Package` model — the bridge from the
|
|
218
|
+
* tolerant discovery projection to the fully typed manifest.
|
|
219
|
+
*/
|
|
220
|
+
static manifest = Effect.fn("WorkspacePackage.manifest")(function* (self) {
|
|
221
|
+
const content = yield* (yield* FileSystem.FileSystem).readFileString(self.packageJsonPath).pipe(Effect.mapError((cause) => new WorkspaceManifestError({
|
|
222
|
+
packageJsonPath: self.packageJsonPath,
|
|
223
|
+
kind: "read",
|
|
224
|
+
cause
|
|
225
|
+
})));
|
|
226
|
+
const raw = yield* Effect.try({
|
|
227
|
+
try: () => JSON.parse(content),
|
|
228
|
+
catch: (cause) => new WorkspaceManifestError({
|
|
229
|
+
packageJsonPath: self.packageJsonPath,
|
|
230
|
+
kind: "decode",
|
|
231
|
+
cause
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
return yield* Package.decode(raw).pipe(Effect.mapError((cause) => new WorkspaceManifestError({
|
|
235
|
+
packageJsonPath: self.packageJsonPath,
|
|
236
|
+
kind: "decode",
|
|
237
|
+
cause
|
|
238
|
+
})));
|
|
239
|
+
});
|
|
240
|
+
/** Instance form of `WorkspacePackage.manifest`. */
|
|
241
|
+
manifest() {
|
|
242
|
+
return WorkspacePackage.manifest(this);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
//#endregion
|
|
247
|
+
export { PublishConfig, WorkspaceManifestError, WorkspacePackage };
|
package/WorkspaceRoot.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import { Walker } from "@effected/walker";
|
|
3
|
+
|
|
4
|
+
//#region src/WorkspaceRoot.ts
|
|
5
|
+
/**
|
|
6
|
+
* The marker filenames {@link WorkspaceRoot} probes for, in priority order.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
const WORKSPACE_MARKERS = ["pnpm-workspace.yaml", "package.json"];
|
|
11
|
+
/**
|
|
12
|
+
* Raised when no workspace root can be found by ascending from a directory.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* `markers` records what was probed, so the failure names the contract rather
|
|
16
|
+
* than paraphrasing it in prose.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var WorkspaceRootNotFoundError = class extends Schema.TaggedErrorClass()("WorkspaceRootNotFoundError", {
|
|
21
|
+
/** The directory the ascent started from. */
|
|
22
|
+
searchPath: Schema.String,
|
|
23
|
+
/** The marker filenames probed at each ancestor. */
|
|
24
|
+
markers: Schema.Array(Schema.String)
|
|
25
|
+
}) {
|
|
26
|
+
/** Renders the search path and probed markers into a one-line message. */
|
|
27
|
+
get message() {
|
|
28
|
+
return `No workspace root above ${this.searchPath} (looked for ${this.markers.join(", ")})`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether `dir` is a workspace root: it holds a `pnpm-workspace.yaml`, or a
|
|
33
|
+
* `package.json` with a `workspaces` field.
|
|
34
|
+
*
|
|
35
|
+
* A malformed root `package.json` is "not a root", not an error — the ascent
|
|
36
|
+
* continues past it, matching walker's absorption contract.
|
|
37
|
+
*/
|
|
38
|
+
const isWorkspaceRoot = (dir) => Effect.gen(function* () {
|
|
39
|
+
const fs = yield* FileSystem.FileSystem;
|
|
40
|
+
const path = yield* Path.Path;
|
|
41
|
+
if (yield* fs.exists(path.join(dir, "pnpm-workspace.yaml")).pipe(Effect.orElseSucceed(() => false))) return true;
|
|
42
|
+
const packageJson = path.join(dir, "package.json");
|
|
43
|
+
if (!(yield* fs.exists(packageJson).pipe(Effect.orElseSucceed(() => false)))) return false;
|
|
44
|
+
const content = yield* fs.readFileString(packageJson).pipe(Effect.orElseSucceed(() => "{}"));
|
|
45
|
+
const parsed = yield* Effect.try({
|
|
46
|
+
try: () => JSON.parse(content),
|
|
47
|
+
catch: () => void 0
|
|
48
|
+
}).pipe(Effect.orElseSucceed(() => ({})));
|
|
49
|
+
return parsed.workspaces !== void 0 && parsed.workspaces !== null;
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Locates the workspace root by ascending from a starting directory.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { WorkspaceRoot } from "@effected/workspaces";
|
|
57
|
+
* import { Effect } from "effect";
|
|
58
|
+
*
|
|
59
|
+
* const program = Effect.gen(function* () {
|
|
60
|
+
* const root = yield* WorkspaceRoot;
|
|
61
|
+
* return yield* root.find("/repo/packages/utils/src");
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
var WorkspaceRoot = class WorkspaceRoot extends Context.Service()("@effected/workspaces/WorkspaceRoot") {
|
|
68
|
+
/** Builds the service over core `FileSystem` and `Path`. */
|
|
69
|
+
static make = Effect.gen(function* () {
|
|
70
|
+
const fs = yield* FileSystem.FileSystem;
|
|
71
|
+
const path = yield* Path.Path;
|
|
72
|
+
const find = Effect.fn("WorkspaceRoot.find")(function* (cwd) {
|
|
73
|
+
const start = path.resolve(cwd);
|
|
74
|
+
const chain = yield* Walker.ascend(start);
|
|
75
|
+
const found = yield* Walker.findRoot(chain, isWorkspaceRoot);
|
|
76
|
+
if (Option.isNone(found)) return yield* Effect.fail(new WorkspaceRootNotFoundError({
|
|
77
|
+
searchPath: start,
|
|
78
|
+
markers: WORKSPACE_MARKERS
|
|
79
|
+
}));
|
|
80
|
+
yield* Effect.logDebug("Workspace root found").pipe(Effect.annotateLogs("workspace.root", found.value));
|
|
81
|
+
return found.value;
|
|
82
|
+
});
|
|
83
|
+
return { find: (cwd) => find(cwd).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path)) };
|
|
84
|
+
});
|
|
85
|
+
/** The live layer. */
|
|
86
|
+
static layer = Layer.effect(WorkspaceRoot, WorkspaceRoot.make);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
export { WORKSPACE_MARKERS, WorkspaceRoot, WorkspaceRootNotFoundError };
|
|
Binary file
|