devflow-kit 3.10.0 → 3.11.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.
@@ -91,9 +91,10 @@ import { isProxyEnabled } from '../../core/proxy-state.js';
91
91
  import { WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
92
92
  import { DEVFLOW_PLUGINS, CURATION_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, selectedWorkflowFolders, skillsOf, workflowFolderNames, } from '../../core/plugins.js';
93
93
  import { convergeFromManifest } from './compliance-install.js';
94
+ import { swapFolderIn } from './folder-swap.js';
94
95
  import { copyDirectory, resolveSkillSource } from './installer.js';
95
96
  import { LANGUAGE_STAMPED_COMMANDS, stampForConverge } from './language-stamp.js';
96
- import { entryIsLink, newLinkReport, refuseUnrealFolder, refusedFolderMessage, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
97
+ import { directoryState, newLinkReport, refuseUnrealFolder, refusedFolderMessage, unlinkLinkAt, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
97
98
  import { convergeReferences } from './reference-converge.js';
98
99
  import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
99
100
  // ── Bounds ─────────────────────────────────────────────────────────────────
@@ -380,6 +381,16 @@ async function pathExists(p) {
380
381
  return false;
381
382
  }
382
383
  }
384
+ /**
385
+ * The two folders a skill swap uses, both directly under `claudeDir`: the staging copy, and the
386
+ * backup the old skill folder waits in while the copy is renamed into place.
387
+ *
388
+ * @param now - The clock reading that makes the name unique, in milliseconds.
389
+ */
390
+ export function curationSwapPaths(claudeDir, now) {
391
+ const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${now.toString(36)}.tmp`);
392
+ return { staging, backup: `${staging}.old` };
393
+ }
383
394
  /**
384
395
  * Install one skill directory atomically: stage a copy beside the claude dir's
385
396
  * skills, then swap it in, restoring the old directory if the swap fails.
@@ -387,33 +398,56 @@ async function pathExists(p) {
387
398
  * Staged under `claudeDir` itself, not under `skills/`: a staging directory a
388
399
  * crash leaves behind must not look like a second skill to Claude Code.
389
400
  *
401
+ * A file in the old directory that devflow did not ship moves to the backup folder first. If one
402
+ * cannot move, nothing is swapped: the shipped files are written into the old directory and the
403
+ * file stays (see {@link swapFolderIn}).
404
+ *
390
405
  * A target that is a link is moved aside as the link itself, never followed, so what it points to is
391
406
  * left as it was; the link is recorded in `links` once the real folder stands in its place.
392
407
  */
393
- async function swapInSkillDirectory(claudeDir, sourceDir, target, links) {
394
- const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${Date.now().toString(36)}.tmp`);
395
- const backup = `${staging}.old`;
408
+ async function swapInSkillDirectory(claudeDir, sourceDir, target, links, backups) {
409
+ const { staging, backup } = curationSwapPaths(claudeDir, Date.now());
396
410
  try {
397
411
  await copyDirectory(sourceDir, staging);
398
412
  await fs.mkdir(path.dirname(target), { recursive: true });
399
- const wasLink = await entryIsLink(target);
400
- const hadTarget = wasLink || await pathExists(target);
401
- if (hadTarget)
402
- await fs.rename(target, backup);
403
- try {
404
- await fs.rename(staging, target);
405
- }
406
- catch (err) {
407
- if (hadTarget)
408
- await fs.rename(backup, target);
409
- throw err;
410
- }
411
- if (wasLink)
412
- links.replacedLinks.push(target);
413
+ await swapFolderIn({
414
+ backups,
415
+ target,
416
+ staged: staging,
417
+ aside: backup,
418
+ links,
419
+ mergeInto: () => copyDirectory(staging, target, undefined, links),
420
+ });
413
421
  }
414
422
  finally {
415
423
  await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
416
- await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
424
+ }
425
+ }
426
+ /**
427
+ * Remove a curation-gated skill folder. The files devflow shipped go by the session's list, anything
428
+ * else moves to the backup folder first, and the folder goes with a plain `rmdir`: 'kept' means it
429
+ * still stands because a file could not move. A link is unlinked and a file in its place moves aside.
430
+ */
431
+ async function removeSkillFolder(target, backups) {
432
+ const state = await directoryState(target);
433
+ switch (state) {
434
+ case 'absent':
435
+ return 'absent';
436
+ case 'link':
437
+ return (await unlinkLinkAt(target)) === 'unlinked' ? 'removed' : 'kept';
438
+ case 'unusable': {
439
+ const moved = await backups.moveWhole(target);
440
+ return moved.status === 'moved' ? 'removed' : moved.status === 'absent' ? 'absent' : 'kept';
441
+ }
442
+ case 'real': {
443
+ await backups.prepass([target]);
444
+ return (await backups.removeListed(target)).folderRemoved ? 'removed' : 'kept';
445
+ }
446
+ default: {
447
+ const unreachable = state;
448
+ void unreachable;
449
+ return 'kept';
450
+ }
417
451
  }
418
452
  }
419
453
  /** Converge the curation-gated skill directories. A skill folder that was a link and was swapped is recorded in `links`. Never throws. */
@@ -421,15 +455,24 @@ async function convergeSkills(opts, links) {
421
455
  const { claudeDir, devflowDir, curation, plugins, warn } = opts;
422
456
  const selected = skillsOf(plugins);
423
457
  let state = 'unchanged';
458
+ // A skills folder that is a link or not a folder is refused once. The removal and the swap below
459
+ // would reach the folder the link points to: `directoryState` of a skill folder follows the link
460
+ // above it. The refusal is recorded in `links`, and the converge is not clean.
461
+ if (await refuseUnrealFolder(links, path.join(claudeDir, 'skills')))
462
+ return 'failed';
424
463
  // Every gated skill is converged: a failure on one must not skip the next.
425
464
  for (const skill of CURATION_GATED_SKILLS) {
426
465
  const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
427
466
  try {
428
467
  if (!curation) {
429
- if (!(await pathExists(target)))
430
- continue;
431
- await fs.rm(target, { recursive: true });
432
- state = 'removed';
468
+ const removal = await removeSkillFolder(target, opts.backups);
469
+ if (removal === 'removed') {
470
+ state = 'removed';
471
+ }
472
+ else if (removal === 'kept') {
473
+ warn(`curation variants: the ${prefixSkillName(skill)} skill folder (${target}) still holds a file that could not be moved aside, so it was not removed`);
474
+ state = 'failed';
475
+ }
433
476
  continue;
434
477
  }
435
478
  if (!selected.has(skill)) {
@@ -440,7 +483,7 @@ async function convergeSkills(opts, links) {
440
483
  const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
441
484
  if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
442
485
  continue;
443
- await swapInSkillDirectory(claudeDir, resolved.dir, target, links);
486
+ await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
444
487
  state = 'installed';
445
488
  }
446
489
  catch (err) {
@@ -590,7 +633,7 @@ async function attempt(label, warn, fallback, run) {
590
633
  * install owns the removal. Never throws.
591
634
  */
592
635
  async function convergeWorkflowFolders(opts) {
593
- const { claudeDir, switches, plugins, folders, root, warn } = opts;
636
+ const { claudeDir, switches, plugins, folders, root, warn, backups } = opts;
594
637
  const installed = [];
595
638
  const unchanged = [];
596
639
  const failures = [];
@@ -611,7 +654,7 @@ async function convergeWorkflowFolders(opts) {
611
654
  continue;
612
655
  }
613
656
  const links = newLinkReport();
614
- const result = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links });
657
+ const result = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links, backups });
615
658
  switch (result.outcome) {
616
659
  case 'installed':
617
660
  installed.push(folder.name);
@@ -668,7 +711,7 @@ export function workflowFoldersPosting(folders, compliance) {
668
711
  * Never throws.
669
712
  */
670
713
  export async function convergeVariantAxes(opts) {
671
- const { claudeDir, devflowDir, manifest, plugins, warn, packageRoot } = opts;
714
+ const { claudeDir, devflowDir, manifest, plugins, warn, packageRoot, backups } = opts;
672
715
  const switches = { curation: manifest.features.curation, compliance: manifest.features.compliance.enabled };
673
716
  // Precondition, asserted in production code: a relative claudeDir would resolve
674
717
  // every install target somewhere unexpected, the skill removal included.
@@ -692,9 +735,9 @@ export async function convergeVariantAxes(opts) {
692
735
  }
693
736
  const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
694
737
  const skillLinks = newLinkReport();
695
- const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot }, skillLinks));
738
+ const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot, backups }, skillLinks));
696
739
  warnLinkReport(skillLinks, warn);
697
- const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn }));
740
+ const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn, backups }));
698
741
  const references = await attempt('reference converge', warn, {
699
742
  converged: false,
700
743
  rewritten: [],
@@ -703,7 +746,7 @@ export async function convergeVariantAxes(opts) {
703
746
  failures: ['the reference converge failed'],
704
747
  stillPosting: [],
705
748
  unproven: !switches.compliance,
706
- }, () => convergeReferences({ claudeDir, devflowDir, compliance: switches.compliance, warn, packageRoot }));
749
+ }, () => convergeReferences({ claudeDir, devflowDir, compliance: switches.compliance, warn, packageRoot, backups }));
707
750
  const workflowFolders = await attempt('workflow folders', warn, { converged: false, installed: [], unchanged: [], failures: ['the workflow folder converge failed'], stalled: [] }, () => convergeWorkflowFolders({
708
751
  claudeDir,
709
752
  switches,
@@ -711,6 +754,7 @@ export async function convergeVariantAxes(opts) {
711
754
  folders: opts.workflowFolders ?? WORKFLOW_FOLDERS,
712
755
  root: opts.workflowFoldersRoot ?? packageRoot ?? getPackageRoot(),
713
756
  warn,
757
+ backups,
714
758
  }));
715
759
  return {
716
760
  converged: prompts.converged && skill !== 'failed' && compliance.converged && references.converged && workflowFolders.converged,
@@ -975,6 +1019,7 @@ export async function applyVariantToggle(opts) {
975
1019
  plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
976
1020
  warn,
977
1021
  packageRoot: opts.packageRoot,
1022
+ backups: opts.backups,
978
1023
  });
979
1024
  if (result.converged) {
980
1025
  await recordInstalledVariants(devflowDir, { curation: state.features.curation, compliance: state.features.compliance.enabled }, warn);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The swap of a folder that devflow owns and that may hold files of the user's: a skill folder the
3
+ * converge replaces. The new folder is built beside the old one and renamed in; the old folder waits
4
+ * in an aside folder while that happens.
5
+ *
6
+ * Nothing here removes a folder with a recursive `rm`. Before the swap, the backup session moves
7
+ * every file devflow did not ship out of the old folder. If any file could not move, the folder is
8
+ * not swapped: the files devflow ships are written into it one by one, and it stays in place. The
9
+ * write comes before any removal, so a copy that fails leaves the old shipped files in the folder;
10
+ * then only the listed entries that the new folder lacks are removed. The same holds when the aside
11
+ * name is taken by a folder that keeps a file. A file at the aside name
12
+ * moves to the backup folder first; if it cannot move, a rename never replaces it, so a folder
13
+ * stays in place and a link makes the swap throw. After
14
+ * a swap, the aside folder is scanned again, because a file can arrive between the first scan and the
15
+ * rename. What the session moves out of it is named, what devflow shipped is removed by the list the
16
+ * scan made, and the folder goes with a plain `rmdir`; a folder that still holds a file stays.
17
+ *
18
+ * This module imports no installer module, so the install and the converge steps can all use it.
19
+ * A swap that cannot finish throws, and the caller reports it.
20
+ */
21
+ import { promises as fs } from 'fs';
22
+ import * as path from 'path';
23
+ import { DEFAULT_ENTRY_CAP, MAX_WALK_DEPTH } from './install-backup.js';
24
+ import { directoryState, unlinkLinkAt } from './linked-folder.js';
25
+ const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
26
+ /**
27
+ * Remove a folder that was displaced from `stoodAt`: scan it as if it still stood there, move what
28
+ * devflow did not ship, remove what it shipped by the session's list, then `rmdir`. A displaced link
29
+ * is unlinked. A folder that still holds a file stays, and the session's record names the file.
30
+ */
31
+ export async function discardDisplacedFolder(backups, aside, stoodAt) {
32
+ const state = await directoryState(aside);
33
+ if (state === 'link') {
34
+ await unlinkLinkAt(aside);
35
+ return;
36
+ }
37
+ if (state !== 'real')
38
+ return;
39
+ await backups.prepass([{ folder: aside, displacedFrom: stoodAt }]);
40
+ await backups.removeListed(aside);
41
+ }
42
+ /**
43
+ * The files and links below `staged` as paths relative to it, with `/` separators. Null when the
44
+ * walk reached its depth or entry bound, because a list that stops early would name too few.
45
+ */
46
+ async function stagedPaths(staged) {
47
+ const found = new Set();
48
+ async function walk(folder, rel, depth) {
49
+ if (depth > MAX_WALK_DEPTH)
50
+ return false;
51
+ const entries = await fs.readdir(folder, { withFileTypes: true });
52
+ for (const entry of entries) {
53
+ const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
54
+ if (entry.isDirectory()) {
55
+ if (!(await walk(path.join(folder, entry.name), childRel, depth + 1)))
56
+ return false;
57
+ }
58
+ else {
59
+ found.add(childRel);
60
+ if (found.size > DEFAULT_ENTRY_CAP)
61
+ return false;
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+ return (await walk(staged, '', 0)) ? found : null;
67
+ }
68
+ /**
69
+ * Write the files of `staged` into `target`, then remove what the old folder held and the new one
70
+ * lacks. The write comes first: if the copy fails, the files devflow shipped are still in the old
71
+ * folder. The removal scans `target` again with a filter that leaves every staged path, every kept
72
+ * file and everything below a kept folder alone, so `removeListed` takes only the listed leftovers
73
+ * (retired and sidecar names, `.DS_Store`) and the shipped files that the new release dropped.
74
+ * When the staged list is not complete, nothing is removed.
75
+ */
76
+ async function mergeThenClear(backups, target, staged, mergeInto) {
77
+ const written = await stagedPaths(staged);
78
+ await mergeInto();
79
+ if (written === null)
80
+ return;
81
+ await backups.prepass([{
82
+ folder: target,
83
+ include: (relative, kind) => !(kind !== 'directory' && written.has(relative)) && !backups.isKept(path.join(target, ...relative.split('/'))),
84
+ }]);
85
+ await backups.removeListed(target);
86
+ }
87
+ /**
88
+ * Put `staged` at `target`. See the module comment for the order of the steps.
89
+ *
90
+ * If the rename of `staged` fails, the old folder is renamed back. If that fails too, the old folder
91
+ * stays at `aside` and the error names the path. Throws on a failure that leaves the swap undone.
92
+ */
93
+ export async function swapFolderIn(options) {
94
+ const { backups, target, staged, aside, links, mergeInto } = options;
95
+ // What an earlier, interrupted swap left at the aside name is cleared first, so the rename below has a free name.
96
+ // A file there is not a displaced folder: it moves to the backup folder, because a rename replaces it without a trace.
97
+ await discardDisplacedFolder(backups, aside, target);
98
+ if ((await directoryState(aside)) === 'unusable')
99
+ await backups.moveWhole(aside);
100
+ const asideTaken = (await directoryState(aside)) !== 'absent';
101
+ let state = await directoryState(target);
102
+ if (state === 'unusable') {
103
+ const moved = await backups.moveWhole(target);
104
+ if (moved.status === 'kept') {
105
+ throw new Error(`${target} is not a folder and could not be moved aside (${moved.reason})`);
106
+ }
107
+ state = 'absent';
108
+ }
109
+ if (state === 'real') {
110
+ await backups.prepass([target]);
111
+ if (asideTaken || backups.keptUnder(target).length > 0) {
112
+ await mergeThenClear(backups, target, staged, mergeInto);
113
+ return 'merged';
114
+ }
115
+ }
116
+ if (state === 'link' && asideTaken) {
117
+ throw new Error(`${target} is a link and was not replaced: ${aside} is in the way and could not be moved`);
118
+ }
119
+ const displaced = state !== 'absent';
120
+ if (displaced)
121
+ await fs.rename(target, aside);
122
+ try {
123
+ await fs.rename(staged, target);
124
+ }
125
+ catch (err) {
126
+ if (displaced) {
127
+ try {
128
+ await fs.rename(aside, target);
129
+ }
130
+ catch (restoreErr) {
131
+ throw new Error(`${errorMessage(err)}; the previous folder could not be put back (${errorMessage(restoreErr)}) and is kept at ${aside}`);
132
+ }
133
+ }
134
+ throw err;
135
+ }
136
+ if (state === 'link')
137
+ links.replacedLinks.push(target);
138
+ if (displaced)
139
+ await discardDisplacedFolder(backups, aside, target);
140
+ return 'swapped';
141
+ }
142
+ //# sourceMappingURL=folder-swap.js.map