@danieljvdm/dev-kit 0.14.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.
- package/README.md +155 -99
- package/dev-kit.example.jsonc +4 -3
- package/package.json +1 -1
- package/schema/dev-kit.schema.json +19 -42
- package/skills/build-effect-apis/SKILL.md +13 -31
- package/skills/build-effect-apis/references/verification.md +3 -3
- package/skills/dev-kit/SKILL.md +115 -222
- 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/skills/open-pull-request/SKILL.md +62 -23
- 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/index.ts +6 -6
- package/src/manifest.ts +28 -29
- package/src/oxlint.js +23 -0
- package/src/oxlint.ts +37 -1
- package/src/path-digest.ts +0 -13
- package/src/project-package.ts +127 -12
- package/src/project-state.ts +3 -0
- package/src/scaffold.ts +79 -0
- package/src/sync.ts +100 -173
- package/src/vite-plus-workflow.ts +82 -0
- package/src/vite-plus.js +8 -1
- package/src/vite-plus.ts +15 -1
- package/src/worktrunk-config.ts +88 -0
- package/templates/vite-plus/github-actions-check.yml +0 -2
- package/templates/worktrunk/wt.toml +27 -0
- package/src/vite-plus-quality.ts +0 -148
- /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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Effect Atom workflows
|
|
2
|
+
|
|
3
|
+
Multi-step client actions are Effects composing atoms. Components dispatch
|
|
4
|
+
them and render `AsyncResult` state; no orchestration crosses the React
|
|
5
|
+
boundary. Confirm exact signatures against the installed
|
|
6
|
+
`effect/unstable/reactivity` declarations before copying.
|
|
7
|
+
|
|
8
|
+
- [Define workflow atoms with Atom.fn](#define-workflow-atoms-with-atomfn)
|
|
9
|
+
- [Compose atoms through the fn context](#compose-atoms-through-the-fn-context)
|
|
10
|
+
- [Overlay optimistic query values](#overlay-optimistic-query-values)
|
|
11
|
+
- [Dispatch from React](#dispatch-from-react)
|
|
12
|
+
- [Reinforce the boundary with a lint rule](#reinforce-the-boundary-with-a-lint-rule)
|
|
13
|
+
|
|
14
|
+
## Define workflow atoms with Atom.fn
|
|
15
|
+
|
|
16
|
+
`Atom.fn<Input>()(effect)` creates a writable atom: writing an input runs the
|
|
17
|
+
effect, and the atom's value is the `AsyncResult` of the latest run. The
|
|
18
|
+
effect receives `(input, get: Atom.FnContext)`.
|
|
19
|
+
|
|
20
|
+
Bare `Atom.fn` accepts no reactivity keys. When the workflow needs Effect
|
|
21
|
+
services or invalidation, create it through a runtime factory —
|
|
22
|
+
`AtomHttpApi.Service` exposes its own as `Client.runtime.fn`:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Effect } from "effect";
|
|
26
|
+
import { Atom, Reactivity } from "effect/unstable/reactivity";
|
|
27
|
+
|
|
28
|
+
export const updateProject = ApiClient.runtime.fn(
|
|
29
|
+
Effect.fnUntraced(function* (input: { readonly projectId: ProjectId; readonly patch: Patch }) {
|
|
30
|
+
const client = yield* ApiClient;
|
|
31
|
+
|
|
32
|
+
return yield* Reactivity.mutation(
|
|
33
|
+
client.projects.updateProject({
|
|
34
|
+
params: { projectId: input.projectId },
|
|
35
|
+
payload: input.patch,
|
|
36
|
+
}),
|
|
37
|
+
[...projectKeys.collection, ...projectKeys.project(input.projectId)],
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Prefer `Client.mutation(group, endpoint)` with call-site `reactivityKeys` for a
|
|
44
|
+
single request; wrap the client call in `runtime.fn` with
|
|
45
|
+
`Reactivity.mutation(effect, keys)` when the key set depends on the input or
|
|
46
|
+
the workflow spans several requests. Either way, keys invalidate only when the
|
|
47
|
+
effect succeeds.
|
|
48
|
+
|
|
49
|
+
Invalidation spans clients: every `AtomHttpApi.Service` built on the same
|
|
50
|
+
runtime factory shares one `Reactivity` instance, so a mutation on one client
|
|
51
|
+
invalidates query keys registered by another. A service given its own
|
|
52
|
+
`Atom.context()` gets a separate `Reactivity` and cannot invalidate the rest —
|
|
53
|
+
share one runtime factory across API services on purpose.
|
|
54
|
+
|
|
55
|
+
## Compose atoms through the fn context
|
|
56
|
+
|
|
57
|
+
Inside the effect, the fn context is the composition surface:
|
|
58
|
+
|
|
59
|
+
- `get.setResult(fnAtom, input)` writes another fn atom and returns an Effect
|
|
60
|
+
of its settled result — the await-another-workflow primitive;
|
|
61
|
+
- `get.set(stateAtom, value)` writes a state atom;
|
|
62
|
+
- `get(atom)` reads the current value **untracked** — the workflow never
|
|
63
|
+
subscribes, so mutating a state atom from inside the effect cannot
|
|
64
|
+
re-trigger it.
|
|
65
|
+
|
|
66
|
+
Optimistic echo with rollback, keyed by the owning entity so the atoms
|
|
67
|
+
dispose with it:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { Effect, Exit } from "effect";
|
|
71
|
+
|
|
72
|
+
interface PendingComment {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly body: string;
|
|
75
|
+
readonly status: "sending" | "queued";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const pendingCommentsAtom = Atom.family((_projectId: ProjectId) =>
|
|
79
|
+
Atom.make<ReadonlyArray<PendingComment>>([]),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const sendCommentWithEcho = Atom.family((projectId: ProjectId) =>
|
|
83
|
+
Atom.fn<{ readonly body: string }>()(
|
|
84
|
+
Effect.fnUntraced(function* (input, get) {
|
|
85
|
+
const pending = pendingCommentsAtom(projectId);
|
|
86
|
+
const id = yield* Effect.sync(() => crypto.randomUUID());
|
|
87
|
+
|
|
88
|
+
get.set(pending, [...get(pending), { id, body: input.body, status: "sending" }]);
|
|
89
|
+
|
|
90
|
+
const exit = yield* Effect.exit(get.setResult(sendComment, { projectId, body: input.body }));
|
|
91
|
+
|
|
92
|
+
get.set(
|
|
93
|
+
pending,
|
|
94
|
+
Exit.isSuccess(exit)
|
|
95
|
+
? get(pending).map((entry) =>
|
|
96
|
+
entry.id === id ? { ...entry, status: "queued" as const } : entry,
|
|
97
|
+
)
|
|
98
|
+
: get(pending).filter((entry) => entry.id !== id),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
yield* exit;
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The pending list lives in an `Atom.family` state atom, not component
|
|
108
|
+
`useState`, so the workflow owns append, settle, and rollback while any
|
|
109
|
+
component can render it. Re-raise the exit so the dispatching leaf still
|
|
110
|
+
observes failure.
|
|
111
|
+
|
|
112
|
+
## Overlay optimistic query values
|
|
113
|
+
|
|
114
|
+
When the optimistic value is the query's own value rather than a sidecar list,
|
|
115
|
+
wrap the query with `Atom.optimistic` and drive it with `Atom.optimisticFn`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { AsyncResult } from "effect/unstable/reactivity";
|
|
119
|
+
|
|
120
|
+
export const timelineAtom = Atom.family((projectId: ProjectId) =>
|
|
121
|
+
Atom.optimistic(timelineQuery(projectId)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
export const sendMessage = Atom.family((projectId: ProjectId) =>
|
|
125
|
+
Atom.optimisticFn(timelineAtom(projectId), {
|
|
126
|
+
reducer: (current, message: MessageDto) =>
|
|
127
|
+
AsyncResult.map(current, (page) => ({
|
|
128
|
+
...page,
|
|
129
|
+
messages: [...page.messages, message],
|
|
130
|
+
})),
|
|
131
|
+
fn: submitMessage(projectId),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The reducer computes the provisional value shown while the mutation runs; a
|
|
137
|
+
successful transition refreshes the source query, and a failure rolls the
|
|
138
|
+
value back to the latest source value. Consumers render `timelineAtom` and
|
|
139
|
+
never see the seam.
|
|
140
|
+
|
|
141
|
+
## Dispatch from React
|
|
142
|
+
|
|
143
|
+
A promise-mode dispatch is handed bare to a leaf component whose contract is
|
|
144
|
+
promise-shaped; presentation derives from the atom's `AsyncResult`:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
const [sendResult, send] = useAtom(sendMessage(projectId), { mode: "promise" });
|
|
148
|
+
const hint = AsyncResult.isSuccess(sendResult) && !sendResult.waiting ? "sent" : undefined;
|
|
149
|
+
|
|
150
|
+
return <Composer hint={hint} onSubmit={(body) => send({ body })} />;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The returned promise resolves with the success value and rejects with the
|
|
154
|
+
squashed failure cause; use `mode: "promiseExit"` when the leaf needs the full
|
|
155
|
+
`Exit`. Anything more than returning the promise — chaining a refresh, echo
|
|
156
|
+
bookkeeping, sequencing a second mutation — belongs in the workflow atom.
|
|
157
|
+
|
|
158
|
+
## Reinforce the boundary with a lint rule
|
|
159
|
+
|
|
160
|
+
A repository can back the logic-free boundary with a lint warning on `then`
|
|
161
|
+
scoped to component and route modules, mirroring the typed-codec lint pattern:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
{
|
|
165
|
+
files: ["apps/web/src/components/**", "apps/web/src/routes/**"],
|
|
166
|
+
rules: {
|
|
167
|
+
"no-restricted-properties": [
|
|
168
|
+
"warn",
|
|
169
|
+
{
|
|
170
|
+
property: "then",
|
|
171
|
+
message:
|
|
172
|
+
"Compose the workflow in Effect (Atom.fn + reactivity keys) and return promise-mode dispatches bare to the leaf component.",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Keep any justified suppression local and documented; the boundary reasoning,
|
|
180
|
+
not the lint rule, remains the source of truth.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: open-pull-request
|
|
3
|
-
description: Open pull requests with conventional commits,
|
|
3
|
+
description: Open pull requests with conventional commits, reviewer-complete descriptions, links to vital code, and concrete evidence. Use whenever preparing or opening a pull request, including checking commit history, explaining a bug or architectural change, drafting the title or body, and attaching screenshots or other evidence.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Open a Pull Request
|
|
@@ -22,18 +22,38 @@ task conversation.
|
|
|
22
22
|
`build`, `ci`, or `chore`. Keep each commit to one logical concern. Rewrite
|
|
23
23
|
only commits you created and know are unshared; get approval before
|
|
24
24
|
rewriting user-authored or published history.
|
|
25
|
-
3. Run the repository's required validation on the final branch state
|
|
26
|
-
the
|
|
27
|
-
|
|
28
|
-
the
|
|
25
|
+
3. Run the repository's required validation on the final branch state, then
|
|
26
|
+
collect the strongest available evidence of the changed behavior. Identify
|
|
27
|
+
the few files, symbols, or modules a reviewer must understand and prepare
|
|
28
|
+
links that resolve in the rendered PR. Finish when every claim can be traced
|
|
29
|
+
to the diff, CI, or a verified artifact.
|
|
29
30
|
|
|
30
31
|
## Write for the reviewer
|
|
31
32
|
|
|
32
|
-
Write
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
Write clear, compact English for someone with little context. Give the reviewer
|
|
34
|
+
enough explanation to agree with both the problem and the solution; do not
|
|
35
|
+
sacrifice causal or architectural context for brevity. Lead with the observable
|
|
36
|
+
outcome, then explain why the change was needed and how the important pieces
|
|
37
|
+
fit together. Prefer concrete nouns and expand uncommon acronyms. Describe
|
|
38
|
+
behavior and impact rather than narrating the task conversation.
|
|
39
|
+
|
|
40
|
+
Link the vital implementation points from the summary or architecture section.
|
|
41
|
+
Use descriptive link text that names each piece by its role, such as the request
|
|
42
|
+
router or cache invalidation boundary, and verify every link after opening the
|
|
43
|
+
PR. Link the core pieces a reviewer should inspect, not every touched file.
|
|
44
|
+
|
|
45
|
+
For every bug fix, include a **What went wrong** section in plain English. State
|
|
46
|
+
the incorrect behavior, its actual root cause and causal chain, and why the
|
|
47
|
+
change fixes it. Make uncertainty or incomplete coverage explicit. A result
|
|
48
|
+
such as "fixed stale state" is not a diagnosis; explain how the stale state was
|
|
49
|
+
created or allowed to survive.
|
|
50
|
+
|
|
51
|
+
When the change alters architecture, identify the affected components and
|
|
52
|
+
boundaries, what each one owns after the change, and any important change to
|
|
53
|
+
control flow, data flow, public contracts, or persistence. Link to the core
|
|
54
|
+
implementation of each affected piece. Use a dedicated **Architecture** section
|
|
55
|
+
when this would make the change easier to review; otherwise include the context
|
|
56
|
+
in the summary.
|
|
37
57
|
|
|
38
58
|
Use the repository's required template when present. Otherwise use this small
|
|
39
59
|
shape and omit empty sections:
|
|
@@ -42,38 +62,57 @@ shape and omit empty sections:
|
|
|
42
62
|
## Summary
|
|
43
63
|
|
|
44
64
|
- <What changes for a user, operator, or developer>
|
|
45
|
-
- <Why it matters
|
|
65
|
+
- <Why it matters and the shape of the solution, with links to vital code>
|
|
66
|
+
|
|
67
|
+
## What went wrong
|
|
68
|
+
|
|
69
|
+
<For a bug fix: explain the symptom, root cause, causal chain, and why this fix
|
|
70
|
+
addresses it.>
|
|
46
71
|
|
|
47
|
-
##
|
|
72
|
+
## Architecture
|
|
48
73
|
|
|
49
|
-
-
|
|
50
|
-
|
|
74
|
+
- <When applicable: explain the changed components, ownership, and flow, with
|
|
75
|
+
links to their core implementations.>
|
|
76
|
+
|
|
77
|
+
## Evidence
|
|
78
|
+
|
|
79
|
+
- <Screenshot, before/after output, request/response, trace, or other verified
|
|
80
|
+
artifact>
|
|
51
81
|
```
|
|
52
82
|
|
|
53
|
-
Keep the
|
|
83
|
+
Keep the body proportional to the change: a small change may need two useful
|
|
84
|
+
bullets, while a subtle bug or architectural change may need several paragraphs.
|
|
85
|
+
Omit conditional sections that do not apply. Make the title specific enough to
|
|
54
86
|
stand alone in release notes and conventional enough to become the squash
|
|
55
87
|
commit without editing.
|
|
56
88
|
|
|
57
|
-
## Show
|
|
89
|
+
## Show useful evidence
|
|
58
90
|
|
|
59
|
-
|
|
60
|
-
works.
|
|
91
|
+
Evidence is something the reviewer can inspect, not an assertion that the
|
|
92
|
+
change works.
|
|
61
93
|
|
|
62
94
|
- For a runnable UI or visual feature, capture and attach a screenshot or short
|
|
63
95
|
recording of the actual final state. Use a representative viewport, add a
|
|
64
96
|
short caption, and check the artifact for secrets or personal data.
|
|
65
97
|
- For CLI, API, or automation behavior, include concise terminal output, a
|
|
66
98
|
request/response example, generated artifact, or execution log when it proves
|
|
67
|
-
more than the
|
|
99
|
+
the behavior more clearly than the CI result alone.
|
|
68
100
|
- For a bug fix or behavior change, prefer before/after evidence when it is
|
|
69
101
|
practical and materially clarifies the result.
|
|
70
|
-
- For internal-only changes,
|
|
71
|
-
|
|
102
|
+
- For internal-only changes, include focused regression output, a trace, a
|
|
103
|
+
generated artifact, or another result that demonstrates the changed behavior
|
|
104
|
+
when available.
|
|
105
|
+
|
|
106
|
+
Routine validation commands that CI always runs, such as `vp check` or standard
|
|
107
|
+
format, lint, typecheck, and test commands, add no useful context to the PR body.
|
|
108
|
+
Let CI report them. Mention a command or CI result only when it is unusual,
|
|
109
|
+
cannot run in CI, or its output itself helps the reviewer understand the change.
|
|
72
110
|
|
|
73
111
|
Include only evidence that was actually produced and verified. When expected
|
|
74
112
|
visual proof cannot be produced, state the concrete reason briefly instead of
|
|
75
|
-
silently substituting a claim.
|
|
76
|
-
|
|
113
|
+
silently substituting a claim. Choose the smallest set of evidence that makes
|
|
114
|
+
the changed behavior easy to inspect. Omit the section when no evidence adds
|
|
115
|
+
information beyond routine CI.
|
|
77
116
|
|
|
78
117
|
## Open and verify
|
|
79
118
|
|
package/src/bin/dev-kit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { printError } from "../cli-ui.ts";
|
|
|
12
12
|
import { syncEffectSource } from "../effect-source.ts";
|
|
13
13
|
import { patchEffectTsgo } from "../effect-tsgo.ts";
|
|
14
14
|
import { patchProjectGitignore } from "../gitignore.ts";
|
|
15
|
+
import { CACHE_PRUNE_AGE_DAYS, runCachePrune } from "../global-cache.ts";
|
|
15
16
|
import {
|
|
16
17
|
addSkills,
|
|
17
18
|
chooseSkillsToAdd,
|
|
@@ -351,6 +352,25 @@ const catalogVerifyCommand = CliCommand.make(
|
|
|
351
352
|
refreshSkillCatalog({ locked: true, lockfilePath: lockfile, repoDir, sourcesPath: sources }),
|
|
352
353
|
).pipe(CliCommand.withDescription("Verify the committed catalog without advancing refs."));
|
|
353
354
|
|
|
355
|
+
const cachePruneCommand = CliCommand.make(
|
|
356
|
+
"prune",
|
|
357
|
+
{
|
|
358
|
+
all: Flag.boolean("all").pipe(
|
|
359
|
+
Flag.withDescription("Remove the entire cache instead of only stale content."),
|
|
360
|
+
),
|
|
361
|
+
maxAgeDays: Flag.integer("max-age-days").pipe(
|
|
362
|
+
Flag.withDefault(CACHE_PRUNE_AGE_DAYS),
|
|
363
|
+
Flag.withDescription("Evict content unused for this many days."),
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
({ all, maxAgeDays }) => runCachePrune({ all, maxAgeDays }),
|
|
367
|
+
).pipe(CliCommand.withDescription("Evict stale content from the machine-global source cache."));
|
|
368
|
+
|
|
369
|
+
const cacheCommand = CliCommand.make("cache").pipe(
|
|
370
|
+
CliCommand.withDescription("Manage the machine-global source cache."),
|
|
371
|
+
CliCommand.withSubcommands([cachePruneCommand] as const),
|
|
372
|
+
);
|
|
373
|
+
|
|
354
374
|
const catalogCommand = CliCommand.make("catalog").pipe(
|
|
355
375
|
CliCommand.withDescription("Maintain the approved upstream catalog."),
|
|
356
376
|
CliCommand.withSubcommands([
|
|
@@ -382,6 +402,7 @@ const command = CliCommand.make("dev-kit", projectFlags, ({ manifest, projectDir
|
|
|
382
402
|
effectCommand,
|
|
383
403
|
tsgoCommand,
|
|
384
404
|
catalogCommand,
|
|
405
|
+
cacheCommand,
|
|
385
406
|
],
|
|
386
407
|
},
|
|
387
408
|
] as const),
|
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, [
|