@danieljvdm/dev-kit 0.15.0 → 0.16.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.
@@ -2,6 +2,11 @@ import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess } from "effect/unstable/process";
3
3
 
4
4
  import { printStatus, withSpinner } from "./cli-ui.ts";
5
+ import {
6
+ commitCacheDirectory,
7
+ resolveGlobalCacheDirectory,
8
+ stampTagUsage,
9
+ } from "./global-cache.ts";
5
10
  import { observeSymbolicLink } from "./node-symbolic-link.ts";
6
11
  import { acquireProjectProcessLock } from "./project-process-lock.ts";
7
12
  import { isTypeScriptPackageName } from "./typescript-package-name.ts";
@@ -272,12 +277,73 @@ export const planEffectSource = Effect.fn("planEffectSource")(function* (
272
277
  } satisfies EffectSourcePlan;
273
278
  });
274
279
 
280
+ // The shared bare repository accumulates shallow tag fetches machine-wide, so
281
+ // a project checkout only contacts the network when its tag has never been
282
+ // cached on this machine. Entries are keyed by repository URL.
283
+ const ensureSharedRepository = Effect.fn("ensureSharedEffectRepository")(function* (
284
+ repository: string,
285
+ tag: string,
286
+ ) {
287
+ const fs = yield* FileSystem.FileSystem;
288
+ const path = yield* Path.Path;
289
+ const repositoryDir = path.join(
290
+ yield* resolveGlobalCacheDirectory(),
291
+ "effect-source",
292
+ encodeURIComponent(repository),
293
+ );
294
+ const populated = fs.exists(path.join(repositoryDir, "HEAD"));
295
+
296
+ if (!(yield* populated)) {
297
+ yield* fs.makeDirectory(path.dirname(repositoryDir), { recursive: true });
298
+ const staged = path.join(
299
+ yield* fs.makeTempDirectoryScoped({
300
+ directory: path.dirname(repositoryDir),
301
+ prefix: ".dev-kit-effect-source-stage-",
302
+ }),
303
+ "repository",
304
+ );
305
+
306
+ yield* runGit(path.dirname(staged), ["init", "--quiet", "--bare", staged]);
307
+ yield* commitCacheDirectory(staged, repositoryDir, populated);
308
+ }
309
+ yield* stampTagUsage(repositoryDir, tag);
310
+ const cached = yield* runGit(repositoryDir, [
311
+ "rev-parse",
312
+ "-q",
313
+ "--verify",
314
+ `refs/tags/${tag}^{commit}`,
315
+ ]).pipe(Effect.catchTag("EffectSourceCommandError", () => Effect.void));
316
+
317
+ if (cached === undefined) {
318
+ yield* runGit(repositoryDir, [
319
+ "fetch",
320
+ "--depth",
321
+ "1",
322
+ "--force",
323
+ "--quiet",
324
+ repository,
325
+ `refs/tags/${tag}:refs/tags/${tag}`,
326
+ ]).pipe(
327
+ // A concurrent apply may have fetched the tag first; keep its result.
328
+ Effect.catchTag("EffectSourceCommandError", (error) =>
329
+ runGit(repositoryDir, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]).pipe(
330
+ Effect.mapError(() => error),
331
+ Effect.asVoid,
332
+ ),
333
+ ),
334
+ );
335
+ }
336
+
337
+ return repositoryDir;
338
+ });
339
+
275
340
  export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function* (
276
341
  plan: EffectSourcePlan,
277
342
  ) {
278
343
  if (plan.action !== "sync") return;
279
344
  const fs = yield* FileSystem.FileSystem;
280
345
  const path = yield* Path.Path;
346
+ const repositoryDir = yield* ensureSharedRepository(plan.repository, plan.tag);
281
347
 
282
348
  if (!(yield* fs.exists(plan.checkoutDir))) {
283
349
  const parent = path.dirname(plan.checkoutDir);
@@ -291,15 +357,15 @@ export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function
291
357
 
292
358
  yield* runGit(plan.projectDir, [
293
359
  "clone",
294
- "--depth",
295
- "1",
360
+ "--quiet",
296
361
  "--branch",
297
362
  plan.tag,
298
363
  "--single-branch",
299
364
  "--",
300
- plan.repository,
365
+ repositoryDir,
301
366
  staged,
302
367
  ]);
368
+ yield* runGit(staged, ["remote", "set-url", "origin", plan.repository]);
303
369
  if (yield* fs.exists(plan.checkoutDir)) {
304
370
  return yield* EffectSourceCheckoutError.make({
305
371
  message: `Effect source destination appeared while cloning: ${plan.checkoutDir}`,
@@ -316,7 +382,7 @@ export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function
316
382
  "1",
317
383
  "--force",
318
384
  "--quiet",
319
- "origin",
385
+ repositoryDir,
320
386
  `refs/tags/${plan.tag}:refs/tags/${plan.tag}`,
321
387
  ]);
322
388
  const target = yield* runGit(plan.checkoutDir, [
@@ -0,0 +1,304 @@
1
+ import {
2
+ Config,
3
+ DateTime,
4
+ Effect,
5
+ FileSystem,
6
+ Option,
7
+ Path,
8
+ Schema,
9
+ Stream,
10
+ type PlatformError,
11
+ } from "effect";
12
+ import { ChildProcess } from "effect/unstable/process";
13
+
14
+ import { printStatus } from "./cli-ui.ts";
15
+
16
+ export class GlobalCacheError extends Schema.TaggedError<GlobalCacheError>()("GlobalCacheError", {
17
+ message: Schema.String,
18
+ }) {}
19
+
20
+ /**
21
+ * Resolves the machine-global dev-kit cache directory. Entries stored here are
22
+ * keyed by immutable identifiers (resolved commit SHAs, repository URLs), so
23
+ * the cache is shared safely across projects and git worktrees. Populating it
24
+ * is not project state: locked verification and dry-run planning may write
25
+ * here without violating their read-only project semantics.
26
+ */
27
+ export const resolveGlobalCacheDirectory = Effect.fn("resolveGlobalCacheDirectory")(function* () {
28
+ const fs = yield* FileSystem.FileSystem;
29
+ const path = yield* Path.Path;
30
+ const override = yield* Config.string("DEV_KIT_CACHE_DIR").pipe(Config.withDefault(""));
31
+
32
+ if (override.length > 0) return path.resolve(override);
33
+ const xdgCacheHome = yield* Config.string("XDG_CACHE_HOME").pipe(Config.withDefault(""));
34
+
35
+ if (xdgCacheHome.length > 0 && path.isAbsolute(xdgCacheHome)) {
36
+ return path.join(xdgCacheHome, "dev-kit");
37
+ }
38
+ const home = yield* Config.string("HOME").pipe(
39
+ Config.orElse(() => Config.string("USERPROFILE")),
40
+ Config.withDefault(""),
41
+ );
42
+
43
+ if (home.length === 0 || !path.isAbsolute(home)) {
44
+ return yield* GlobalCacheError.make({
45
+ message: "cannot locate the dev-kit cache; set DEV_KIT_CACHE_DIR, XDG_CACHE_HOME, or HOME",
46
+ });
47
+ }
48
+ // The user cache convention differs per platform; an existing Library/Caches
49
+ // identifies macOS without reaching for Node platform APIs.
50
+ const macCaches = path.join(home, "Library", "Caches");
51
+
52
+ return (yield* fs.exists(macCaches))
53
+ ? path.join(macCaches, "dev-kit")
54
+ : path.join(home, ".cache", "dev-kit");
55
+ });
56
+
57
+ /**
58
+ * Publishes a fully staged cache entry at its immutable destination. The
59
+ * staged directory must live on the same filesystem so the rename is atomic;
60
+ * when a concurrent writer publishes the destination first, its entry wins and
61
+ * the staged copy is discarded.
62
+ */
63
+ export const commitCacheDirectory = Effect.fn("commitCacheDirectory")(function* (
64
+ staged: string,
65
+ destination: string,
66
+ isPopulated: Effect.Effect<boolean, PlatformError.PlatformError>,
67
+ ) {
68
+ const fs = yield* FileSystem.FileSystem;
69
+
70
+ if ((yield* fs.exists(destination)) && !(yield* isPopulated)) {
71
+ yield* fs.remove(destination, { force: true, recursive: true });
72
+ }
73
+
74
+ yield* fs.rename(staged, destination).pipe(
75
+ Effect.catch((error) =>
76
+ Effect.gen(function* () {
77
+ if (!(yield* isPopulated)) return yield* error;
78
+
79
+ return yield* fs.remove(staged, { force: true, recursive: true });
80
+ }),
81
+ ),
82
+ );
83
+ });
84
+
85
+ export const CACHE_PRUNE_AGE_DAYS = 30;
86
+
87
+ const DAY_MILLIS = 24 * 60 * 60 * 1000;
88
+ const LAST_USED_STAMP = ".last-used";
89
+ const TAG_USAGE_DIRECTORY = "dev-kit-tag-usage";
90
+
91
+ const runGit = Effect.fn("runGlobalCacheGit")(function* (cwd: string, args: ReadonlyArray<string>) {
92
+ const child = yield* ChildProcess.make("git", args, {
93
+ cwd,
94
+ stderr: "pipe",
95
+ stdout: "pipe",
96
+ });
97
+ const [output, exitCode] = yield* Effect.all([
98
+ Stream.mkString(Stream.decodeText(child.all)),
99
+ child.exitCode,
100
+ ]);
101
+
102
+ if (exitCode !== 0) {
103
+ return yield* GlobalCacheError.make({
104
+ message: `git ${args.join(" ")} failed: ${output.trim()}`,
105
+ });
106
+ }
107
+
108
+ return output.trim();
109
+ });
110
+
111
+ const readMtimeMillis = Effect.fn("readCacheMtimeMillis")(function* (target: string) {
112
+ const fs = yield* FileSystem.FileSystem;
113
+ const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void));
114
+
115
+ if (info === undefined) return undefined;
116
+
117
+ return Option.match(info.mtime, {
118
+ onNone: () => undefined,
119
+ onSome: (mtime) => mtime.getTime(),
120
+ });
121
+ });
122
+
123
+ const listDirectory = Effect.fn("listCacheDirectory")(function* (directory: string) {
124
+ const fs = yield* FileSystem.FileSystem;
125
+
126
+ return (yield* fs.exists(directory)) ? yield* fs.readDirectory(directory) : [];
127
+ });
128
+
129
+ /** Best-effort recency stamp for a catalog cache entry; read by pruning. */
130
+ export const stampCacheEntryUsage = Effect.fn("stampCacheEntryUsage")(function* (entryDir: string) {
131
+ const fs = yield* FileSystem.FileSystem;
132
+ const path = yield* Path.Path;
133
+
134
+ yield* fs.writeFileString(path.join(entryDir, LAST_USED_STAMP), "").pipe(Effect.ignore);
135
+ });
136
+
137
+ /**
138
+ * Best-effort recency stamp for one tag in a shared repository. Written before
139
+ * the tag is fetched so pruning never mistakes an in-flight fetch for an
140
+ * unused tag.
141
+ */
142
+ export const stampTagUsage = Effect.fn("stampTagUsage")(function* (
143
+ repositoryDir: string,
144
+ tag: string,
145
+ ) {
146
+ const fs = yield* FileSystem.FileSystem;
147
+ const path = yield* Path.Path;
148
+ const usageDir = path.join(repositoryDir, TAG_USAGE_DIRECTORY);
149
+
150
+ yield* fs
151
+ .makeDirectory(usageDir, { recursive: true })
152
+ .pipe(
153
+ Effect.andThen(fs.writeFileString(path.join(usageDir, encodeURIComponent(tag)), "")),
154
+ Effect.ignore,
155
+ );
156
+ });
157
+
158
+ export type GlobalCachePruneOptions = {
159
+ readonly all?: boolean;
160
+ readonly maxAgeDays?: number;
161
+ };
162
+
163
+ export type GlobalCachePruneSummary = {
164
+ readonly removedEntries: number;
165
+ readonly removedRepositories: number;
166
+ readonly removedTags: number;
167
+ };
168
+
169
+ /**
170
+ * Evicts cache content that no project on the machine has used recently.
171
+ * Everything here is regenerable, so eviction is always safe: a wrongly
172
+ * removed entry is simply fetched again on the next apply.
173
+ */
174
+ export const pruneGlobalCache = Effect.fn("pruneGlobalCache")(function* (
175
+ options: GlobalCachePruneOptions = {},
176
+ ) {
177
+ const fs = yield* FileSystem.FileSystem;
178
+ const path = yield* Path.Path;
179
+ const cacheDir = yield* resolveGlobalCacheDirectory();
180
+ const catalogDir = path.join(cacheDir, "catalog");
181
+ const repositoriesDir = path.join(cacheDir, "effect-source");
182
+ let removedEntries = 0;
183
+ let removedRepositories = 0;
184
+ let removedTags = 0;
185
+
186
+ if (options.all === true) {
187
+ for (const id of yield* listDirectory(catalogDir)) {
188
+ removedEntries += (yield* listDirectory(path.join(catalogDir, id))).length;
189
+ }
190
+ removedRepositories = (yield* listDirectory(repositoriesDir)).length;
191
+ yield* fs.remove(catalogDir, { force: true, recursive: true });
192
+ yield* fs.remove(repositoriesDir, { force: true, recursive: true });
193
+
194
+ return { removedEntries, removedRepositories, removedTags } satisfies GlobalCachePruneSummary;
195
+ }
196
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
197
+ const cutoff = now - (options.maxAgeDays ?? CACHE_PRUNE_AGE_DAYS) * DAY_MILLIS;
198
+
199
+ // Catalog entries and orphaned staging directories age out individually.
200
+ for (const id of yield* listDirectory(catalogDir)) {
201
+ const idDir = path.join(catalogDir, id);
202
+
203
+ for (const entry of yield* listDirectory(idDir)) {
204
+ const entryDir = path.join(idDir, entry);
205
+ const lastUsed =
206
+ (yield* readMtimeMillis(path.join(entryDir, LAST_USED_STAMP))) ??
207
+ (yield* readMtimeMillis(path.join(entryDir, ".ready"))) ??
208
+ (yield* readMtimeMillis(entryDir));
209
+
210
+ if (lastUsed !== undefined && lastUsed < cutoff) {
211
+ yield* fs.remove(entryDir, { force: true, recursive: true }).pipe(Effect.ignore);
212
+ removedEntries += 1;
213
+ }
214
+ }
215
+ }
216
+ // Shared repositories drop tags individually; a repository that no longer
217
+ // holds any tag is removed whole.
218
+ for (const name of yield* listDirectory(repositoriesDir)) {
219
+ const repositoryDir = path.join(repositoriesDir, name);
220
+
221
+ if (!(yield* fs.exists(path.join(repositoryDir, "HEAD")))) {
222
+ const mtime = yield* readMtimeMillis(repositoryDir);
223
+
224
+ if (mtime !== undefined && mtime < cutoff) {
225
+ yield* fs.remove(repositoryDir, { force: true, recursive: true }).pipe(Effect.ignore);
226
+ removedRepositories += 1;
227
+ }
228
+ continue;
229
+ }
230
+ const listed = yield* runGit(repositoryDir, ["tag", "--list"]).pipe(
231
+ Effect.catchTag("GlobalCacheError", () => Effect.void),
232
+ );
233
+
234
+ if (listed === undefined) {
235
+ // Unreadable repository: age it out by initialization time.
236
+ if (((yield* readMtimeMillis(path.join(repositoryDir, "HEAD"))) ?? 0) < cutoff) {
237
+ yield* fs.remove(repositoryDir, { force: true, recursive: true }).pipe(Effect.ignore);
238
+ removedRepositories += 1;
239
+ }
240
+ continue;
241
+ }
242
+ const tags = listed.split("\n").filter((tag) => tag.length > 0);
243
+ let kept = 0;
244
+ let deleted = 0;
245
+
246
+ for (const tag of tags) {
247
+ const stamp = path.join(repositoryDir, TAG_USAGE_DIRECTORY, encodeURIComponent(tag));
248
+
249
+ if (((yield* readMtimeMillis(stamp)) ?? 0) < cutoff) {
250
+ yield* runGit(repositoryDir, ["update-ref", "-d", `refs/tags/${tag}`]).pipe(Effect.ignore);
251
+ yield* fs.remove(stamp, { force: true }).pipe(Effect.ignore);
252
+ removedTags += 1;
253
+ deleted += 1;
254
+ } else {
255
+ kept += 1;
256
+ }
257
+ }
258
+ if (kept === 0) {
259
+ if (
260
+ deleted > 0 ||
261
+ ((yield* readMtimeMillis(path.join(repositoryDir, "HEAD"))) ?? 0) < cutoff
262
+ ) {
263
+ yield* fs.remove(repositoryDir, { force: true, recursive: true }).pipe(Effect.ignore);
264
+ removedRepositories += 1;
265
+ }
266
+ } else if (deleted > 0) {
267
+ yield* runGit(repositoryDir, ["gc", "--prune=now", "--quiet"]).pipe(Effect.ignore);
268
+ }
269
+ }
270
+
271
+ return { removedEntries, removedRepositories, removedTags } satisfies GlobalCachePruneSummary;
272
+ });
273
+
274
+ /**
275
+ * Opportunistic prune for the apply lifecycle: runs the age-based sweep at
276
+ * most once a day so routine applies stay fast.
277
+ */
278
+ export const maybePruneGlobalCache = Effect.fn("maybePruneGlobalCache")(function* () {
279
+ const fs = yield* FileSystem.FileSystem;
280
+ const path = yield* Path.Path;
281
+ const cacheDir = yield* resolveGlobalCacheDirectory();
282
+ const marker = path.join(cacheDir, ".last-pruned");
283
+ const lastPruned = yield* readMtimeMillis(marker);
284
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
285
+
286
+ if (lastPruned !== undefined && now - lastPruned < DAY_MILLIS) return;
287
+ yield* fs.makeDirectory(cacheDir, { recursive: true });
288
+ yield* fs.writeFileString(marker, "");
289
+ yield* pruneGlobalCache();
290
+ });
291
+
292
+ export const runCachePrune = Effect.fn("runCachePrune")(function* (
293
+ options: GlobalCachePruneOptions = {},
294
+ ) {
295
+ const cacheDir = yield* resolveGlobalCacheDirectory();
296
+ const summary = yield* pruneGlobalCache(options);
297
+
298
+ yield* printStatus(
299
+ "success",
300
+ options.all === true ? "Cache cleared" : "Cache pruned",
301
+ `removed ${summary.removedEntries} catalog entries, ${summary.removedTags} tags, ` +
302
+ `${summary.removedRepositories} repositories from ${cacheDir}`,
303
+ );
304
+ });
package/src/oxlint.js CHANGED
@@ -8,6 +8,29 @@ import { devKitToolIgnorePatterns } from "./tool-ignore-patterns.js";
8
8
 
9
9
  export { devKitToolIgnorePatterns } from "./tool-ignore-patterns.js";
10
10
 
11
+ export const createAbsoluteImportsOxlintOverride = (options) => {
12
+ if (options.files.length === 0) {
13
+ throw new Error("absolute imports enforcement requires at least one file glob");
14
+ }
15
+ const files = [...new Set(options.files)];
16
+
17
+ if (files.length !== options.files.length) {
18
+ throw new Error("absolute imports file globs must be unique");
19
+ }
20
+ for (const glob of files) {
21
+ if (glob.trim().length === 0) {
22
+ throw new Error("absolute imports file globs must not be blank");
23
+ }
24
+ }
25
+
26
+ return {
27
+ files,
28
+ rules: {
29
+ "import/no-relative-parent-imports": "error",
30
+ },
31
+ };
32
+ };
33
+
11
34
  export const recommendedOxlintConfig = {
12
35
  ignorePatterns: [...devKitToolIgnorePatterns],
13
36
  options: {
package/src/oxlint.ts CHANGED
@@ -1,9 +1,45 @@
1
- import type { OxlintConfig } from "oxlint";
1
+ import type { OxlintConfig, OxlintOverride } from "oxlint";
2
2
 
3
3
  import { devKitToolIgnorePatterns } from "./tool-ignore-patterns.ts";
4
4
 
5
5
  export { devKitToolIgnorePatterns } from "./tool-ignore-patterns.ts";
6
6
 
7
+ export type AbsoluteImportsOptions = {
8
+ /** Globs that must use path-alias imports, e.g. `"apps/app/src/**"`. */
9
+ readonly files: ReadonlyArray<string>;
10
+ };
11
+
12
+ /**
13
+ * Build an Oxlint override that forbids `../` imports inside the given globs,
14
+ * so those files import through tsconfig path aliases such as `@/*`. Append it
15
+ * to a standalone Oxlint config's `overrides`, or opt in through
16
+ * `createRecommendedVitePlusConfig({ absoluteImports })`.
17
+ */
18
+ export const createAbsoluteImportsOxlintOverride = (
19
+ options: AbsoluteImportsOptions,
20
+ ): OxlintOverride => {
21
+ if (options.files.length === 0) {
22
+ throw new Error("absolute imports enforcement requires at least one file glob");
23
+ }
24
+ const files = [...new Set(options.files)];
25
+
26
+ if (files.length !== options.files.length) {
27
+ throw new Error("absolute imports file globs must be unique");
28
+ }
29
+ for (const glob of files) {
30
+ if (glob.trim().length === 0) {
31
+ throw new Error("absolute imports file globs must not be blank");
32
+ }
33
+ }
34
+
35
+ return {
36
+ files,
37
+ rules: {
38
+ "import/no-relative-parent-imports": "error",
39
+ },
40
+ };
41
+ };
42
+
7
43
  /**
8
44
  * High-signal Oxlint defaults for TypeScript projects.
9
45
  *
@@ -1,4 +1,4 @@
1
- import { Effect, FileSystem, Path, Schema } from "effect";
1
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
2
 
3
3
  export class ProjectPackageError extends Schema.TaggedError<ProjectPackageError>()(
4
4
  "ProjectPackageError",
@@ -91,23 +91,87 @@ export const detectPackageManager = Effect.fn("detectPackageManager")(function*
91
91
  return detected.length === 1 ? detected[0] : undefined;
92
92
  });
93
93
 
94
- export const readDirectDependencyNames = Effect.fn("readDirectDependencyNames")(function* (
95
- projectDir: string,
94
+ const readOptionalProjectPackage = Effect.fn("readOptionalProjectPackage")(function* (
95
+ packageDir: string,
96
96
  ) {
97
- const manifest = yield* readProjectPackage(projectDir).pipe(
97
+ return yield* readProjectPackage(packageDir).pipe(
98
98
  Effect.catchTag("ProjectPackageError", (error) =>
99
99
  error.message.startsWith("package.json not found:") ? Effect.void : Effect.fail(error),
100
100
  ),
101
101
  );
102
+ });
103
+
104
+ const manifestDependencyNames = (
105
+ manifest: (typeof ProjectPackageSchema)["Type"] | undefined | void,
106
+ ): ReadonlyArray<string> =>
107
+ manifest === undefined
108
+ ? []
109
+ : [
110
+ ...Object.keys(manifest.dependencies ?? {}),
111
+ ...Object.keys(manifest.devDependencies ?? {}),
112
+ ...Object.keys(manifest.optionalDependencies ?? {}),
113
+ ...Object.keys(manifest.peerDependencies ?? {}),
114
+ ];
115
+
116
+ export const readDirectDependencyNames = Effect.fn("readDirectDependencyNames")(function* (
117
+ projectDir: string,
118
+ ) {
119
+ const manifest = yield* readOptionalProjectPackage(projectDir);
120
+
121
+ return [...new Set(manifestDependencyNames(manifest))].sort();
122
+ });
123
+
124
+ const WorkspacePatternsSchema = Schema.Union([
125
+ Schema.Array(Schema.String),
126
+ Schema.Struct({ packages: Schema.Array(Schema.String) }),
127
+ ]);
128
+ // `workspaces` is declared `Schema.Unknown` in the project manifest schema, so
129
+ // this is a genuinely untyped boundary.
130
+ const decodeWorkspacePatterns = Schema.decodeUnknownOption(WorkspacePatternsSchema);
131
+
132
+ const workspacePatterns = (workspaces: unknown): ReadonlyArray<string> => {
133
+ const decoded = decodeWorkspacePatterns(workspaces);
102
134
 
103
- if (manifest === undefined) return [];
135
+ if (Option.isNone(decoded)) return [];
136
+
137
+ return "packages" in decoded.value ? decoded.value.packages : decoded.value;
138
+ };
139
+
140
+ /**
141
+ * Direct dependency names of the project package plus every workspace member
142
+ * package. Only literal workspace paths and single trailing-star globs
143
+ * (`apps/*`) are expanded; other patterns are skipped.
144
+ */
145
+ export const readWorkspaceDependencyNames = Effect.fn("readWorkspaceDependencyNames")(function* (
146
+ projectDir: string,
147
+ ) {
148
+ const fs = yield* FileSystem.FileSystem;
149
+ const path = yield* Path.Path;
150
+ const manifest = yield* readOptionalProjectPackage(projectDir);
151
+ const names = new Set(manifestDependencyNames(manifest));
152
+
153
+ for (const pattern of workspacePatterns(manifest?.workspaces)) {
154
+ if (pattern.startsWith("!")) continue;
155
+ const star = pattern.indexOf("*");
156
+ let memberDirs: ReadonlyArray<string> = [];
157
+
158
+ if (star === -1) {
159
+ memberDirs = [pattern];
160
+ } else if (pattern.endsWith("/*") && star === pattern.length - 1) {
161
+ const parent = path.join(projectDir, pattern.slice(0, -2));
162
+
163
+ if (yield* fs.exists(parent)) {
164
+ memberDirs = (yield* fs.readDirectory(parent)).map((name) =>
165
+ path.join(pattern.slice(0, -2), name),
166
+ );
167
+ }
168
+ }
169
+ for (const memberDir of memberDirs) {
170
+ const member = yield* readOptionalProjectPackage(path.join(projectDir, memberDir));
171
+
172
+ for (const name of manifestDependencyNames(member)) names.add(name);
173
+ }
174
+ }
104
175
 
105
- return [
106
- ...new Set([
107
- ...Object.keys(manifest.dependencies ?? {}),
108
- ...Object.keys(manifest.devDependencies ?? {}),
109
- ...Object.keys(manifest.optionalDependencies ?? {}),
110
- ...Object.keys(manifest.peerDependencies ?? {}),
111
- ]),
112
- ].sort();
176
+ return [...names].sort();
113
177
  });
package/src/sync.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  planEffectTsgoPatch,
16
16
  type EffectTsgoPatchPlan,
17
17
  } from "./effect-tsgo.ts";
18
+ import { maybePruneGlobalCache } from "./global-cache.ts";
18
19
  import { DevKitManifestSchema, normalizeManifest } from "./manifest.ts";
19
20
  import { observeSymbolicLink } from "./node-symbolic-link.ts";
20
21
  import { resolvePackageSkillSelector } from "./package-skill-source.ts";
@@ -29,6 +30,7 @@ import {
29
30
  detectPackageManager,
30
31
  PACKAGE_MANAGER_COMMANDS,
31
32
  readDirectDependencyNames,
33
+ readWorkspaceDependencyNames,
32
34
  readProjectPackage,
33
35
  type PackageManagerName,
34
36
  } from "./project-package.ts";
@@ -266,7 +268,13 @@ const encodePlanSnapshotJson = Schema.encodeSync(Schema.fromJsonString(Schema.Un
266
268
  const encodeAppliedStatePrettyJson = Schema.encodeSync(fromJsonString(AppliedStateSchema, 2));
267
269
 
268
270
  const SKILL_FAMILIES: SkillCatalog = {
269
- effect: ["effect-ts", "effect-architecture-audit", "build-effect-apis", "build-effect-clis"],
271
+ effect: [
272
+ "effect-ts",
273
+ "effect-architecture-audit",
274
+ "build-effect-apis",
275
+ "effect-atom-state",
276
+ "build-effect-clis",
277
+ ],
270
278
  };
271
279
 
272
280
  export const DEFAULT_MANIFEST = "dev-kit.jsonc";
@@ -805,7 +813,7 @@ const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
805
813
  );
806
814
  const directDependencyNames = yield* readDirectDependencyNames(projectDir);
807
815
  const usesVitePlus = directDependencyNames.includes("vite-plus");
808
- const effectInstructions =
816
+ const effectGuideInstructions =
809
817
  directDependencyNames.includes("effect") &&
810
818
  (yield* observePath(path.join(projectDir, "node_modules", "effect", "AGENTS.md"))).kind ===
811
819
  "file"
@@ -821,6 +829,20 @@ guide doesn't cover, search through the source code in \`node_modules/effect/src
821
829
 
822
830
  `
823
831
  : "";
832
+ const atomBoundaryInstructions = (yield* readWorkspaceDependencyNames(projectDir)).includes(
833
+ "@effect/atom-react",
834
+ )
835
+ ? `# Effect Atom client boundary
836
+
837
+ This repository consumes APIs through Effect Atom clients (\`@effect/atom-react\`).
838
+ Keep business logic in Effect: compose multi-step client workflows as atoms,
839
+ declare cross-query invalidation as reactivity keys on mutations, and keep
840
+ promise-mode dispatches at the React boundary logic-free — no \`.then\` chains
841
+ in components or routes.
842
+
843
+ `
844
+ : "";
845
+ const effectInstructions = `${effectGuideInstructions}${atomBoundaryInstructions}`;
824
846
  const projectPackage = yield* readProjectPackage(projectDir).pipe(
825
847
  Effect.catchTag("ProjectPackageError", (error) =>
826
848
  error.message.startsWith("package.json not found:") ? Effect.void : Effect.fail(error),
@@ -1877,6 +1899,18 @@ export const runProjectSkillPlan = Effect.fn("runProjectSkillPlan")(function* (
1877
1899
  yield* applyPlannedSkillChanges(replanned);
1878
1900
  }),
1879
1901
  );
1902
+ const fs = yield* FileSystem.FileSystem;
1903
+ const path = yield* Path.Path;
1904
+
1905
+ // Catalog checkouts moved to the machine-global cache; drop the regenerable
1906
+ // project-local copies left behind by earlier dev-kit versions.
1907
+ yield* fs
1908
+ .remove(path.join(plan.projectDir, ".dev-kit", "cache", "catalog"), {
1909
+ force: true,
1910
+ recursive: true,
1911
+ })
1912
+ .pipe(Effect.ignore);
1913
+ yield* maybePruneGlobalCache().pipe(Effect.ignore);
1880
1914
  yield* printStatus(
1881
1915
  "success",
1882
1916
  changes === 0 && !replanned.metadataChanged ? "Dev kit up to date" : "Dev kit ready",