@danieljvdm/dev-kit 0.15.0 → 0.17.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/README.md +73 -7
- package/package.json +1 -1
- package/skill-sources.jsonc +17 -0
- package/skill-sources.lock.json +48 -0
- package/skills/build-effect-apis/SKILL.md +13 -31
- package/skills/build-effect-apis/references/verification.md +3 -3
- package/skills/effect-atom-state/SKILL.md +97 -0
- package/skills/effect-atom-state/agents/openai.yaml +4 -0
- package/skills/effect-atom-state/references/effect-atom-workflows.md +180 -0
- package/src/bin/dev-kit.ts +21 -0
- package/src/catalog.ts +39 -15
- package/src/effect-source.ts +70 -4
- package/src/global-cache.ts +304 -0
- package/src/oxlint.js +23 -0
- package/src/oxlint.ts +37 -1
- package/src/project-package.ts +77 -13
- package/src/sync.ts +36 -2
- package/src/vite-plus.js +8 -1
- package/src/vite-plus.ts +15 -1
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-client.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-lifecycle.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-testing.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/tanstack-start.md +0 -0
package/src/catalog.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
|
2
2
|
import { ChildProcess } from "effect/unstable/process";
|
|
3
3
|
import { parse as parseJsonc, type ParseError } from "jsonc-parser";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
commitCacheDirectory,
|
|
7
|
+
resolveGlobalCacheDirectory,
|
|
8
|
+
stampCacheEntryUsage,
|
|
9
|
+
} from "./global-cache.ts";
|
|
5
10
|
import {
|
|
6
11
|
discoverPackageSkills,
|
|
7
12
|
resolvePackageSkillSelector,
|
|
@@ -181,7 +186,13 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
188
|
const families: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
184
|
-
effect: [
|
|
189
|
+
effect: [
|
|
190
|
+
"effect-ts",
|
|
191
|
+
"effect-architecture-audit",
|
|
192
|
+
"build-effect-apis",
|
|
193
|
+
"effect-atom-state",
|
|
194
|
+
"build-effect-clis",
|
|
195
|
+
],
|
|
185
196
|
...Object.fromEntries(externalFamilies),
|
|
186
197
|
};
|
|
187
198
|
|
|
@@ -261,26 +272,35 @@ const materializePackageSkill = Effect.fn("materializePackageSkill")(function* (
|
|
|
261
272
|
return staged;
|
|
262
273
|
});
|
|
263
274
|
|
|
275
|
+
// Catalog checkouts are keyed by source id and resolved commit SHA, so the
|
|
276
|
+
// materialized content is immutable and shared machine-wide across projects
|
|
277
|
+
// and worktrees. Planning and locked verification populate the same cache:
|
|
278
|
+
// writing an immutable commit-keyed cache entry is not project state.
|
|
264
279
|
const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
265
|
-
projectDir: string,
|
|
266
280
|
source: LockedSkillSource,
|
|
267
281
|
selected: ReadonlyArray<string>,
|
|
268
|
-
cache: boolean,
|
|
269
282
|
) {
|
|
270
283
|
const fs = yield* FileSystem.FileSystem;
|
|
271
284
|
const path = yield* Path.Path;
|
|
272
|
-
const root =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
);
|
|
279
|
-
const checkout = path.join(root, "checkout");
|
|
285
|
+
const root = path.join(
|
|
286
|
+
yield* resolveGlobalCacheDirectory(),
|
|
287
|
+
"catalog",
|
|
288
|
+
source.id,
|
|
289
|
+
source.resolved,
|
|
290
|
+
);
|
|
280
291
|
const ready = path.join(root, ".ready");
|
|
281
292
|
|
|
282
293
|
if (!(yield* fs.exists(ready))) {
|
|
283
|
-
yield* fs.
|
|
294
|
+
yield* fs.makeDirectory(path.dirname(root), { recursive: true });
|
|
295
|
+
const staged = path.join(
|
|
296
|
+
yield* fs.makeTempDirectoryScoped({
|
|
297
|
+
directory: path.dirname(root),
|
|
298
|
+
prefix: ".dev-kit-catalog-stage-",
|
|
299
|
+
}),
|
|
300
|
+
source.resolved,
|
|
301
|
+
);
|
|
302
|
+
const checkout = path.join(staged, "checkout");
|
|
303
|
+
|
|
284
304
|
yield* fs.makeDirectory(checkout, { recursive: true });
|
|
285
305
|
yield* runGit(checkout, ["init", "--quiet"]);
|
|
286
306
|
yield* runGit(checkout, ["remote", "add", "origin", source.repository]);
|
|
@@ -302,7 +322,7 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
302
322
|
}
|
|
303
323
|
for (const skill of source.skills) {
|
|
304
324
|
const from = path.join(checkout, source.skillsPath, skill);
|
|
305
|
-
const to = path.join(
|
|
325
|
+
const to = path.join(staged, "skills", skill);
|
|
306
326
|
const observation = yield* observePath(from);
|
|
307
327
|
|
|
308
328
|
if (observation.kind !== "directory") {
|
|
@@ -320,8 +340,10 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
320
340
|
);
|
|
321
341
|
}
|
|
322
342
|
}
|
|
323
|
-
yield* fs.writeFileString(ready, `${source.resolved}\n`);
|
|
343
|
+
yield* fs.writeFileString(path.join(staged, ".ready"), `${source.resolved}\n`);
|
|
344
|
+
yield* commitCacheDirectory(staged, root, fs.exists(ready));
|
|
324
345
|
}
|
|
346
|
+
yield* stampCacheEntryUsage(root);
|
|
325
347
|
for (const skill of selected) {
|
|
326
348
|
const observation = yield* observePath(path.join(root, "skills", skill));
|
|
327
349
|
const approvedDigest = source.digests?.[skill];
|
|
@@ -351,6 +373,8 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
351
373
|
);
|
|
352
374
|
});
|
|
353
375
|
|
|
376
|
+
// The cache flag only affects package skills, whose staging area is project
|
|
377
|
+
// state under .dev-kit; catalog sources always use the machine-global cache.
|
|
354
378
|
export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
355
379
|
packageRoot: string,
|
|
356
380
|
projectDir: string,
|
|
@@ -370,7 +394,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
|
370
394
|
const wanted = source.skills.filter((skill) => selected.includes(skill));
|
|
371
395
|
|
|
372
396
|
if (wanted.length === 0) continue;
|
|
373
|
-
for (const [name, sourcePath] of yield* materializeSource(
|
|
397
|
+
for (const [name, sourcePath] of yield* materializeSource(source, wanted)) {
|
|
374
398
|
sources.set(name, sourcePath);
|
|
375
399
|
}
|
|
376
400
|
}
|
package/src/effect-source.ts
CHANGED
|
@@ -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
|
-
"--
|
|
295
|
-
"1",
|
|
360
|
+
"--quiet",
|
|
296
361
|
"--branch",
|
|
297
362
|
plan.tag,
|
|
298
363
|
"--single-branch",
|
|
299
364
|
"--",
|
|
300
|
-
|
|
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
|
-
|
|
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
|
*
|