@savvy-web/silk-effects 1.5.2 → 2.0.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.
@@ -0,0 +1,367 @@
1
+ import { ChangesetIOError } from "../errors.js";
2
+ import { serializeDependencyTableToMarkdown, sortDependencyRows } from "../utils/dependency-table.js";
3
+ import { ChangesetConfigReaderLive } from "../../services/ChangesetConfigReader.js";
4
+ import { ChangesetConfig, ChangesetConfigLive } from "../../services/ChangesetConfig.js";
5
+ import { PublishabilityDetectorAdaptiveLive } from "../../services/SilkPublishability.js";
6
+ import { ConfigInspector, ConfigInspectorLive } from "./config-inspector.js";
7
+ import { computeWorkspaceDependencyDiffs } from "../utils/dep-diff.js";
8
+ import { gitMergeBase } from "../utils/git.js";
9
+ import { listPublishablePackageNames } from "../utils/publishability.js";
10
+ import { Context, Effect, Layer, Option } from "effect";
11
+ import { join, resolve } from "node:path";
12
+ import { FileSystem } from "@effect/platform";
13
+ import { PointInTimeWorkspace, PointInTimeWorkspaceLive, PublishabilityDetector, WorkspaceDiscovery, WorkspaceDiscoveryLive, WorkspaceRootLive } from "workspaces-effect";
14
+
15
+ //#region src/changesets/services/deps-regen.ts
16
+ /**
17
+ * `Changesets.DepsRegen` service — lift the `deps regen` / `deps detect`
18
+ * orchestration out of the CLI into a `Context.Tag` service with a
19
+ * `plan()` / `execute()` split.
20
+ *
21
+ * @remarks
22
+ * `plan()` computes the cumulative dependency diff (merge-base → working
23
+ * tree by default, or between two explicit refs) by snapshotting both
24
+ * sides through `PointInTimeWorkspace`, which resolves `catalog:` /
25
+ * `workspace:` specifiers against each ref's own catalogs and package
26
+ * versions before diffing. It drops `devDependency` rows (unless
27
+ * `includeDevDeps`), and returns a complete {@link RegenPlan}
28
+ * (target filenames + stale-changeset deletes) WITHOUT touching the
29
+ * filesystem. `execute()` applies a plan — writing the fresh changesets
30
+ * first, then deleting the stale pure-dependency ones (so an interrupted
31
+ * run loses nothing and is safely re-runnable).
32
+ *
33
+ * This is the single source of truth for regen/detect: the CLI commands
34
+ * and MCP tools are thin adapters over this service.
35
+ *
36
+ * @see {@link DepsRegen} for the service tag
37
+ * @see {@link DepsRegenLive} for the production layer
38
+ *
39
+ */
40
+ const ADJECTIVES = [
41
+ "brave",
42
+ "clever",
43
+ "swift",
44
+ "silver",
45
+ "lucky",
46
+ "happy",
47
+ "calm",
48
+ "bright",
49
+ "quiet",
50
+ "wild"
51
+ ];
52
+ const NOUNS = [
53
+ "dogs",
54
+ "cats",
55
+ "wolves",
56
+ "foxes",
57
+ "cups",
58
+ "ships",
59
+ "trees",
60
+ "owls",
61
+ "cranes",
62
+ "hills"
63
+ ];
64
+ const VERBS = [
65
+ "laugh",
66
+ "dream",
67
+ "fly",
68
+ "sing",
69
+ "dance",
70
+ "wander",
71
+ "soar",
72
+ "rest",
73
+ "leap",
74
+ "ponder"
75
+ ];
76
+ function pickRandomTriplet() {
77
+ return `${ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]}-${NOUNS[Math.floor(Math.random() * NOUNS.length)]}-${VERBS[Math.floor(Math.random() * VERBS.length)]}`;
78
+ }
79
+ /**
80
+ * Pick a `<adjective>-<noun>-<verb>` filename slug that does not collide
81
+ * with an existing `.changeset/*.md` OR with a slug already claimed
82
+ * earlier in the same {@link RegenPlan.toWrite} computation. `plan()`
83
+ * never writes to disk, so an on-disk existence check alone cannot see
84
+ * slugs chosen moments earlier in the same call — the `chosen` set closes
85
+ * that gap. The triplet space is 1,000 combinations, so a busy repo can
86
+ * plausibly exhaust it across runs; fall back to a timestamp suffix after
87
+ * 20 unlucky picks.
88
+ *
89
+ * @param fileExists - Effectful on-disk existence check (never fails; a
90
+ * filesystem error is treated as "does not exist" so filename selection
91
+ * degrades gracefully rather than blocking the plan).
92
+ * @param changesetDir - Directory checked for on-disk collisions.
93
+ * @param chosen - Basenames (without extension) already picked within this plan;
94
+ * the picked candidate is added to this set before returning.
95
+ * @internal
96
+ */
97
+ function randomFilename(fileExists, changesetDir, chosen) {
98
+ return Effect.gen(function* () {
99
+ for (let i = 0; i < 20; i++) {
100
+ const candidate = pickRandomTriplet();
101
+ if (!chosen.has(candidate) && !(yield* fileExists(join(changesetDir, `${candidate}.md`)))) {
102
+ chosen.add(candidate);
103
+ return candidate;
104
+ }
105
+ }
106
+ let attempt = 0;
107
+ let fallback = `${pickRandomTriplet()}-${Date.now()}`;
108
+ while (chosen.has(fallback) || (yield* fileExists(join(changesetDir, `${fallback}.md`)))) fallback = `${pickRandomTriplet()}-${Date.now()}-${++attempt}`;
109
+ chosen.add(fallback);
110
+ return fallback;
111
+ });
112
+ }
113
+ /**
114
+ * Strict detection of "pure dependency changesets" per the documented
115
+ * rules: single-package frontmatter, single `## Dependencies` heading,
116
+ * no other body content beyond that section.
117
+ *
118
+ * @param content - Raw `.changeset/*.md` file contents.
119
+ * @returns `{ isPure, package }` — `isPure` is `true` only for a
120
+ * single-package, Dependencies-only changeset; `package` is the sole
121
+ * frontmatter package name (or `null` when not pure).
122
+ *
123
+ * @public
124
+ */
125
+ function isPureDependencyChangeset(content) {
126
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
127
+ if (!fmMatch) return {
128
+ isPure: false,
129
+ package: null
130
+ };
131
+ const frontmatter = fmMatch[1];
132
+ const body = (fmMatch[2] ?? "").trim();
133
+ const fmLines = frontmatter.split(/\r?\n/).filter((l) => l.trim().length > 0 && !/^\s*#/.test(l));
134
+ if (fmLines.length !== 1) return {
135
+ isPure: false,
136
+ package: null
137
+ };
138
+ const pkgMatch = fmLines[0].match(/^\s*["']?([^"':\s]+)["']?\s*:\s*([a-z]+)\s*$/);
139
+ if (!pkgMatch) return {
140
+ isPure: false,
141
+ package: null
142
+ };
143
+ const pkg = pkgMatch[1];
144
+ const bodyTrimmed = body.replace(/^\s+/, "");
145
+ if (!/^## Dependencies\b/.test(bodyTrimmed)) return {
146
+ isPure: false,
147
+ package: null
148
+ };
149
+ if ((bodyTrimmed.match(/^## /gm) ?? []).length !== 1) return {
150
+ isPure: false,
151
+ package: null
152
+ };
153
+ if (/^# /m.test(bodyTrimmed)) return {
154
+ isPure: false,
155
+ package: null
156
+ };
157
+ return {
158
+ isPure: true,
159
+ package: pkg
160
+ };
161
+ }
162
+ /**
163
+ * List `.changeset/*.md` files (excluding `README.md`) in `changesetDir`.
164
+ * A missing directory is today's behavior for "no changesets yet" and
165
+ * resolves to an empty list; any OTHER failure (e.g. a directory that
166
+ * exists but cannot be read) is a loud {@link ChangesetIOError} — an
167
+ * unreadable `.changeset` dir must not silently masquerade as "no
168
+ * changesets".
169
+ */
170
+ const listChangesetFiles = (fs, changesetDir) => fs.readDirectory(changesetDir).pipe(Effect.map((names) => names.filter((f) => f.endsWith(".md") && f !== "README.md").map((f) => join(changesetDir, f))), Effect.catchAll((e) => e._tag === "SystemError" && e.reason === "NotFound" ? Effect.succeed([]) : Effect.fail(new ChangesetIOError({
171
+ path: changesetDir,
172
+ operation: "list",
173
+ reason: String(e)
174
+ }))));
175
+ /**
176
+ * Pure-dependency changesets found in `changesetDir`. An individual file
177
+ * that cannot be read (e.g. removed mid-scan, permission race) is skipped —
178
+ * today's semantics — while a failure to list the directory itself
179
+ * propagates as a loud {@link ChangesetIOError}.
180
+ */
181
+ const findPureDependencyChangesets = (fs, changesetDir) => Effect.gen(function* () {
182
+ const files = yield* listChangesetFiles(fs, changesetDir);
183
+ const result = [];
184
+ for (const file of files) {
185
+ const content = yield* fs.readFileString(file).pipe(Effect.option);
186
+ if (Option.isNone(content)) continue;
187
+ const detection = isPureDependencyChangeset(content.value);
188
+ if (detection.isPure && detection.package) result.push({
189
+ file,
190
+ package: detection.package
191
+ });
192
+ }
193
+ return result;
194
+ });
195
+ /**
196
+ * Mixed dependency changesets (have a `## Dependencies` heading but fail
197
+ * the strict pure-changeset test) found in `changesetDir`. Same
198
+ * skip-unreadable-file / loud-list-failure semantics as
199
+ * {@link findPureDependencyChangesets}.
200
+ */
201
+ const findMixedDependencyChangesets = (fs, changesetDir) => Effect.gen(function* () {
202
+ const files = yield* listChangesetFiles(fs, changesetDir);
203
+ const result = [];
204
+ for (const file of files) {
205
+ const content = yield* fs.readFileString(file).pipe(Effect.option);
206
+ if (Option.isNone(content)) continue;
207
+ if (/^## Dependencies\b/m.test(content.value) && !isPureDependencyChangeset(content.value).isPure) result.push(file);
208
+ }
209
+ return result;
210
+ });
211
+ /**
212
+ * Render a single-package, patch-bump changeset for a diff whose rows are
213
+ * already resolved (per-side, inside {@link computeWorkspaceDependencyDiffs})
214
+ * and devDep-filtered.
215
+ */
216
+ function renderChangesetContent(diff) {
217
+ return `${`---\n"${diff.package}": patch\n---`}\n\n## Dependencies\n\n${serializeDependencyTableToMarkdown([...diff.rows])}\n`;
218
+ }
219
+ const _tag = Context.Tag("Changesets/DepsRegen");
220
+ /**
221
+ * @internal
222
+ */
223
+ const DepsRegenBase = _tag();
224
+ /**
225
+ * Effect service tag for {@link DepsRegenShape}.
226
+ *
227
+ * @example
228
+ * ```typescript
229
+ * import { Effect } from "effect";
230
+ * import { Changesets } from "@savvy-web/silk-effects";
231
+ *
232
+ * const program = Effect.gen(function* () {
233
+ * const svc = yield* Changesets.DepsRegen;
234
+ * const plan = yield* svc.plan({ cwd: process.cwd() });
235
+ * return yield* svc.execute(plan);
236
+ * });
237
+ * ```
238
+ *
239
+ * @public
240
+ */
241
+ var DepsRegen = class extends DepsRegenBase {};
242
+ /**
243
+ * Build a {@link DepsRegenShape} that closes over already-resolved service
244
+ * implementations, keeping the public `plan`/`execute` signatures
245
+ * requirement-free (`R = never`).
246
+ */
247
+ function makeShape(pit, inspector, discovery, detector, config, fs) {
248
+ const provideDetector = Layer.succeed(PublishabilityDetector, detector);
249
+ const fileExists = (p) => fs.exists(p).pipe(Effect.orElseSucceed(() => false));
250
+ const plan = (options) => Effect.gen(function* () {
251
+ const resolvedCwd = resolve(options.cwd);
252
+ const changesetDir = join(resolvedCwd, ".changeset");
253
+ let fromRef = options.from;
254
+ if (!fromRef) {
255
+ let baseBranch = options.base;
256
+ if (!baseBranch) baseBranch = (yield* inspector.inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", () => Effect.succeed({ baseBranch: "main" })))).baseBranch;
257
+ fromRef = yield* gitMergeBase(resolvedCwd, baseBranch);
258
+ }
259
+ const rawDiffs = computeWorkspaceDependencyDiffs(yield* pit.at(fromRef, { cwd: resolvedCwd }), options.to ? yield* pit.at(options.to, { cwd: resolvedCwd }) : yield* pit.worktree({ cwd: resolvedCwd }));
260
+ const targetPkg = options.package;
261
+ const livePackages = yield* discovery.listPackages(resolvedCwd);
262
+ const publishable = yield* listPublishablePackageNames(livePackages, resolvedCwd).pipe(Effect.provide(provideDetector));
263
+ const versionPrivate = yield* config.versionPrivate(resolvedCwd);
264
+ const inScope = /* @__PURE__ */ new Set();
265
+ for (const pkg of livePackages) {
266
+ if (yield* config.isIgnored(pkg.name, resolvedCwd)) continue;
267
+ if (publishable.has(pkg.name) || versionPrivate) inScope.add(pkg.name);
268
+ }
269
+ const targetIgnored = targetPkg ? yield* config.isIgnored(targetPkg, resolvedCwd) : false;
270
+ const keepDevDeps = options.includeDevDeps === true;
271
+ const scoped = targetPkg ? targetIgnored ? [] : rawDiffs.filter((d) => d.package === targetPkg) : rawDiffs.filter((d) => inScope.has(d.package));
272
+ const resolved = [];
273
+ for (const diff of scoped) {
274
+ const rows = keepDevDeps ? [...diff.rows] : diff.rows.filter((r) => r.type !== "devDependency");
275
+ if (rows.length > 0) resolved.push({
276
+ ...diff,
277
+ rows: sortDependencyRows(rows)
278
+ });
279
+ }
280
+ const existingPure = yield* findPureDependencyChangesets(fs, changesetDir);
281
+ const skippedMixed = yield* findMixedDependencyChangesets(fs, changesetDir);
282
+ const toDelete = targetPkg ? targetIgnored ? [] : existingPure.filter((p) => p.package === targetPkg) : existingPure.filter((p) => inScope.has(p.package));
283
+ const chosenFilenames = /* @__PURE__ */ new Set();
284
+ const toWrite = [];
285
+ for (const diff of resolved) {
286
+ const filename = yield* randomFilename(fileExists, changesetDir, chosenFilenames);
287
+ toWrite.push({
288
+ file: join(changesetDir, `${filename}.md`),
289
+ package: diff.package,
290
+ diff
291
+ });
292
+ }
293
+ return {
294
+ toDelete,
295
+ toWrite,
296
+ skippedMixed
297
+ };
298
+ });
299
+ const execute = (plan) => Effect.gen(function* () {
300
+ const deleted = [];
301
+ const written = [];
302
+ for (const entry of plan.toWrite) {
303
+ yield* fs.writeFileString(entry.file, renderChangesetContent(entry.diff)).pipe(Effect.mapError((e) => new ChangesetIOError({
304
+ path: entry.file,
305
+ operation: "write",
306
+ reason: String(e)
307
+ })));
308
+ written.push(entry.file);
309
+ }
310
+ for (const entry of plan.toDelete) if (yield* Effect.isSuccess(fs.remove(entry.file))) deleted.push(entry.file);
311
+ return {
312
+ deleted,
313
+ written,
314
+ skippedMixed: plan.skippedMixed
315
+ };
316
+ });
317
+ return {
318
+ plan,
319
+ execute
320
+ };
321
+ }
322
+ /**
323
+ * Live layer for {@link DepsRegen}.
324
+ *
325
+ * Requires `PointInTimeWorkspace`, `WorkspaceDiscovery`,
326
+ * `PublishabilityDetector` (all from `workspaces-effect`),
327
+ * {@link ConfigInspector}, {@link ChangesetConfig}, and
328
+ * `FileSystem.FileSystem` (resolved once at construction and closed over by
329
+ * the shape, keeping `plan`/`execute` themselves requirement-free).
330
+ *
331
+ * @public
332
+ */
333
+ const DepsRegenLive = Layer.effect(DepsRegen, Effect.gen(function* () {
334
+ return makeShape(yield* PointInTimeWorkspace, yield* ConfigInspector, yield* WorkspaceDiscovery, yield* PublishabilityDetector, yield* ChangesetConfig, yield* FileSystem.FileSystem);
335
+ }));
336
+ const WorkspaceGraph = Layer.mergeAll(WorkspaceRootLive, WorkspaceDiscoveryLive.pipe(Layer.provide(WorkspaceRootLive)));
337
+ const ConfigGraph = ChangesetConfigLive.pipe(Layer.provide(ChangesetConfigReaderLive));
338
+ /**
339
+ * Batteries-included {@link DepsRegen} layer: silk's opinionated default
340
+ * composition of the full dependency graph. Only the platform services
341
+ * remain — note that {@link PointInTimeWorkspace} reads git history, so
342
+ * this layer genuinely requires `CommandExecutor` in addition to
343
+ * `FileSystem`/`Path`: provide a git-capable platform layer
344
+ * (`NodeContext.layer`), not a bare filesystem-only layer.
345
+ *
346
+ * Gating uses silk's adaptive publishability detector
347
+ * ({@link PublishabilityDetectorAdaptiveLive}), so the default semantics
348
+ * are "versionable minus ignored" — identical to the savvy CLI and MCP
349
+ * runtimes. Consumers who need to swap any dependency (test detectors,
350
+ * alternate config sources) should keep composing {@link DepsRegenLive}
351
+ * directly; this layer is purely additive.
352
+ *
353
+ * @example
354
+ * ```typescript
355
+ * import { NodeContext } from "@effect/platform-node";
356
+ * import { Layer } from "effect";
357
+ * import { Changesets } from "@savvy-web/silk-effects";
358
+ *
359
+ * const depsRegen = Changesets.DepsRegenDefault.pipe(Layer.provide(NodeContext.layer));
360
+ * ```
361
+ *
362
+ * @public
363
+ */
364
+ const DepsRegenDefault = DepsRegenLive.pipe(Layer.provide(PointInTimeWorkspaceLive.pipe(Layer.provide(WorkspaceGraph))), Layer.provide(ConfigInspectorLive.pipe(Layer.provide(Layer.mergeAll(ChangesetConfigReaderLive, WorkspaceGraph)))), Layer.provide(PublishabilityDetectorAdaptiveLive.pipe(Layer.provide(ConfigGraph))), Layer.provide(ConfigGraph), Layer.provide(WorkspaceGraph));
365
+
366
+ //#endregion
367
+ export { DepsRegen, DepsRegenBase, DepsRegenDefault, DepsRegenLive, isPureDependencyChangeset };
@@ -3,9 +3,8 @@ import { ChangelogTransformer } from "../api/transformer.js";
3
3
  import { ConfigInspector } from "./config-inspector.js";
4
4
  import { VersionFiles } from "../utils/version-files.js";
5
5
  import { Context, Effect, Layer } from "effect";
6
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
7
6
  import { isAbsolute, join, relative } from "node:path";
8
- import { tmpdir } from "node:os";
7
+ import { FileSystem } from "@effect/platform";
9
8
  import applyReleasePlan from "@changesets/apply-release-plan";
10
9
  import { read } from "@changesets/config";
11
10
  import getReleasePlan from "@changesets/get-release-plan";
@@ -37,8 +36,8 @@ const _tag = Context.Tag("ReleasePlanner");
37
36
  const ReleasePlannerBase = _tag();
38
37
  /** Effect service tag for the release planner. @public */
39
38
  var ReleasePlanner = class extends ReleasePlannerBase {};
40
- /** Build the service shape over a resolved {@link ConfigInspector}. */
41
- function makeShape(inspector) {
39
+ /** Build the service shape over a resolved {@link ConfigInspector} and {@link FileSystem.FileSystem}. */
40
+ function makeShape(inspector, fs) {
42
41
  const plan = (root) => Effect.tryPromise({
43
42
  try: () => getReleasePlan(root),
44
43
  catch: (e) => new ReleasePlanError({
@@ -46,23 +45,17 @@ function makeShape(inspector) {
46
45
  reason: errMsg(e)
47
46
  })
48
47
  });
49
- const preview = (root) => Effect.tryPromise({
50
- try: () => previewImpl(root),
51
- catch: (e) => new ReleasePlanError({
52
- phase: "preview",
53
- reason: errMsg(e)
54
- })
55
- });
56
- const apply = (root, options) => applyEffect(root, options?.dryRun ?? false, inspector);
48
+ const preview = (root) => previewEffect(root, fs);
49
+ const apply = (root, options) => applyEffect(root, options?.dryRun ?? false, inspector, fs);
57
50
  return {
58
51
  plan,
59
52
  preview,
60
53
  apply
61
54
  };
62
55
  }
63
- /** Production layer. Requires {@link ConfigInspector} (used by `apply`). @public */
56
+ /** Production layer. Requires {@link ConfigInspector} (used by `apply`) and `FileSystem`. @public */
64
57
  const ReleasePlannerLive = Layer.effect(ReleasePlanner, Effect.gen(function* () {
65
- return makeShape(yield* ConfigInspector);
58
+ return makeShape(yield* ConfigInspector, yield* FileSystem.FileSystem);
66
59
  }));
67
60
  /**
68
61
  * Test factory — supply fixed results for any subset of methods. Unsupplied
@@ -93,31 +86,52 @@ function extractVersionBlock(changelog, version) {
93
86
  }
94
87
  return lines.slice(start, end).join("\n").trim();
95
88
  }
96
- async function previewImpl(root) {
97
- const [plan, packages] = await Promise.all([getReleasePlan(root), buildPackages(root)]);
98
- const config = await read(root, packages);
99
- const preMode = plan.preState ? plan.preState.mode : null;
100
- const changesets = plan.changesets.map((cs) => ({
101
- id: cs.id,
102
- summary: cs.summary,
103
- releases: cs.releases.filter((r) => r.type !== "none").map((r) => ({
104
- name: r.name,
105
- type: r.type
106
- }))
107
- }));
108
- const releasesToRender = plan.releases.filter((r) => r.type !== "none");
109
- if (releasesToRender.length === 0) return {
110
- preMode,
111
- releases: [],
112
- changesets
113
- };
114
- const tempRoot = mkdtempSync(join(tmpdir(), "silk-preview-"));
115
- try {
89
+ /**
90
+ * Render a non-destructive preview by redirecting every write into a
91
+ * scope-managed temp directory (cleaned up automatically when the scope
92
+ * closes) and reading the generated CHANGELOG blocks back.
93
+ */
94
+ function previewEffect(root, fs) {
95
+ const program = Effect.gen(function* () {
96
+ const [plan, packages] = yield* Effect.tryPromise({
97
+ try: () => Promise.all([getReleasePlan(root), buildPackages(root)]),
98
+ catch: (e) => new ReleasePlanError({
99
+ phase: "preview",
100
+ reason: errMsg(e)
101
+ })
102
+ });
103
+ const config = yield* Effect.tryPromise({
104
+ try: () => read(root, packages),
105
+ catch: (e) => new ReleasePlanError({
106
+ phase: "preview",
107
+ reason: errMsg(e)
108
+ })
109
+ });
110
+ const preMode = plan.preState ? plan.preState.mode : null;
111
+ const changesets = plan.changesets.map((cs) => ({
112
+ id: cs.id,
113
+ summary: cs.summary,
114
+ releases: cs.releases.filter((r) => r.type !== "none").map((r) => ({
115
+ name: r.name,
116
+ type: r.type
117
+ }))
118
+ }));
119
+ const releasesToRender = plan.releases.filter((r) => r.type !== "none");
120
+ if (releasesToRender.length === 0) return {
121
+ preMode,
122
+ releases: [],
123
+ changesets
124
+ };
125
+ const tempRoot = yield* fs.makeTempDirectoryScoped({ prefix: "silk-preview-" });
116
126
  const mapDir = (dir) => {
117
127
  const rel = relative(packages.root.dir, dir);
118
- if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Package directory is outside the workspace root: ${dir}`);
119
- return join(tempRoot, rel);
128
+ if (rel.startsWith("..") || isAbsolute(rel)) return Effect.fail(new ReleasePlanError({
129
+ phase: "preview",
130
+ reason: `Package directory is outside the workspace root: ${dir}`
131
+ }));
132
+ return Effect.succeed(join(tempRoot, rel));
120
133
  };
134
+ const tempDirs = yield* Effect.forEach(packages.packages, (p) => mapDir(p.dir));
121
135
  const tempPackages = {
122
136
  tool: packages.tool,
123
137
  root: {
@@ -125,26 +139,33 @@ async function previewImpl(root) {
125
139
  dir: tempRoot,
126
140
  packageJson: structuredClone(packages.root.packageJson)
127
141
  },
128
- packages: packages.packages.map((p) => ({
142
+ packages: packages.packages.map((p, i) => ({
129
143
  ...p,
130
- dir: mapDir(p.dir),
144
+ dir: tempDirs[i],
131
145
  packageJson: structuredClone(p.packageJson)
132
146
  }))
133
147
  };
134
- mkdirSync(join(tempRoot, ".changeset"), { recursive: true });
135
- cpSync(join(root, "package.json"), join(tempRoot, "package.json"));
148
+ yield* fs.makeDirectory(join(tempRoot, ".changeset"), { recursive: true });
149
+ yield* fs.copyFile(join(root, "package.json"), join(tempRoot, "package.json"));
136
150
  const preJson = join(root, ".changeset", "pre.json");
137
- if (existsSync(preJson)) cpSync(preJson, join(tempRoot, ".changeset", "pre.json"));
138
- for (const p of packages.packages) {
139
- const tDir = mapDir(p.dir);
140
- mkdirSync(tDir, { recursive: true });
141
- cpSync(join(p.dir, "package.json"), join(tDir, "package.json"));
151
+ if (yield* fs.exists(preJson)) yield* fs.copyFile(preJson, join(tempRoot, ".changeset", "pre.json"));
152
+ for (let i = 0; i < packages.packages.length; i++) {
153
+ const p = packages.packages[i];
154
+ const tDir = tempDirs[i];
155
+ yield* fs.makeDirectory(tDir, { recursive: true });
156
+ yield* fs.copyFile(join(p.dir, "package.json"), join(tDir, "package.json"));
142
157
  const realCl = join(p.dir, "CHANGELOG.md");
143
- if (existsSync(realCl)) cpSync(realCl, join(tDir, "CHANGELOG.md"));
158
+ if (yield* fs.exists(realCl)) yield* fs.copyFile(realCl, join(tDir, "CHANGELOG.md"));
144
159
  }
145
160
  const rootCl = join(packages.root.dir, "CHANGELOG.md");
146
- if (existsSync(rootCl)) cpSync(rootCl, join(tempRoot, "CHANGELOG.md"));
147
- await applyReleasePlan(plan, tempPackages, config, void 0, root);
161
+ if (yield* fs.exists(rootCl)) yield* fs.copyFile(rootCl, join(tempRoot, "CHANGELOG.md"));
162
+ yield* Effect.tryPromise({
163
+ try: () => applyReleasePlan(plan, tempPackages, config, void 0, root),
164
+ catch: (e) => new ReleasePlanError({
165
+ phase: "preview",
166
+ reason: errMsg(e)
167
+ })
168
+ });
148
169
  const dirByName = /* @__PURE__ */ new Map();
149
170
  for (const p of tempPackages.packages) dirByName.set(p.packageJson.name, p.dir);
150
171
  if (tempPackages.root.packageJson.name) dirByName.set(tempPackages.root.packageJson.name, tempRoot);
@@ -153,9 +174,15 @@ async function previewImpl(root) {
153
174
  const dir = dirByName.get(r.name);
154
175
  if (!dir) continue;
155
176
  const clPath = join(dir, "CHANGELOG.md");
156
- if (!existsSync(clPath)) continue;
157
- ChangelogTransformer.transformFile(clPath);
158
- const content = readFileSync(clPath, "utf-8");
177
+ if (!(yield* fs.exists(clPath))) continue;
178
+ yield* Effect.try({
179
+ try: () => ChangelogTransformer.transformFile(clPath),
180
+ catch: (e) => new ReleasePlanError({
181
+ phase: "preview",
182
+ reason: errMsg(e)
183
+ })
184
+ });
185
+ const content = yield* fs.readFileString(clPath);
159
186
  releases.push({
160
187
  name: r.name,
161
188
  type: r.type,
@@ -170,22 +197,17 @@ async function previewImpl(root) {
170
197
  releases,
171
198
  changesets
172
199
  };
173
- } finally {
174
- rmSync(tempRoot, {
175
- recursive: true,
176
- force: true
177
- });
178
- }
200
+ });
201
+ return Effect.scoped(program).pipe(Effect.mapError((e) => e instanceof ReleasePlanError ? e : new ReleasePlanError({
202
+ phase: "preview",
203
+ reason: errMsg(e)
204
+ })));
179
205
  }
180
- /** Re-read a package's version from disk (post-bump) to feed versionFiles. */
181
- function diskVersion(workspaceDir, fallback) {
182
- try {
183
- return JSON.parse(readFileSync(join(workspaceDir, "package.json"), "utf-8")).version ?? fallback;
184
- } catch {
185
- return fallback;
186
- }
206
+ /** Re-read a package's version from disk (post-bump) to feed versionFiles; unreadable/unparseable falls back. */
207
+ function diskVersion(workspaceDir, fallback, fs) {
208
+ return fs.readFileString(join(workspaceDir, "package.json")).pipe(Effect.flatMap((raw) => Effect.try(() => JSON.parse(raw).version ?? fallback)), Effect.orElseSucceed(() => fallback));
187
209
  }
188
- function applyEffect(root, dryRun, inspector) {
210
+ function applyEffect(root, dryRun, inspector, fs) {
189
211
  return Effect.gen(function* () {
190
212
  const { plan, packages, config } = yield* Effect.tryPromise({
191
213
  try: async () => {
@@ -222,22 +244,24 @@ function applyEffect(root, dryRun, inspector) {
222
244
  const newVersionByName = new Map(plan.releases.map((r) => [r.name, r.newVersion]));
223
245
  const inspected = yield* inspector.inspect(root).pipe(Effect.catchAll((error) => Effect.logWarning(`Skipping versionFiles update: ${errMsg(error)}`).pipe(Effect.as(null))));
224
246
  let versionFileUpdates = [];
225
- if (inspected) versionFileUpdates = yield* Effect.try({
226
- try: () => {
227
- const scopes = inspected.packages.filter((p) => p.versionFiles.length > 0).map((p) => {
228
- const fresh = dryRun ? newVersionByName.get(p.name) ?? p.version : diskVersion(p.workspaceDir, p.version);
229
- return fresh !== p.version ? {
230
- ...p,
231
- version: fresh
232
- } : p;
233
- });
234
- return scopes.length > 0 ? VersionFiles.processResolvedVersionFiles(scopes, dryRun) : [];
235
- },
236
- catch: (e) => new ReleasePlanError({
237
- phase: "apply",
238
- reason: errMsg(e)
239
- })
240
- });
247
+ if (inspected) {
248
+ const candidates = inspected.packages.filter((p) => p.versionFiles.length > 0);
249
+ const freshVersions = yield* Effect.forEach(candidates, (p) => dryRun ? Effect.succeed(newVersionByName.get(p.name) ?? p.version) : diskVersion(p.workspaceDir, p.version, fs));
250
+ const scopes = candidates.map((p, i) => {
251
+ const fresh = freshVersions[i];
252
+ return fresh !== p.version ? {
253
+ ...p,
254
+ version: fresh
255
+ } : p;
256
+ });
257
+ versionFileUpdates = yield* Effect.try({
258
+ try: () => scopes.length > 0 ? VersionFiles.processResolvedVersionFiles(scopes, dryRun) : [],
259
+ catch: (e) => new ReleasePlanError({
260
+ phase: "apply",
261
+ reason: errMsg(e)
262
+ })
263
+ });
264
+ }
241
265
  return {
242
266
  dryRun,
243
267
  touchedFiles,