@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
package/LockfileReader.js
CHANGED
|
@@ -27,6 +27,8 @@ var LockfileReadError = class extends Schema.TaggedErrorClass()("LockfileReadErr
|
|
|
27
27
|
return `Cannot read ${this.format} lockfile at ${this.lockfilePath}`;
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
|
+
/** A defect naming the unstubbed test-double method — a test-wiring mistake, not a typed failure. */
|
|
31
|
+
const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`LockfileReader.makeTest: ${method}() was called but not stubbed — pass a \`${method}\` override.`));
|
|
30
32
|
/**
|
|
31
33
|
* Reads and parses the workspace's lockfile.
|
|
32
34
|
*
|
|
@@ -112,6 +114,74 @@ var LockfileReader = class LockfileReader extends Context.Service()("@effected/w
|
|
|
112
114
|
* `const` and reuse it.
|
|
113
115
|
*/
|
|
114
116
|
static layer = (options) => Layer.effect(LockfileReader, LockfileReader.make(options));
|
|
117
|
+
/**
|
|
118
|
+
* A test double satisfying the full {@link LockfileReaderShape} with no
|
|
119
|
+
* filesystem, root walk, or package-manager detection.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* There is **no honest default lockfile**: an empty one that looks like a
|
|
123
|
+
* legitimate answer is indistinguishable from "this workspace resolves
|
|
124
|
+
* nothing" — the silent-empty failure class this package documents on the
|
|
125
|
+
* live paths — so `read` **dies** with an instructive defect until stubbed.
|
|
126
|
+
*
|
|
127
|
+
* The one derivation mirrors `WorkspaceDiscovery.makeTest`'s
|
|
128
|
+
* derived-from-the-primary rule: when a `read` override is supplied,
|
|
129
|
+
* `resolvedVersion` answers as the live service does — the **first** entry
|
|
130
|
+
* of `lockfile.packagesNamed(name)` in lockfile order, `Option.none()` on a
|
|
131
|
+
* miss — so the two stay consistent by construction. `integrity` is **not**
|
|
132
|
+
* derivable: the live method compares the lockfile against the workspace
|
|
133
|
+
* manifests discovery enumerates, and the double has no discovery to ask, so
|
|
134
|
+
* it dies unless stubbed.
|
|
135
|
+
*
|
|
136
|
+
* `refresh` defaults to `Effect.void` honestly: the live contract is "drop
|
|
137
|
+
* the memoized read so the next call re-reads", and this double memoizes
|
|
138
|
+
* nothing — every `read()` call re-invokes the override — so there is
|
|
139
|
+
* nothing to drop and the no-op is truthful, the same reasoning as
|
|
140
|
+
* `WorkspaceDiscovery.makeTest`'s `refresh`.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* import { Lockfile } from "@effected/lockfiles";
|
|
145
|
+
* import { LockfileReader } from "@effected/workspaces";
|
|
146
|
+
* import { Effect } from "effect";
|
|
147
|
+
*
|
|
148
|
+
* const double = LockfileReader.makeTest({
|
|
149
|
+
* read: () =>
|
|
150
|
+
* Effect.succeed(
|
|
151
|
+
* Lockfile.make({ format: "pnpm", lockfileVersion: "9.0", packages: [], workspaceDependencies: [] }),
|
|
152
|
+
* ),
|
|
153
|
+
* });
|
|
154
|
+
* // `resolvedVersion` now answers consistently from that lockfile.
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
static makeTest = (overrides = {}) => {
|
|
158
|
+
const read = overrides.read;
|
|
159
|
+
return {
|
|
160
|
+
read: () => unstubbed("read"),
|
|
161
|
+
resolvedVersion: read !== void 0 ? (packageName) => Effect.map(read(), (lockfile) => Option.fromUndefinedOr(lockfile.packagesNamed(packageName)[0])) : () => unstubbed("resolvedVersion"),
|
|
162
|
+
integrity: () => unstubbed("integrity"),
|
|
163
|
+
refresh: () => Effect.void,
|
|
164
|
+
...overrides
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* The test layer: {@link LockfileReader.makeTest} behind `Layer.succeed`, so
|
|
169
|
+
* a suite provides only the methods it exercises.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* A parameterized layer factory mints a **fresh reference per call**, and
|
|
173
|
+
* layers memoize by reference — bind the result to a `const` and reuse it
|
|
174
|
+
* rather than calling `layerTest(...)` at each composition site.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { LockfileReader } from "@effected/workspaces";
|
|
179
|
+
*
|
|
180
|
+
* const TestLockfiles = LockfileReader.layerTest();
|
|
181
|
+
* // program.pipe(Effect.provide(TestLockfiles)) — dies loudly if touched.
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
static layerTest = (overrides = {}) => Layer.succeed(LockfileReader, LockfileReader.makeTest(overrides));
|
|
115
185
|
};
|
|
116
186
|
|
|
117
187
|
//#endregion
|
package/PackageManagerName.js
CHANGED
|
@@ -103,7 +103,11 @@ const CHECKED = [
|
|
|
103
103
|
"bun.lock",
|
|
104
104
|
"bun.lockb",
|
|
105
105
|
"yarn.lock",
|
|
106
|
-
"package.json#workspaces"
|
|
106
|
+
"package.json#workspaces",
|
|
107
|
+
"pnpm-lock.yaml",
|
|
108
|
+
"package-lock.json",
|
|
109
|
+
"package.json#packageManager",
|
|
110
|
+
"package.json#devEngines.packageManager"
|
|
107
111
|
];
|
|
108
112
|
/**
|
|
109
113
|
* Detects which package manager owns a workspace root.
|
|
@@ -217,7 +221,8 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
|
|
|
217
221
|
version: versionFor(hints, "pnpm"),
|
|
218
222
|
runtime: "node"
|
|
219
223
|
});
|
|
220
|
-
|
|
224
|
+
const bunLock = (yield* has(root, "bun.lock")) || (yield* has(root, "bun.lockb"));
|
|
225
|
+
if (bunLock && namesManager(hints, "bun")) return DetectedPackageManager.make({
|
|
221
226
|
name: "bun",
|
|
222
227
|
version: versionFor(hints, "bun"),
|
|
223
228
|
runtime: "bun"
|
|
@@ -233,6 +238,35 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
|
|
|
233
238
|
version: versionFor(hints, "npm"),
|
|
234
239
|
runtime: "node"
|
|
235
240
|
});
|
|
241
|
+
if (yield* has(root, "pnpm-lock.yaml")) return DetectedPackageManager.make({
|
|
242
|
+
name: "pnpm",
|
|
243
|
+
version: versionFor(hints, "pnpm"),
|
|
244
|
+
runtime: "node"
|
|
245
|
+
});
|
|
246
|
+
if (bunLock && namesManager(hints, "bun")) return DetectedPackageManager.make({
|
|
247
|
+
name: "bun",
|
|
248
|
+
version: versionFor(hints, "bun"),
|
|
249
|
+
runtime: "bun"
|
|
250
|
+
});
|
|
251
|
+
if ((yield* has(root, "yarn.lock")) && namesManager(hints, "yarn")) return DetectedPackageManager.make({
|
|
252
|
+
name: "yarn",
|
|
253
|
+
version: versionFor(hints, "yarn"),
|
|
254
|
+
runtime: "node"
|
|
255
|
+
});
|
|
256
|
+
if (yield* has(root, "package-lock.json")) return DetectedPackageManager.make({
|
|
257
|
+
name: "npm",
|
|
258
|
+
version: versionFor(hints, "npm"),
|
|
259
|
+
runtime: "node"
|
|
260
|
+
});
|
|
261
|
+
const declared = declaredName(hints);
|
|
262
|
+
if (Option.isSome(declared)) {
|
|
263
|
+
const name = declared.value;
|
|
264
|
+
if (name === "pnpm" || name === "npm" || name === "yarn" || name === "bun") return DetectedPackageManager.make({
|
|
265
|
+
name,
|
|
266
|
+
version: versionFor(hints, name),
|
|
267
|
+
runtime: name === "bun" ? "bun" : "node"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
236
270
|
return yield* Effect.fail(new PackageManagerDetectionError({
|
|
237
271
|
root,
|
|
238
272
|
checked: CHECKED
|
|
@@ -242,6 +276,53 @@ var PackageManagerDetector = class PackageManagerDetector extends Context.Servic
|
|
|
242
276
|
});
|
|
243
277
|
/** The live layer. */
|
|
244
278
|
static layer = Layer.effect(PackageManagerDetector, PackageManagerDetector.make);
|
|
279
|
+
/**
|
|
280
|
+
* The sanctioned in-memory double.
|
|
281
|
+
*
|
|
282
|
+
* @remarks
|
|
283
|
+
* **`detect` has no honest default, so an unstubbed call dies** — the
|
|
284
|
+
* `WorkspaceDiscovery.info` posture, for the same reason. A stand-in that
|
|
285
|
+
* answered `"pnpm"` would hand a consumer a fact nothing established, and it
|
|
286
|
+
* would contradict the very service it stands in for: the live detector's
|
|
287
|
+
* defining property is that it [refuses to
|
|
288
|
+
* guess](https://github.com/spencerbeggs/effected) when no evidence matches.
|
|
289
|
+
* A double that guesses is worse than no double.
|
|
290
|
+
*
|
|
291
|
+
* Failing typed would be the subtler mistake: `PackageManagerDetectionError`
|
|
292
|
+
* reads as a legitimate "no manager here" answer, so a consumer would branch
|
|
293
|
+
* on it and proceed, never learning that the test simply forgot to stub.
|
|
294
|
+
*
|
|
295
|
+
* @param overrides - Members to supply; anything omitted dies on use.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* import { DetectedPackageManager, PackageManagerDetector } from "@effected/workspaces";
|
|
300
|
+
* import { Effect, Option } from "effect";
|
|
301
|
+
*
|
|
302
|
+
* const TestDetector = PackageManagerDetector.layerTest({
|
|
303
|
+
* detect: () =>
|
|
304
|
+
* Effect.succeed(
|
|
305
|
+
* DetectedPackageManager.make({ name: "pnpm", version: Option.none(), runtime: "node" }),
|
|
306
|
+
* ),
|
|
307
|
+
* });
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
static makeTest = (overrides = {}) => ({
|
|
311
|
+
detect: () => Effect.die(/* @__PURE__ */ new Error("PackageManagerDetector.makeTest: detect() was called but not stubbed — no honest default DetectedPackageManager exists for a test double; pass a `detect` override.")),
|
|
312
|
+
...overrides
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* {@link PackageManagerDetector.makeTest} behind `Layer.succeed`.
|
|
316
|
+
*
|
|
317
|
+
* @remarks
|
|
318
|
+
* A parameterized layer factory mints a **fresh reference per call**, and
|
|
319
|
+
* layers memoize by reference — bind the result to a `const` and reuse it
|
|
320
|
+
* rather than calling `layerTest(...)` at each composition site.
|
|
321
|
+
*
|
|
322
|
+
* Pairs with `WorkspaceRoot.layerTest` and `WorkspaceDiscovery.layerTest` to
|
|
323
|
+
* stand up the whole discovery path with no filesystem at all.
|
|
324
|
+
*/
|
|
325
|
+
static layerTest = (overrides = {}) => Layer.succeed(PackageManagerDetector, PackageManagerDetector.makeTest(overrides));
|
|
245
326
|
};
|
|
246
327
|
|
|
247
328
|
//#endregion
|
package/Publishability.js
CHANGED
|
@@ -84,9 +84,35 @@ var PublishTarget = class extends Schema.Class("PublishTarget")({
|
|
|
84
84
|
*
|
|
85
85
|
* @public
|
|
86
86
|
*/
|
|
87
|
-
var PublishabilityDetector = class
|
|
88
|
-
/**
|
|
89
|
-
|
|
87
|
+
var PublishabilityDetector = class extends Context.Service()("@effected/workspaces/PublishabilityDetector") {
|
|
88
|
+
/**
|
|
89
|
+
* Standard npm publishing semantics, **as a value**. Pure — no filesystem,
|
|
90
|
+
* no platform services.
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* Exposed as a shape and not only as a layer, because a consumer composing
|
|
94
|
+
* *around* these rules cannot reach them through a layer without re-entering
|
|
95
|
+
* the very tag it is replacing. `@savvy-web/silk-effects` had to write
|
|
96
|
+
* `Effect.provide(PublishabilityDetector, PublishabilityDetector.layer)`
|
|
97
|
+
* **inside its own implementation of that tag** to get at this function for
|
|
98
|
+
* its pass-through branch; with the value exposed that becomes
|
|
99
|
+
* `PublishabilityDetector.npm.detect(pkg)`.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { PublishabilityDetector } from "@effected/workspaces";
|
|
104
|
+
* import { Effect, Layer } from "effect";
|
|
105
|
+
*
|
|
106
|
+
* // A policy that defers to npm semantics for everything it does not veto.
|
|
107
|
+
* const withVeto = Layer.succeed(PublishabilityDetector, {
|
|
108
|
+
* detect: (pkg) =>
|
|
109
|
+
* pkg.name.endsWith("-private")
|
|
110
|
+
* ? Effect.succeed([])
|
|
111
|
+
* : PublishabilityDetector.npm.detect(pkg),
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
static npm = { detect: (pkg) => Effect.sync(() => {
|
|
90
116
|
const config = pkg.publishConfig;
|
|
91
117
|
const access = config?.access;
|
|
92
118
|
if (pkg.private && access === void 0) return [];
|
|
@@ -97,7 +123,33 @@ var PublishabilityDetector = class PublishabilityDetector extends Context.Servic
|
|
|
97
123
|
access: access ?? "public",
|
|
98
124
|
provenance: false
|
|
99
125
|
})];
|
|
100
|
-
}) }
|
|
126
|
+
}) };
|
|
127
|
+
/** Nothing publishes. */
|
|
128
|
+
static none = { detect: () => Effect.succeed([]) };
|
|
129
|
+
/**
|
|
130
|
+
* {@link PublishabilityDetector.npm} as a layer.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* Named for its policy rather than called `layer`, deliberately. **No
|
|
134
|
+
* composite in this package provides a publishability detector**: a
|
|
135
|
+
* `Workspaces.layer()` that quietly supplied npm semantics made the choice
|
|
136
|
+
* invisible, and worse, made a naively-ordered override lose to it in
|
|
137
|
+
* silence — `Layer.mergeAll(myDetector, Workspaces.layer())` resolved to the
|
|
138
|
+
* default, because `mergeAll` is last-wins. For a service that decides
|
|
139
|
+
* whether a package publishes and to which registry, that silent revert was
|
|
140
|
+
* the worst available failure. The requirement now sits in `R`, so the
|
|
141
|
+
* choice is made once, explicitly, and unmade wiring does not compile.
|
|
142
|
+
*/
|
|
143
|
+
static layerNpm = Layer.succeed(this, this.npm);
|
|
144
|
+
/**
|
|
145
|
+
* {@link PublishabilityDetector.none} as a layer: a workspace where nothing
|
|
146
|
+
* publishes.
|
|
147
|
+
*
|
|
148
|
+
* @remarks
|
|
149
|
+
* For dry runs, and for a release tool whose configuration disables
|
|
150
|
+
* publishing wholesale — silk's changeset `mode: "none"` is exactly this.
|
|
151
|
+
*/
|
|
152
|
+
static layerNone = Layer.succeed(this, this.none);
|
|
101
153
|
};
|
|
102
154
|
|
|
103
155
|
//#endregion
|
package/README.md
CHANGED
|
@@ -134,7 +134,21 @@ A specifier the workspace cannot answer fails typed as `UnresolvedDependencyErro
|
|
|
134
134
|
|
|
135
135
|
## The synchronous escape hatch
|
|
136
136
|
|
|
137
|
-
Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply
|
|
137
|
+
Vitest's config-time project discovery cannot await. Two functions exist for exactly that case, and they run synchronously over file and path operations you supply. On Node you do not have to write them: the `@effected/workspaces/node-sync` subpath exports `nodeSyncOps`, the ready-made `node:fs` and `node:path` bindings, so adopting the sync path is one extra import.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
|
|
141
|
+
import { nodeSyncOps } from "@effected/workspaces/node-sync";
|
|
142
|
+
|
|
143
|
+
const root = findWorkspaceRootSync(process.cwd(), nodeSyncOps);
|
|
144
|
+
const packages = root === null ? [] : getWorkspacePackagesSync(root, nodeSyncOps);
|
|
145
|
+
// root: the workspace root path, or null when none is found above the cwd
|
|
146
|
+
// packages: the discovered workspace packages, empty when there is no root
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both entry points take their path positionally, so the bag usually passes through verbatim; spread it to add `getWorkspacePackagesSync`'s traversal extras — `{ ...nodeSyncOps, maxDepth }`. The bindings are a separate subpath deliberately: the main entry imports nothing platform-shaped, and re-exporting them from it would drag `node:*` into every consumer, including the ones supplying their own operations. `nodePath` is the running platform's `node:path`, so on Windows the paths handed back are win32 paths.
|
|
150
|
+
|
|
151
|
+
Write the operations yourself when Node's built-ins are not the platform you mean — a Bun or Deno binding, a test fake, or `node:path/win32` to pin a dialect rather than follow the running platform. Each one is a one-liner:
|
|
138
152
|
|
|
139
153
|
```ts
|
|
140
154
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
@@ -157,7 +171,7 @@ const packages = root === null ? [] : getWorkspacePackagesSync(root, options);
|
|
|
157
171
|
// packages: the discovered workspace packages, empty when there is no root
|
|
158
172
|
```
|
|
159
173
|
|
|
160
|
-
Windows correctness is the operations you pass
|
|
174
|
+
Windows correctness is therefore the operations you pass, and nothing else. 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.
|
|
161
175
|
|
|
162
176
|
## Error handling
|
|
163
177
|
|
|
@@ -220,6 +234,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
|
|
|
220
234
|
- `ChangeDetector` — git-range change detection over `@effected/git`'s `Git` service; swap the layer to mock it with no repository.
|
|
221
235
|
- `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.
|
|
222
236
|
- `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
|
|
237
|
+
- `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own.
|
|
223
238
|
|
|
224
239
|
## License
|
|
225
240
|
|
package/ReleaseTag.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ReleaseTag.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether one shared tag names a whole release, or one tag names each package.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* `single` is the shape of a single-package repo and of a monorepo whose
|
|
9
|
+
* publishable packages all version in lockstep; `scoped` is the shape of
|
|
10
|
+
* independent versioning, where a shared tag would be ambiguous.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
const TagStyle = Schema.Literals(["single", "scoped"]);
|
|
15
|
+
/** Digits only, and no leading zeros beyond `0` itself — SemVer's numeric identifier. */
|
|
16
|
+
const NUMERIC_IDENTIFIER = /^(?:0|[1-9]\d*)$/;
|
|
17
|
+
/**
|
|
18
|
+
* Read a version's numeric core, or nothing when it is not `X.Y.Z[-pre][+build]`.
|
|
19
|
+
*
|
|
20
|
+
* Build metadata is stripped **before** the prerelease test: `+build` carries
|
|
21
|
+
* no precedence meaning in SemVer, so `1.2.3+sha.abc` is the stable `1.2.3`,
|
|
22
|
+
* and treating any `-`-or-`+` suffix as a prerelease is the obvious wrong
|
|
23
|
+
* reading.
|
|
24
|
+
*/
|
|
25
|
+
const versionCore = (version) => {
|
|
26
|
+
const withoutBuild = version.split("+", 1)[0] ?? "";
|
|
27
|
+
const dash = withoutBuild.indexOf("-");
|
|
28
|
+
const prerelease = dash !== -1;
|
|
29
|
+
const segments = (prerelease ? withoutBuild.slice(0, dash) : withoutBuild).split(".");
|
|
30
|
+
if (segments.length !== 3) return void 0;
|
|
31
|
+
if (!segments.every((segment) => NUMERIC_IDENTIFIER.test(segment))) return void 0;
|
|
32
|
+
return {
|
|
33
|
+
major: Number(segments[0]),
|
|
34
|
+
minor: Number(segments[1]),
|
|
35
|
+
prerelease
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A floating alias tag — `v1`, `v1.2` — that a repo re-points at its newest
|
|
40
|
+
* matching release.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* This is the GitHub Actions distribution convention: a consumer writes
|
|
44
|
+
* `uses: owner/repo@v1` and receives whatever 1.x the repo last pointed `v1` at.
|
|
45
|
+
*
|
|
46
|
+
* **Deliberately not SemVer, and deliberately not a {@link (TagStyle:variable)}.**
|
|
47
|
+
* A release tag names one immutable version; a tracking tag is an alias derived
|
|
48
|
+
* *from* a version, carrying a truncated number that is not a version at all.
|
|
49
|
+
* Folding it into `ReleaseTag` as a third style would put a mutable pointer and
|
|
50
|
+
* an immutable name behind one type.
|
|
51
|
+
*
|
|
52
|
+
* Everything here is derivation, formatting and parsing. **Actually moving a
|
|
53
|
+
* git tag is not this package's business** — a consumer does that through git,
|
|
54
|
+
* and the deliberate omission is what keeps this module a pure leaf.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { TrackingTag } from "@effected/workspaces";
|
|
59
|
+
*
|
|
60
|
+
* TrackingTag.forVersion("1.2.3").map((t) => t.value); // ["v1", "v1.2"]
|
|
61
|
+
* TrackingTag.forVersion("1.0.0-beta.3"); // [] — never float onto a beta
|
|
62
|
+
* TrackingTag.forVersion("1.2.3", { packageName: "@acme/cli" });
|
|
63
|
+
* // ["@acme/cli@v1", "@acme/cli@v1.2"]
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
var TrackingTag = class TrackingTag extends Schema.Class("TrackingTag")({
|
|
69
|
+
/** The tag string exactly as it appears in git. */
|
|
70
|
+
value: Schema.NonEmptyString,
|
|
71
|
+
/** The package the alias namespaces; absent on a bare `v1`. */
|
|
72
|
+
packageName: Schema.optionalKey(Schema.NonEmptyString),
|
|
73
|
+
/** The major version the alias tracks. */
|
|
74
|
+
major: Schema.Int,
|
|
75
|
+
/** The minor version, on a `v1.2`-precision alias; absent on `v1`. */
|
|
76
|
+
minor: Schema.optionalKey(Schema.Int)
|
|
77
|
+
}) {
|
|
78
|
+
/** Whether this alias tracks a whole major line, or one minor line inside it. */
|
|
79
|
+
get precision() {
|
|
80
|
+
return this.minor === void 0 ? "major" : "minor";
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The tracking tags a release of `version` should be pointed at.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* **A prerelease derives nothing.** Anyone depending on `owner/repo@v1` is
|
|
87
|
+
* asking for the newest *stable* 1.x, so re-pointing that alias at
|
|
88
|
+
* `1.0.0-beta.3` would ship a prerelease to every such consumer with no
|
|
89
|
+
* signal at all. `includePrerelease` exists for callers who genuinely mean
|
|
90
|
+
* it — a prerelease-only distribution channel — and should be rare.
|
|
91
|
+
*
|
|
92
|
+
* **Total, never throwing.** A version that is not `X.Y.Z` derives nothing
|
|
93
|
+
* rather than failing: this is a query about a version, not a validation of
|
|
94
|
+
* one, and `WorkspacePackage.version` is deliberately tolerant, so odd
|
|
95
|
+
* versions reach here routinely.
|
|
96
|
+
*
|
|
97
|
+
* 0.x versions DO derive aliases. Floating `v0` across 0.x minors is a real
|
|
98
|
+
* hazard, but which aliases to publish is the caller's policy, decided where
|
|
99
|
+
* the tags are moved — not something a derivation should quietly withhold.
|
|
100
|
+
*
|
|
101
|
+
* @param version - The version being released.
|
|
102
|
+
* @param options - Package prefix, precision and the prerelease override.
|
|
103
|
+
* @returns The aliases, broadest first; empty when none apply.
|
|
104
|
+
*/
|
|
105
|
+
static forVersion(version, options) {
|
|
106
|
+
const core = versionCore(version);
|
|
107
|
+
if (core === void 0) return [];
|
|
108
|
+
if (core.prerelease && options?.includePrerelease !== true) return [];
|
|
109
|
+
const packageName = options?.packageName;
|
|
110
|
+
const prefix = packageName === void 0 ? "" : `${packageName}@`;
|
|
111
|
+
const named = (suffix, fields) => TrackingTag.make({
|
|
112
|
+
value: `${prefix}v${suffix}`,
|
|
113
|
+
...packageName !== void 0 && { packageName },
|
|
114
|
+
major: fields.major,
|
|
115
|
+
...fields.minor !== void 0 && { minor: fields.minor }
|
|
116
|
+
});
|
|
117
|
+
const major = named(`${core.major}`, { major: core.major });
|
|
118
|
+
if (options?.precision === "major") return [major];
|
|
119
|
+
return [major, named(`${core.major}.${core.minor}`, {
|
|
120
|
+
major: core.major,
|
|
121
|
+
minor: core.minor
|
|
122
|
+
})];
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/** A tag string split into an optional package prefix and the version part. */
|
|
126
|
+
const splitTag = (tag) => {
|
|
127
|
+
const at = tag.lastIndexOf("@");
|
|
128
|
+
if (at === -1) return { rest: tag };
|
|
129
|
+
const packageName = tag.slice(0, at);
|
|
130
|
+
const rest = tag.slice(at + 1);
|
|
131
|
+
if (packageName === "" || rest === "") return void 0;
|
|
132
|
+
return {
|
|
133
|
+
packageName,
|
|
134
|
+
rest
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether a tag string is a release tag, a tracking alias, or neither.
|
|
139
|
+
*
|
|
140
|
+
* @remarks
|
|
141
|
+
* The two families are told apart by **segment count**, not by the `v` prefix:
|
|
142
|
+
* three numeric segments is a version (so `1.0.0` and `v1.0.0` are both release
|
|
143
|
+
* tags), while one or two segments is a truncated alias. The `v` *is* required
|
|
144
|
+
* on an alias — a bare `1` is neither valid SemVer nor the tracking convention,
|
|
145
|
+
* and accepting it would make this function guess.
|
|
146
|
+
*
|
|
147
|
+
* The package prefix splits at the **last** `@`, so a leading npm scope
|
|
148
|
+
* survives: `@scope/pkg@1.0.0` is package `@scope/pkg` at version `1.0.0`.
|
|
149
|
+
*
|
|
150
|
+
* Round-tripping is a tested property: every tag {@link ReleaseTag} and
|
|
151
|
+
* {@link TrackingTag} format classifies back to the family that produced it,
|
|
152
|
+
* with its fields intact.
|
|
153
|
+
*
|
|
154
|
+
* @param tag - Any tag string.
|
|
155
|
+
* @returns The classification.
|
|
156
|
+
*
|
|
157
|
+
* @public
|
|
158
|
+
*/
|
|
159
|
+
const classifyTag = (tag) => {
|
|
160
|
+
const split = splitTag(tag);
|
|
161
|
+
if (split === void 0) return { kind: "unrecognized" };
|
|
162
|
+
const { packageName, rest } = split;
|
|
163
|
+
const withoutV = rest.startsWith("v") ? rest.slice(1) : rest;
|
|
164
|
+
const hasV = rest.startsWith("v");
|
|
165
|
+
if (versionCore(withoutV) !== void 0) return {
|
|
166
|
+
kind: "release",
|
|
167
|
+
tag: ReleaseTag.make({
|
|
168
|
+
value: tag,
|
|
169
|
+
...packageName !== void 0 && { packageName },
|
|
170
|
+
version: withoutV,
|
|
171
|
+
style: packageName === void 0 ? "single" : "scoped"
|
|
172
|
+
})
|
|
173
|
+
};
|
|
174
|
+
if (!hasV) return { kind: "unrecognized" };
|
|
175
|
+
const segments = withoutV.split(".");
|
|
176
|
+
if (segments.length > 2 || !segments.every((segment) => NUMERIC_IDENTIFIER.test(segment))) return { kind: "unrecognized" };
|
|
177
|
+
const major = Number(segments[0]);
|
|
178
|
+
const minor = segments.length === 2 ? Number(segments[1]) : void 0;
|
|
179
|
+
return {
|
|
180
|
+
kind: "tracking",
|
|
181
|
+
tag: TrackingTag.make({
|
|
182
|
+
value: tag,
|
|
183
|
+
...packageName !== void 0 && { packageName },
|
|
184
|
+
major,
|
|
185
|
+
...minor !== void 0 && { minor }
|
|
186
|
+
})
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* A git tag naming a release, and the parts it was built from.
|
|
191
|
+
*
|
|
192
|
+
* @remarks
|
|
193
|
+
* `value` is the tag exactly as it appears in git; `version` stays **bare**
|
|
194
|
+
* even when `value` carries a prefix, so a consumer comparing versions never
|
|
195
|
+
* has to strip one back off.
|
|
196
|
+
*
|
|
197
|
+
* Formatting is **total**: there is no error channel, because the only failure
|
|
198
|
+
* v3 modelled — an empty version — is caught by `Schema.NonEmptyString` when
|
|
199
|
+
* the value is constructed. A bad version reaching these statics is developer
|
|
200
|
+
* wiring rather than untrusted input, so it dies as a defect, the same posture
|
|
201
|
+
* as an uncompilable glob literal in `WorkspacePackage.matchesDependency`.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* import { ReleaseTag } from "@effected/workspaces";
|
|
206
|
+
*
|
|
207
|
+
* ReleaseTag.single("1.2.3").value; // "1.2.3"
|
|
208
|
+
* ReleaseTag.scoped("@acme/cli", "1.2.3").value; // "@acme/cli@1.2.3"
|
|
209
|
+
* ReleaseTag.scoped("cli", "1.2.3").value; // "cli@1.2.3"
|
|
210
|
+
* ReleaseTag.scoped("cli", "1.2.3", { versionPrefix: "v" }).value; // "cli@v1.2.3"
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @public
|
|
214
|
+
*/
|
|
215
|
+
var ReleaseTag = class ReleaseTag extends Schema.Class("ReleaseTag")({
|
|
216
|
+
/** The tag string exactly as it appears in git. */
|
|
217
|
+
value: Schema.NonEmptyString,
|
|
218
|
+
/** The package the tag names; absent on a workspace-wide single tag. */
|
|
219
|
+
packageName: Schema.optionalKey(Schema.NonEmptyString),
|
|
220
|
+
/** The version the tag names, without any prefix. */
|
|
221
|
+
version: Schema.NonEmptyString,
|
|
222
|
+
/** Which style produced it. */
|
|
223
|
+
style: TagStyle
|
|
224
|
+
}) {
|
|
225
|
+
/**
|
|
226
|
+
* One shared tag for a whole release: `1.2.3`.
|
|
227
|
+
*
|
|
228
|
+
* @param version - The version being released. Must not be empty.
|
|
229
|
+
* @param options - Formatting overrides.
|
|
230
|
+
*/
|
|
231
|
+
static single(version, options) {
|
|
232
|
+
const prefix = options?.versionPrefix ?? "";
|
|
233
|
+
return ReleaseTag.make({
|
|
234
|
+
value: `${prefix}${version}`,
|
|
235
|
+
version,
|
|
236
|
+
style: "single"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A per-package tag: `<packageName>@<version>` — `@scope/pkg@1.2.3` for a
|
|
241
|
+
* scoped name, `pkg@1.2.3` for an unscoped one, uniformly, unless
|
|
242
|
+
* `options.versionPrefix` says otherwise.
|
|
243
|
+
*
|
|
244
|
+
* @param packageName - The package being released. Must not be empty.
|
|
245
|
+
* @param version - The version being released. Must not be empty.
|
|
246
|
+
* @param options - Formatting overrides.
|
|
247
|
+
*/
|
|
248
|
+
static scoped(packageName, version, options) {
|
|
249
|
+
const prefix = options?.versionPrefix ?? "";
|
|
250
|
+
return ReleaseTag.make({
|
|
251
|
+
value: `${packageName}@${prefix}${version}`,
|
|
252
|
+
packageName,
|
|
253
|
+
version,
|
|
254
|
+
style: "scoped"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
//#endregion
|
|
260
|
+
export { ReleaseTag, TagStyle, TrackingTag, classifyTag };
|