@danieljvdm/dev-kit 0.18.0 → 1.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.
Files changed (43) hide show
  1. package/README.md +148 -659
  2. package/package.json +8 -62
  3. package/skills/dev-kit/SKILL.md +42 -212
  4. package/skills/dev-kit/agents/openai.yaml +2 -2
  5. package/skills/dev-kit/references/cloudflare-worker-api.md +37 -0
  6. package/skills/dev-kit/references/default-typescript-repository.md +43 -0
  7. package/skills/dev-kit/references/legacy-eject.md +46 -0
  8. package/skills/dev-kit/references/repository-setup.md +53 -0
  9. package/skills/dev-kit/references/skills.md +35 -0
  10. package/src/bin/dev-kit.ts +142 -127
  11. package/src/eject.ts +715 -0
  12. package/src/legacy-project.ts +67 -0
  13. package/src/oxfmt.ts +1 -4
  14. package/src/oxlint.ts +5 -10
  15. package/src/path-digest.ts +60 -0
  16. package/src/project-skills.ts +722 -0
  17. package/src/tool-metadata.ts +0 -2
  18. package/src/vendor.ts +0 -5
  19. package/src/vite-plus.ts +1 -3
  20. package/dev-kit.example.jsonc +0 -22
  21. package/schema/dev-kit.schema.json +0 -218
  22. package/schema/skill-sources.schema.json +0 -83
  23. package/scripts/sync-anti-slop-runtime.mjs +0 -19
  24. package/src/index.ts +0 -125
  25. package/src/manifest.ts +0 -224
  26. package/src/oxfmt.js +0 -23
  27. package/src/oxlint-plugin-anti-slop/runtime.d.ts +0 -22
  28. package/src/oxlint-plugin-effect.d.ts +0 -19
  29. package/src/oxlint-plugin-style.d.ts +0 -8
  30. package/src/oxlint.js +0 -113
  31. package/src/project-state.ts +0 -122
  32. package/src/scaffold.ts +0 -79
  33. package/src/skill-manager.ts +0 -527
  34. package/src/sync.ts +0 -1935
  35. package/src/tool-ignore-patterns.js +0 -9
  36. package/src/vite-plus-dependency.ts +0 -69
  37. package/src/vite-plus-hooks.ts +0 -175
  38. package/src/vite-plus-workflow.ts +0 -82
  39. package/src/vite-plus.js +0 -88
  40. package/src/worktrunk-config.ts +0 -88
  41. package/templates/AGENTS.md +0 -11
  42. package/templates/vite-plus/github-actions-check.yml +0 -51
  43. package/templates/worktrunk/wt.toml +0 -27
package/src/eject.ts ADDED
@@ -0,0 +1,715 @@
1
+ import { Effect, FileSystem, Path, Schema, Stream } from "effect";
2
+ import { ChildProcess } from "effect/unstable/process";
3
+ import {
4
+ applyEdits,
5
+ modify,
6
+ parse as parseJsonc,
7
+ type FormattingOptions,
8
+ type ParseError,
9
+ } from "jsonc-parser";
10
+
11
+ import { printDetail, printStatus } from "./cli-ui.ts";
12
+ import {
13
+ LegacyDevKitLockSchema,
14
+ LegacyDevKitManifestSchema,
15
+ legacySetupFlags,
16
+ type LegacyManagedSkillOutput,
17
+ } from "./legacy-project.ts";
18
+ import { observeSymbolicLink } from "./node-symbolic-link.ts";
19
+ import { observePath } from "./path-digest.ts";
20
+ import { readProjectPackage } from "./project-package.ts";
21
+ import {
22
+ DEFAULT_SKILLS_TARGET,
23
+ renderSkillOrigin,
24
+ SKILL_ORIGIN_FILE,
25
+ type SkillOrigin,
26
+ } from "./project-skills.ts";
27
+
28
+ export type EjectOptions = {
29
+ readonly projectDir?: string;
30
+ readonly manifestPath?: string;
31
+ readonly lockfilePath?: string;
32
+ readonly statePath?: string;
33
+ readonly target?: string;
34
+ readonly dryRun?: boolean;
35
+ };
36
+
37
+ type EjectAction =
38
+ | {
39
+ readonly type: "write";
40
+ readonly path: string;
41
+ readonly destination: string;
42
+ readonly content: string;
43
+ readonly label: string;
44
+ }
45
+ | {
46
+ readonly type: "remove";
47
+ readonly path: string;
48
+ readonly destination: string;
49
+ readonly label: string;
50
+ }
51
+ | {
52
+ readonly type: "materialize-skill";
53
+ readonly path: string;
54
+ readonly destination: string;
55
+ readonly source: string;
56
+ readonly origin: SkillOrigin;
57
+ readonly replaceSymlink: boolean;
58
+ readonly label: string;
59
+ };
60
+
61
+ type EjectPlan = {
62
+ readonly projectDir: string;
63
+ readonly actions: ReadonlyArray<EjectAction>;
64
+ readonly conflicts: ReadonlyArray<string>;
65
+ };
66
+
67
+ type ManagedInstructionRelease =
68
+ | { readonly type: "unchanged" }
69
+ | { readonly type: "write"; readonly content: string }
70
+ | { readonly type: "conflict"; readonly message: string };
71
+
72
+ type PackagePatch =
73
+ | { readonly conflicts: ReadonlyArray<string> }
74
+ | {
75
+ readonly conflicts: ReadonlyArray<string>;
76
+ readonly content: string;
77
+ readonly path: "package.json";
78
+ readonly destination: string;
79
+ };
80
+
81
+ export class EjectError extends Schema.TaggedError<EjectError>()("EjectError", {
82
+ message: Schema.String,
83
+ }) {}
84
+
85
+ const decodeManifest = Schema.decodeUnknownEffect(LegacyDevKitManifestSchema);
86
+ const decodeLock = Schema.decodeUnknownEffect(Schema.fromJsonString(LegacyDevKitLockSchema));
87
+
88
+ const FORMATTING_OPTIONS: FormattingOptions = { insertSpaces: true, tabSize: 2 };
89
+ const DEV_KIT_PACKAGE = "@danieljvdm/dev-kit";
90
+ const MANAGED_MARKER_START = "<!-- DEV KIT START -->";
91
+ const MANAGED_MARKER_END = "<!-- DEV KIT END -->";
92
+
93
+ const resolveInsideProject = (
94
+ path: Path.Path,
95
+ projectDir: string,
96
+ candidate: string,
97
+ label: string,
98
+ ) => {
99
+ if (candidate.length === 0 || path.isAbsolute(candidate)) {
100
+ throw new Error(`${label} must be a non-empty project-relative path`);
101
+ }
102
+ const absolute = path.resolve(projectDir, candidate);
103
+ const relative = path.relative(projectDir, absolute);
104
+
105
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
106
+ throw new Error(`${label} must resolve inside the project`);
107
+ }
108
+
109
+ return { absolute, relative };
110
+ };
111
+
112
+ const readRequiredFile = Effect.fn("readRequiredEjectFile")(function* (
113
+ absolute: string,
114
+ label: string,
115
+ ) {
116
+ const fs = yield* FileSystem.FileSystem;
117
+ const observed = yield* observeSymbolicLink(absolute);
118
+
119
+ if (observed.kind === "missing") {
120
+ return yield* EjectError.make({ message: `${label} not found: ${absolute}` });
121
+ }
122
+ if (observed.kind === "symlink" || (yield* fs.stat(absolute)).type !== "File") {
123
+ return yield* EjectError.make({ message: `${label} is not a regular file: ${absolute}` });
124
+ }
125
+
126
+ return yield* fs.readFileString(absolute);
127
+ });
128
+
129
+ const readLegacyManifest = Effect.fn("readLegacyEjectManifest")(function* (absolute: string) {
130
+ const raw = yield* readRequiredFile(absolute, "legacy manifest");
131
+ const errors: Array<ParseError> = [];
132
+ const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
133
+
134
+ if (errors.length > 0) {
135
+ return yield* EjectError.make({ message: "legacy manifest contains invalid JSONC" });
136
+ }
137
+
138
+ return yield* decodeManifest(parsed).pipe(
139
+ Effect.mapError((error) =>
140
+ EjectError.make({ message: `invalid legacy manifest: ${error.message}` }),
141
+ ),
142
+ );
143
+ });
144
+
145
+ const findSymlinkAncestor = Effect.fn("findEjectSymlinkAncestor")(function* (
146
+ projectDir: string,
147
+ relative: string,
148
+ includeLeaf: boolean,
149
+ ) {
150
+ const path = yield* Path.Path;
151
+ const segments = relative.split(path.sep);
152
+ const inspected = includeLeaf ? segments : segments.slice(0, -1);
153
+ let ancestor = projectDir;
154
+
155
+ for (const segment of inspected) {
156
+ ancestor = path.join(ancestor, segment);
157
+ if ((yield* observeSymbolicLink(ancestor)).kind === "symlink") {
158
+ return path.relative(projectDir, ancestor);
159
+ }
160
+ }
161
+
162
+ return undefined;
163
+ });
164
+
165
+ const removeObsoleteDevKitIntroduction = (content: string): string => {
166
+ const lines = content.split(/\r?\n/);
167
+ const retained: Array<string> = [];
168
+ let skipParagraph = false;
169
+
170
+ for (const line of lines) {
171
+ if (line === "# Dev Kit") continue;
172
+ if (
173
+ line.startsWith("This project uses `@danieljvdm/dev-kit`") ||
174
+ line.startsWith("For dev-kit operations, use the `dev-kit` skill")
175
+ ) {
176
+ skipParagraph = true;
177
+ continue;
178
+ }
179
+ if (skipParagraph) {
180
+ if (line.trim().length === 0) skipParagraph = false;
181
+ continue;
182
+ }
183
+ retained.push(line);
184
+ }
185
+
186
+ return retained.join("\n").replace(/^\s+|\s+$/g, "");
187
+ };
188
+
189
+ const unwrapManagedAgentInstructions = (content: string): ManagedInstructionRelease => {
190
+ const starts = content.split(MANAGED_MARKER_START).length - 1;
191
+ const ends = content.split(MANAGED_MARKER_END).length - 1;
192
+
193
+ if (starts === 0 && ends === 0) return { type: "unchanged" };
194
+ if (starts !== 1 || ends !== 1) {
195
+ return { type: "conflict", message: "AGENTS.md has duplicate or unmatched Dev Kit markers" };
196
+ }
197
+ const start = content.indexOf(MANAGED_MARKER_START);
198
+ const end = content.indexOf(MANAGED_MARKER_END);
199
+
200
+ if (end < start) {
201
+ return { type: "conflict", message: "AGENTS.md has reversed Dev Kit markers" };
202
+ }
203
+ const before = content.slice(0, start).trimEnd();
204
+ const managed = content.slice(start + MANAGED_MARKER_START.length, end);
205
+ const after = content.slice(end + MANAGED_MARKER_END.length).trimStart();
206
+ const retainedManaged = removeObsoleteDevKitIntroduction(managed);
207
+ const sections = [before, retainedManaged, after].filter((section) => section.length > 0);
208
+
209
+ return { type: "write", content: `${sections.join("\n\n").trimEnd()}\n` };
210
+ };
211
+
212
+ const patchPackageJson = Effect.fn("patchEjectedPackageJson")(function* (projectDir: string) {
213
+ const fs = yield* FileSystem.FileSystem;
214
+ const path = yield* Path.Path;
215
+ const packagePath = path.join(projectDir, "package.json");
216
+
217
+ if (!(yield* fs.exists(packagePath))) {
218
+ return { conflicts: [] } satisfies PackagePatch;
219
+ }
220
+ const manifest = yield* readProjectPackage(projectDir);
221
+ const raw = yield* fs.readFileString(packagePath);
222
+ let content = raw;
223
+ const conflicts: Array<string> = [];
224
+
225
+ for (const section of [
226
+ "dependencies",
227
+ "devDependencies",
228
+ "optionalDependencies",
229
+ "peerDependencies",
230
+ ] as const) {
231
+ if (manifest[section]?.[DEV_KIT_PACKAGE] === undefined) continue;
232
+ content = applyEdits(
233
+ content,
234
+ modify(content, [section, DEV_KIT_PACKAGE], undefined, {
235
+ formattingOptions: FORMATTING_OPTIONS,
236
+ }),
237
+ );
238
+ }
239
+ const pureLegacyApply =
240
+ /^(?:dev-kit|\.\/bin\/dev-kit\.mjs|bun \.\/node_modules\/@danieljvdm\/dev-kit\/bin\/dev-kit\.mjs) (?:apply|sync)(?: --locked)?$/;
241
+ const legacyCommandReference =
242
+ /(?:^|[ /])(?:@danieljvdm\/dev-kit(?:\/\S*)?|dev-kit(?:\.mjs)?)(?:\s|$)/;
243
+
244
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
245
+ if (!legacyCommandReference.test(command)) continue;
246
+ if (pureLegacyApply.test(command)) {
247
+ content = applyEdits(
248
+ content,
249
+ modify(content, ["scripts", name], undefined, { formattingOptions: FORMATTING_OPTIONS }),
250
+ );
251
+ } else {
252
+ conflicts.push(`package.json script ${name} still depends on Dev Kit: ${command}`);
253
+ }
254
+ }
255
+
256
+ if (content === raw) return { conflicts } satisfies PackagePatch;
257
+
258
+ return {
259
+ conflicts,
260
+ content,
261
+ path: "package.json",
262
+ destination: packagePath,
263
+ } satisfies PackagePatch;
264
+ });
265
+
266
+ const patchWorkflows = Effect.fn("patchEjectedWorkflows")(function* (projectDir: string) {
267
+ const fs = yield* FileSystem.FileSystem;
268
+ const path = yield* Path.Path;
269
+ const workflowsDir = path.join(projectDir, ".github", "workflows");
270
+ const actions: Array<EjectAction> = [];
271
+ const conflicts: Array<string> = [];
272
+
273
+ if (!(yield* fs.exists(workflowsDir))) return { actions, conflicts };
274
+ for (const name of yield* fs.readDirectory(workflowsDir)) {
275
+ if (!/\.ya?ml$/.test(name)) continue;
276
+ const destination = path.join(workflowsDir, name);
277
+ const observed = yield* observeSymbolicLink(destination);
278
+
279
+ if (observed.kind !== "not-symlink" || (yield* fs.stat(destination)).type !== "File") continue;
280
+ const raw = yield* fs.readFileString(destination);
281
+ const content = raw.replace(
282
+ /^([ \t]*)- name: Verify locked Dev Kit setup\r?\n\1 run: [^\r\n]*dev-kit(?:\.mjs)? apply --locked\r?\n?/gm,
283
+ "",
284
+ );
285
+
286
+ if (/dev-kit(?:\.mjs)? (?:apply|sync)/.test(content)) {
287
+ conflicts.push(`${path.relative(projectDir, destination)} still invokes legacy Dev Kit`);
288
+ continue;
289
+ }
290
+ if (content !== raw) {
291
+ actions.push({
292
+ type: "write",
293
+ path: path.relative(projectDir, destination),
294
+ destination,
295
+ content,
296
+ label: "Remove legacy CI verification",
297
+ });
298
+ }
299
+ }
300
+
301
+ return { actions, conflicts };
302
+ });
303
+
304
+ const findRuntimeImports = Effect.fn("findEjectRuntimeImports")(function* (projectDir: string) {
305
+ const path = yield* Path.Path;
306
+ const child = yield* ChildProcess.make(
307
+ "git",
308
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
309
+ {
310
+ cwd: projectDir,
311
+ stderr: "pipe",
312
+ stdout: "pipe",
313
+ },
314
+ );
315
+ const [output, exitCode] = yield* Effect.all([
316
+ Stream.mkString(Stream.decodeText(child.all)),
317
+ child.exitCode,
318
+ ]);
319
+
320
+ if (exitCode !== 0) {
321
+ return yield* EjectError.make({ message: `git ls-files failed: ${output.trim()}` });
322
+ }
323
+ const fs = yield* FileSystem.FileSystem;
324
+ const imports: Array<string> = [];
325
+ const runtimeImport =
326
+ /(?:from\s*|import\s*\(\s*|require\s*\(\s*|specifier\s*:\s*)["']@danieljvdm\/dev-kit(?:\/[^"']*)?["']/;
327
+
328
+ for (const relative of output.split("\0").filter(Boolean)) {
329
+ if (
330
+ relative.startsWith(".agents/") ||
331
+ relative.startsWith(".claude/") ||
332
+ relative.startsWith(".opencode/") ||
333
+ !/\.(?:[cm]?[jt]sx?|jsonc?|mjs|cjs)$/.test(relative)
334
+ ) {
335
+ continue;
336
+ }
337
+ const absolute = path.join(projectDir, relative);
338
+ const observed = yield* observeSymbolicLink(absolute);
339
+
340
+ if (observed.kind !== "not-symlink" || (yield* fs.stat(absolute)).type !== "File") continue;
341
+ if (runtimeImport.test(yield* fs.readFileString(absolute))) imports.push(relative);
342
+ }
343
+
344
+ return imports;
345
+ });
346
+
347
+ const selectorFromLegacyOutput = (output: LegacyManagedSkillOutput): string => {
348
+ const suffix = `@${output.target}`;
349
+ const encoded = output.resourceId.startsWith("skill:")
350
+ ? output.resourceId.slice("skill:".length)
351
+ : output.skill;
352
+
353
+ return encoded.endsWith(suffix) ? encoded.slice(0, -suffix.length) : output.skill;
354
+ };
355
+
356
+ const originFromLegacyOutput = (
357
+ output: LegacyManagedSkillOutput,
358
+ toolVersion: string,
359
+ baseDigest: SkillOrigin["baseDigest"],
360
+ ): SkillOrigin => {
361
+ const common = {
362
+ version: 1 as const,
363
+ selector: selectorFromLegacyOutput(output),
364
+ name: output.skill,
365
+ baseDigest,
366
+ };
367
+
368
+ if (output.catalog === undefined) {
369
+ return { ...common, source: { type: "bundled", version: toolVersion } };
370
+ }
371
+ if ("source" in output.catalog) {
372
+ return { ...common, source: { type: "git", ...output.catalog } };
373
+ }
374
+
375
+ return { ...common, source: { type: "package", ...output.catalog } };
376
+ };
377
+
378
+ const planLegacySkills = Effect.fn("planEjectedLegacySkills")(function* (
379
+ projectDir: string,
380
+ target: string,
381
+ toolVersion: string,
382
+ outputs: ReadonlyArray<LegacyManagedSkillOutput>,
383
+ ) {
384
+ const fs = yield* FileSystem.FileSystem;
385
+ const path = yield* Path.Path;
386
+ const actions: Array<EjectAction> = [];
387
+ const conflicts: Array<string> = [];
388
+ const bySkill = Map.groupBy(outputs, (output) => output.skill);
389
+ const outputPaths = new Map<LegacyManagedSkillOutput, string>();
390
+
391
+ for (const output of outputs) {
392
+ const resolved = yield* Effect.try({
393
+ try: () => resolveInsideProject(path, projectDir, output.path, "legacy skill path"),
394
+ catch: (error) =>
395
+ EjectError.make({ message: error instanceof Error ? error.message : String(error) }),
396
+ });
397
+
398
+ outputPaths.set(output, resolved.absolute);
399
+ }
400
+
401
+ for (const [skillName, skillOutputs] of bySkill) {
402
+ const destination = path.join(target, skillName);
403
+ const relativeDestination = path.relative(projectDir, destination);
404
+ const destinationObserved = yield* observePath(destination);
405
+ const matchingOutput = skillOutputs.find((output) => outputPaths.get(output) === destination);
406
+ const candidate =
407
+ matchingOutput ?? skillOutputs.find((output) => output.mode === "copy") ?? skillOutputs[0];
408
+
409
+ if (candidate === undefined) continue;
410
+ const candidatePath = outputPaths.get(candidate);
411
+
412
+ if (candidatePath === undefined) {
413
+ return yield* EjectError.make({
414
+ message: `legacy skill path unavailable: ${candidate.path}`,
415
+ });
416
+ }
417
+ const candidateObserved = yield* observePath(candidatePath);
418
+ let source: string;
419
+
420
+ if (candidateObserved.kind === "directory") source = candidatePath;
421
+ else if (candidateObserved.kind === "symlink") {
422
+ source = yield* fs
423
+ .realPath(candidatePath)
424
+ .pipe(
425
+ Effect.mapError(() =>
426
+ EjectError.make({ message: `legacy skill link is broken: ${candidate.path}` }),
427
+ ),
428
+ );
429
+ if (candidate.catalog && "package" in candidate.catalog) {
430
+ conflicts.push(
431
+ `${candidate.path} is a package-backed symlink; install a copied target or merge it manually before ejecting`,
432
+ );
433
+ continue;
434
+ }
435
+ } else {
436
+ conflicts.push(`legacy skill output is unavailable: ${candidate.path}`);
437
+ continue;
438
+ }
439
+ const sourceObserved = yield* observePath(source);
440
+
441
+ if (sourceObserved.kind !== "directory") {
442
+ conflicts.push(`legacy skill source is not a directory: ${candidate.path}`);
443
+ continue;
444
+ }
445
+ const baseDigest = candidate.mode === "copy" ? candidate.digest : sourceObserved.digest;
446
+ const origin = originFromLegacyOutput(candidate, toolVersion, baseDigest);
447
+
448
+ if (destinationObserved.kind === "directory") {
449
+ if (matchingOutput === undefined) {
450
+ conflicts.push(`repo-owned destination already exists: ${relativeDestination}`);
451
+ continue;
452
+ }
453
+ const originPath = path.join(destination, SKILL_ORIGIN_FILE);
454
+ const originObserved = yield* observePath(originPath);
455
+
456
+ if (originObserved.kind === "missing") {
457
+ actions.push({
458
+ type: "write",
459
+ path: path.relative(projectDir, originPath),
460
+ destination: originPath,
461
+ content: renderSkillOrigin(origin),
462
+ label: `Release ${skillName} with an origin receipt`,
463
+ });
464
+ }
465
+ continue;
466
+ }
467
+ if (destinationObserved.kind !== "missing" && destinationObserved.kind !== "symlink") {
468
+ conflicts.push(`skill destination is not a directory: ${relativeDestination}`);
469
+ continue;
470
+ }
471
+ if (destinationObserved.kind === "symlink" && matchingOutput === undefined) {
472
+ conflicts.push(`repo-owned skill symlink already exists: ${relativeDestination}`);
473
+ continue;
474
+ }
475
+ actions.push({
476
+ type: "materialize-skill",
477
+ path: relativeDestination,
478
+ destination,
479
+ source,
480
+ origin,
481
+ replaceSymlink: destinationObserved.kind === "symlink",
482
+ label: `Materialize ${skillName}`,
483
+ });
484
+ }
485
+
486
+ return { actions, conflicts };
487
+ });
488
+
489
+ export const planEject = Effect.fn("planEject")(function* (options: EjectOptions = {}) {
490
+ const fs = yield* FileSystem.FileSystem;
491
+ const path = yield* Path.Path;
492
+ const projectDir = yield* fs
493
+ .realPath(path.resolve(options.projectDir ?? "."))
494
+ .pipe(Effect.mapError(() => EjectError.make({ message: "project directory not found" })));
495
+ const resolved = yield* Effect.try({
496
+ try: () => ({
497
+ manifest: resolveInsideProject(
498
+ path,
499
+ projectDir,
500
+ options.manifestPath ?? "dev-kit.jsonc",
501
+ "--manifest",
502
+ ),
503
+ lock: resolveInsideProject(
504
+ path,
505
+ projectDir,
506
+ options.lockfilePath ?? "dev-kit.lock.json",
507
+ "--lockfile",
508
+ ),
509
+ state: resolveInsideProject(
510
+ path,
511
+ projectDir,
512
+ options.statePath ?? ".dev-kit/state.json",
513
+ "--state",
514
+ ),
515
+ target: resolveInsideProject(
516
+ path,
517
+ projectDir,
518
+ options.target ?? DEFAULT_SKILLS_TARGET,
519
+ "--target",
520
+ ),
521
+ }),
522
+ catch: (error) =>
523
+ EjectError.make({ message: error instanceof Error ? error.message : String(error) }),
524
+ });
525
+
526
+ for (const file of [resolved.manifest, resolved.lock, resolved.state]) {
527
+ const symlink = yield* findSymlinkAncestor(projectDir, file.relative, false);
528
+
529
+ if (symlink !== undefined) {
530
+ return yield* EjectError.make({
531
+ message: `legacy metadata passes through a symlink: ${symlink}`,
532
+ });
533
+ }
534
+ }
535
+ const targetSymlink = yield* findSymlinkAncestor(projectDir, resolved.target.relative, true);
536
+
537
+ if (targetSymlink !== undefined) {
538
+ return yield* EjectError.make({
539
+ message: `skills target passes through a symlink: ${targetSymlink}`,
540
+ });
541
+ }
542
+ const setup = legacySetupFlags(yield* readLegacyManifest(resolved.manifest.absolute));
543
+ const lock = yield* readRequiredFile(resolved.lock.absolute, "legacy lock").pipe(
544
+ Effect.flatMap(decodeLock),
545
+ Effect.mapError((error) =>
546
+ EjectError.make({ message: `invalid legacy lock: ${error.message}` }),
547
+ ),
548
+ );
549
+ const actions: Array<EjectAction> = [];
550
+ const conflicts: Array<string> = [];
551
+
552
+ if (setup.effectSource) {
553
+ conflicts.push(
554
+ "setup.effectSource is enabled; materialize its ongoing behavior before ejecting",
555
+ );
556
+ }
557
+ if (setup.effectTsgo) {
558
+ conflicts.push(
559
+ "setup.effectTsgo is enabled; materialize its install-time patch before ejecting",
560
+ );
561
+ }
562
+ if (setup.vitePlusHooks) {
563
+ conflicts.push("setup.vitePlus.hooks is enabled; materialize Git hook setup before ejecting");
564
+ }
565
+ for (const importPath of yield* findRuntimeImports(projectDir)) {
566
+ conflicts.push(`${importPath} imports Dev Kit runtime configuration`);
567
+ }
568
+ const packagePatch = yield* patchPackageJson(projectDir);
569
+
570
+ conflicts.push(...packagePatch.conflicts);
571
+ if (
572
+ packagePatch.content !== undefined &&
573
+ packagePatch.path !== undefined &&
574
+ packagePatch.destination !== undefined
575
+ ) {
576
+ actions.push({
577
+ type: "write",
578
+ path: packagePatch.path,
579
+ destination: packagePatch.destination,
580
+ content: packagePatch.content,
581
+ label: "Remove the Dev Kit dependency and pure apply scripts",
582
+ });
583
+ }
584
+ const workflows = yield* patchWorkflows(projectDir);
585
+
586
+ actions.push(...workflows.actions);
587
+ conflicts.push(...workflows.conflicts);
588
+ const agentsPath = path.join(projectDir, "AGENTS.md");
589
+
590
+ if (yield* fs.exists(agentsPath)) {
591
+ const rawAgents = yield* fs.readFileString(agentsPath);
592
+ const unwrapped = unwrapManagedAgentInstructions(rawAgents);
593
+
594
+ if (unwrapped.type === "conflict") conflicts.push(unwrapped.message);
595
+ else if (unwrapped.type === "write" && unwrapped.content !== rawAgents) {
596
+ actions.push({
597
+ type: "write",
598
+ path: "AGENTS.md",
599
+ destination: agentsPath,
600
+ content: unwrapped.content,
601
+ label: "Release agent instructions",
602
+ });
603
+ }
604
+ }
605
+ const legacySkills = yield* planLegacySkills(
606
+ projectDir,
607
+ resolved.target.absolute,
608
+ lock.toolVersion,
609
+ lock.outputs.filter((output): output is LegacyManagedSkillOutput => "skill" in output),
610
+ );
611
+
612
+ actions.push(...legacySkills.actions);
613
+ conflicts.push(...legacySkills.conflicts);
614
+ actions.push({
615
+ type: "remove",
616
+ path: resolved.manifest.relative,
617
+ destination: resolved.manifest.absolute,
618
+ label: "Remove legacy manifest",
619
+ });
620
+ actions.push({
621
+ type: "remove",
622
+ path: resolved.lock.relative,
623
+ destination: resolved.lock.absolute,
624
+ label: "Remove legacy lock",
625
+ });
626
+ const stateObserved = yield* observePath(resolved.state.absolute);
627
+
628
+ if (stateObserved.kind === "file") {
629
+ actions.push({
630
+ type: "remove",
631
+ path: resolved.state.relative,
632
+ destination: resolved.state.absolute,
633
+ label: "Remove local ownership state",
634
+ });
635
+ } else if (stateObserved.kind !== "missing") {
636
+ conflicts.push(`${resolved.state.relative} is not a regular file`);
637
+ }
638
+
639
+ return { projectDir, actions, conflicts } satisfies EjectPlan;
640
+ });
641
+
642
+ const writeAtomically = Effect.fn("writeEjectedFileAtomically")(function* (
643
+ destination: string,
644
+ content: string,
645
+ ) {
646
+ const fs = yield* FileSystem.FileSystem;
647
+ const path = yield* Path.Path;
648
+ const staged = yield* fs.makeTempFileScoped({
649
+ directory: path.dirname(destination),
650
+ prefix: ".dev-kit-eject-",
651
+ });
652
+
653
+ yield* fs.writeFileString(staged, content);
654
+ yield* fs.rename(staged, destination);
655
+ });
656
+
657
+ const applyEjectPlan = Effect.fn("applyEjectPlan")(function* (plan: EjectPlan) {
658
+ if (plan.conflicts.length > 0) {
659
+ return yield* EjectError.make({
660
+ message: `eject has ${plan.conflicts.length} conflict${plan.conflicts.length === 1 ? "" : "s"}:\n${plan.conflicts.map((conflict) => ` ${conflict}`).join("\n")}`,
661
+ });
662
+ }
663
+ const fs = yield* FileSystem.FileSystem;
664
+ const path = yield* Path.Path;
665
+
666
+ for (const action of plan.actions) {
667
+ if (action.type === "remove") continue;
668
+ if (action.type === "write") {
669
+ yield* fs.makeDirectory(path.dirname(action.destination), { recursive: true });
670
+ yield* writeAtomically(action.destination, action.content);
671
+ } else {
672
+ const temp = yield* fs.makeTempDirectoryScoped({
673
+ directory: plan.projectDir,
674
+ prefix: ".dev-kit-eject-skill-",
675
+ });
676
+ const staged = path.join(temp, path.basename(action.destination));
677
+
678
+ yield* fs.copy(action.source, staged, { overwrite: true });
679
+ yield* fs.writeFileString(
680
+ path.join(staged, SKILL_ORIGIN_FILE),
681
+ renderSkillOrigin(action.origin),
682
+ );
683
+ yield* fs.makeDirectory(path.dirname(action.destination), { recursive: true });
684
+ if (action.replaceSymlink) yield* fs.remove(action.destination);
685
+ yield* fs.rename(staged, action.destination);
686
+ }
687
+ yield* printStatus("success", action.label, action.path);
688
+ }
689
+ for (const action of plan.actions) {
690
+ if (action.type !== "remove") continue;
691
+ yield* fs.remove(action.destination);
692
+ yield* printStatus("success", action.label, action.path);
693
+ }
694
+ yield* printDetail(
695
+ "Regenerate the package-manager lockfile, then run the repository's full validation.",
696
+ );
697
+ });
698
+
699
+ export const runEject = Effect.fn("runEject")(function* (options: EjectOptions = {}) {
700
+ const plan = yield* planEject(options);
701
+
702
+ for (const action of plan.actions) {
703
+ yield* printStatus("plan", action.label, action.path);
704
+ }
705
+ for (const conflict of plan.conflicts) {
706
+ yield* printStatus("error", conflict);
707
+ }
708
+ if (options.dryRun) {
709
+ if (plan.conflicts.length === 0) yield* printDetail("Ready to eject.");
710
+
711
+ return;
712
+ }
713
+
714
+ yield* applyEjectPlan(plan);
715
+ });