magus-cli 7.12.2 → 7.14.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 (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,468 @@
1
+ /**
2
+ * market-publish.ts — what happens to a local skill folder around a push:
3
+ * the sets read from it before, and the two tails after.
4
+ *
5
+ * - {@link readSourceSets}: the folder's inventory `S`, the files that will
6
+ * be published `P` (with blob ids and their hash `H`), and `N`, every file
7
+ * that will not, with the reason.
8
+ * - {@link moveInstalls}: bring this machine's installs to the version just
9
+ * pushed, through `claude plugin` commands only (`advanceViaCli`), after a
10
+ * fast-forward of Claude Code's clone.
11
+ * - {@link removeLocalCopy}: delete the folder only when it holds exactly the
12
+ * committed files, file by file; otherwise keep it and say why.
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import {
18
+ readGlobalSettings,
19
+ readLocalSettings,
20
+ readSettings,
21
+ } from "./claude-settings.js";
22
+ import { checkIgnore, hashObject } from "./git-write.js";
23
+ import { ORIGIN_FILE, forgetBase } from "./market-bases.js";
24
+ import { advanceViaCli } from "./market-install.js";
25
+ import {
26
+ type SkillFile,
27
+ excludedByShape,
28
+ joinRel,
29
+ treeHash,
30
+ } from "./market-layout.js";
31
+ import {
32
+ type InstallRow,
33
+ type InstallScope,
34
+ type LocalCopy,
35
+ MarketError,
36
+ type NotPublishedReason,
37
+ } from "./market-types.js";
38
+ import { fastForwardCloneIfPresent } from "./marketplace-refresh.js";
39
+ import {
40
+ buildScopeStatus,
41
+ isInstalledInScope,
42
+ readInstalledVersionForScope,
43
+ } from "./plugin-manager.js";
44
+ import { readActiveMarker } from "./profile-live.js";
45
+
46
+ // ─── Source sets ─────────────────────────────────────────────────────────────
47
+
48
+ export interface InventoryItem {
49
+ /** Path inside the folder, with `/`. */
50
+ path: string;
51
+ symlink: boolean;
52
+ }
53
+
54
+ /** Every file and symlink under `folder`, `.magus-origin` left out. */
55
+ export function inventory(folder: string): InventoryItem[] {
56
+ const out: InventoryItem[] = [];
57
+ const walk = (dir: string, rel: string) => {
58
+ for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
59
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
60
+ if (childRel === ORIGIN_FILE) continue;
61
+ if (d.isSymbolicLink()) out.push({ path: childRel, symlink: true });
62
+ else if (d.isDirectory()) walk(path.join(dir, d.name), childRel);
63
+ else if (d.isFile()) out.push({ path: childRel, symlink: false });
64
+ }
65
+ };
66
+ walk(folder, "");
67
+ return out.sort((a, b) => (a.path < b.path ? -1 : 1));
68
+ }
69
+
70
+ export interface SourceSets {
71
+ /** The files that will be published, with blob ids. */
72
+ published: SkillFile[];
73
+ /** `H`: the hash of {@link published}. */
74
+ hash: string;
75
+ notPublished: Array<{ path: string; reason: NotPublishedReason }>;
76
+ /** True when the folder is not tracked by the project's git at all. */
77
+ untrackedFolder: boolean;
78
+ }
79
+
80
+ /**
81
+ * Read a skill folder's copy set against the marketplace it is going to.
82
+ * `destRepo` is the local copy of the marketplace the plan read and
83
+ * `destDir` the skill's directory in it (repository-relative); destination
84
+ * ignore rules are read there, read-only.
85
+ */
86
+ export async function readSourceSets(
87
+ folder: string,
88
+ destRepo: string,
89
+ destDir: string,
90
+ /**
91
+ * A top-level folder whose own dot-name does not exclude it: a plugin's
92
+ * `.claude-plugin`. What lies inside it is judged by the usual rules.
93
+ */
94
+ keepDotDir?: string,
95
+ ): Promise<SourceSets> {
96
+ const items = inventory(folder);
97
+ const notPublished: SourceSets["notPublished"] = [];
98
+ const candidates: string[] = [];
99
+ for (const item of items) {
100
+ const judged =
101
+ keepDotDir && item.path.startsWith(`${keepDotDir}/`)
102
+ ? item.path.slice(keepDotDir.length + 1)
103
+ : item.path;
104
+ const shape = excludedByShape(judged, item.symlink);
105
+ if (shape) notPublished.push({ path: item.path, reason: shape });
106
+ else candidates.push(item.path);
107
+ }
108
+
109
+ // The project's own ignore rules apply unless the skill folder itself is
110
+ // ignored: magus ignores the skill folders a profile installs, and every
111
+ // file would then read as excluded. Outside a work tree there are none.
112
+ const parent = path.dirname(folder);
113
+ const base = path.basename(folder);
114
+ const folderIgnored = await checkIgnore(parent, [base, `${base}/`]);
115
+ const untrackedFolder = folderIgnored.ok && folderIgnored.value.size > 0;
116
+ let ignoredHere = new Set<string>();
117
+ if (!untrackedFolder && candidates.length > 0) {
118
+ const r = await checkIgnore(folder, candidates);
119
+ if (r.ok) ignoredHere = r.value;
120
+ }
121
+ const afterHere: string[] = [];
122
+ for (const p of candidates) {
123
+ if (ignoredHere.has(p))
124
+ notPublished.push({ path: p, reason: "ignored-here" });
125
+ else afterHere.push(p);
126
+ }
127
+
128
+ let ignoredThere = new Set<string>();
129
+ if (afterHere.length > 0) {
130
+ const r = await checkIgnore(
131
+ destRepo,
132
+ afterHere.map((p) => joinRel(destDir, p)),
133
+ );
134
+ if (r.ok) ignoredThere = r.value;
135
+ }
136
+ const kept: string[] = [];
137
+ for (const p of afterHere) {
138
+ if (ignoredThere.has(joinRel(destDir, p))) {
139
+ notPublished.push({ path: p, reason: "ignored-by-marketplace" });
140
+ } else {
141
+ kept.push(p);
142
+ }
143
+ }
144
+
145
+ const ids = await hashObject(folder, kept);
146
+ if (!ids.ok) {
147
+ throw new MarketError(
148
+ "other",
149
+ `Could not read the skill's files: ${ids.message}`,
150
+ );
151
+ }
152
+ const published = kept.map((p, i) => ({ path: p, oid: ids.value[i] ?? "" }));
153
+ return {
154
+ published,
155
+ hash: treeHash(published),
156
+ notPublished: notPublished.sort((a, b) => (a.path < b.path ? -1 : 1)),
157
+ untrackedFolder,
158
+ };
159
+ }
160
+
161
+ // ─── Installs ────────────────────────────────────────────────────────────────
162
+
163
+ export interface InstalledAt {
164
+ scope: InstallScope;
165
+ version: string;
166
+ }
167
+
168
+ /** The scope's own `enabledPlugins`; an unreadable file turns nothing on. */
169
+ async function enabledAt(
170
+ scope: InstallScope,
171
+ cwd: string,
172
+ ): Promise<Record<string, boolean>> {
173
+ try {
174
+ const settings =
175
+ scope === "user"
176
+ ? await readGlobalSettings()
177
+ : scope === "project"
178
+ ? await readSettings(cwd)
179
+ : await readLocalSettings(cwd);
180
+ return settings.enabledPlugins ?? {};
181
+ } catch {
182
+ return {};
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The version of `pluginId` installed at `scope` for this project, or null
188
+ * where it is not installed. Installed means what it means everywhere in
189
+ * magus ({@link isInstalledInScope}): the scope's settings turn the plugin
190
+ * on. A registry row alone is not an install: it can outlive the setting, or
191
+ * belong to another checkout. Read-only.
192
+ */
193
+ export async function installedVersionAt(
194
+ pluginId: string,
195
+ scope: InstallScope,
196
+ cwd: string,
197
+ ): Promise<string | null> {
198
+ const version = await readInstalledVersionForScope(pluginId, scope, cwd);
199
+ const status = buildScopeStatus(
200
+ await enabledAt(scope, cwd),
201
+ version ? { [pluginId]: version } : {},
202
+ pluginId,
203
+ );
204
+ return isInstalledInScope(status) ? (version ?? null) : null;
205
+ }
206
+
207
+ /** Where this project (or USER) has `pluginId` installed, read-only. */
208
+ export async function installedScopes(
209
+ pluginId: string,
210
+ cwd: string,
211
+ ): Promise<InstalledAt[]> {
212
+ const out: InstalledAt[] = [];
213
+ for (const scope of ["user", "project", "local"] as const) {
214
+ const version = await installedVersionAt(pluginId, scope, cwd);
215
+ if (version) out.push({ scope, version });
216
+ }
217
+ return out;
218
+ }
219
+
220
+ export interface MoveResult {
221
+ installs: InstallRow[];
222
+ /** True when every scope moved and this folder loads the target. */
223
+ ok: boolean;
224
+ loaded: string | null;
225
+ message?: string;
226
+ fix?: string;
227
+ }
228
+
229
+ /**
230
+ * Move each installed scope to `target`. Claude Code's clone is fast-forwarded
231
+ * first (`claude plugin update` does not fetch); a clone with local changes,
232
+ * or one that would not pull, stops the move with its own reason.
233
+ */
234
+ export async function moveInstalls(
235
+ marketplace: string,
236
+ pluginId: string,
237
+ scopes: readonly InstalledAt[],
238
+ target: string,
239
+ cwd: string,
240
+ ): Promise<MoveResult> {
241
+ if (scopes.length === 0) return { installs: [], ok: true, loaded: null };
242
+ const ff = await fastForwardCloneIfPresent(marketplace);
243
+ if (ff === "skipped" || ff === "failed") {
244
+ const message =
245
+ ff === "skipped"
246
+ ? `Claude Code's copy of ${marketplace} has local changes, so it could not take the new version`
247
+ : `Claude Code's copy of ${marketplace} could not be brought up to date`;
248
+ const fix =
249
+ ff === "skipped"
250
+ ? "discard those changes, then run `magus update`"
251
+ : "magus update";
252
+ return {
253
+ ok: false,
254
+ loaded: null,
255
+ message,
256
+ fix,
257
+ installs: scopes.map((s) => ({
258
+ scope: s.scope,
259
+ from: s.version,
260
+ to: s.version,
261
+ status: "failed",
262
+ message,
263
+ fix,
264
+ })),
265
+ };
266
+ }
267
+ const moved = await advanceViaCli(
268
+ pluginId,
269
+ scopes.map((s) => s.scope),
270
+ cwd,
271
+ target,
272
+ );
273
+ return {
274
+ installs: moved.installs.map((i) => ({
275
+ scope: i.scope,
276
+ from: i.from,
277
+ to: i.to,
278
+ loaded: i.loaded,
279
+ status: i.status,
280
+ ...(i.message ? { message: i.message } : {}),
281
+ ...(i.fix ? { fix: i.fix } : {}),
282
+ })),
283
+ ok: moved.outcome === "ok",
284
+ loaded: moved.loaded,
285
+ ...(moved.message ? { message: moved.message } : {}),
286
+ ...(moved.fix ? { fix: moved.fix } : {}),
287
+ };
288
+ }
289
+
290
+ // ─── The local copy ──────────────────────────────────────────────────────────
291
+
292
+ /** True when this project's active profile installs the skill folder `name`. */
293
+ export async function profileManaged(
294
+ cwd: string,
295
+ name: string,
296
+ ): Promise<boolean> {
297
+ const marker = await readActiveMarker(cwd);
298
+ return !!marker && Object.hasOwn(marker.skills ?? {}, name);
299
+ }
300
+
301
+ /**
302
+ * The scope whose setting turns `pluginId` off in this project, or null when
303
+ * it is on here. Claude Code reads LOCAL over PROJECT over USER, so the first
304
+ * of them that names the plugin decides; one naming it `false` wins over an
305
+ * install at a lower scope. Read-only.
306
+ */
307
+ export async function turnedOffHere(
308
+ pluginId: string,
309
+ cwd: string,
310
+ ): Promise<InstallScope | null> {
311
+ for (const scope of ["local", "project", "user"] as const) {
312
+ const flag = (await enabledAt(scope, cwd))[pluginId];
313
+ if (flag === undefined) continue;
314
+ return flag === true ? null : scope;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ /** Why the plugin will not replace a local copy here, or null when it will. */
320
+ export async function offHereReason(
321
+ plugin: string,
322
+ pluginId: string,
323
+ cwd: string,
324
+ ): Promise<string | null> {
325
+ const off = await turnedOffHere(pluginId, cwd);
326
+ return off
327
+ ? `${plugin} does not load in this project (not installed at ${off.toUpperCase()} scope, which comes first), so nothing would replace this copy`
328
+ : null;
329
+ }
330
+
331
+ /**
332
+ * The local-copy tail of a push: remove the copy when the plan predicted it
333
+ * and every install moved, and the plugin still loads here when read again
334
+ * now; otherwise keep it with the reason.
335
+ */
336
+ export async function settleLocalCopy(input: {
337
+ folder: string;
338
+ committed: readonly SkillFile[];
339
+ copy: { scope: "project" | "user"; name: string };
340
+ keepReason: string | null;
341
+ moved: boolean;
342
+ plugin: string;
343
+ pluginId: string;
344
+ cwd: string;
345
+ }): Promise<LocalCopy> {
346
+ const kept = (reason: string): LocalCopy => ({
347
+ ...input.copy,
348
+ action: "kept",
349
+ reason,
350
+ });
351
+ if (input.keepReason !== null) return kept(input.keepReason);
352
+ if (!input.moved) return kept("not every install moved");
353
+ const off = await offHereReason(input.plugin, input.pluginId, input.cwd);
354
+ if (off !== null) return kept(off);
355
+ return removeLocalCopy(input.folder, input.committed, input.copy);
356
+ }
357
+
358
+ /** Does an install at one of `scopes` reach where a copy at `from` loads? */
359
+ export function installReaches(
360
+ from: "project" | "user",
361
+ scopes: readonly InstallScope[],
362
+ ): boolean {
363
+ return from === "user" ? scopes.includes("user") : scopes.length > 0;
364
+ }
365
+
366
+ /**
367
+ * Why `folder` does not hold exactly `committed` (path and blob id), or null
368
+ * when it does. Read-only: the removal rule, and its prediction in a plan.
369
+ * `verb` finishes "holds files that …" ("were not published", "will not be
370
+ * published").
371
+ */
372
+ export async function copyMismatch(
373
+ folder: string,
374
+ committed: readonly SkillFile[],
375
+ verb: string,
376
+ ): Promise<string | null> {
377
+ const items = inventory(folder);
378
+ const expected = new Map(committed.map((f) => [f.path, f.oid]));
379
+ const extra = items.filter((i) => i.symlink || !expected.has(i.path));
380
+ if (extra.length > 0) {
381
+ const names = extra
382
+ .slice(0, 5)
383
+ .map((i) => i.path)
384
+ .join(", ");
385
+ return `holds files that ${verb}: ${names}${extra.length > 5 ? ", …" : ""}`;
386
+ }
387
+ const files = items.map((i) => i.path);
388
+ if (files.length !== expected.size) return "changed since it was published";
389
+ const ids = await hashObject(folder, files);
390
+ if (!ids.ok || files.some((f, i) => expected.get(f) !== ids.value[i])) {
391
+ return "changed since it was published";
392
+ }
393
+ return null;
394
+ }
395
+
396
+ /**
397
+ * Remove `folder` when its files, read now, are exactly `committed` (path and
398
+ * blob id). Files are deleted one by one and directories only when empty;
399
+ * `.magus-origin` and the base record go last. Anything else keeps the copy.
400
+ */
401
+ export async function removeLocalCopy(
402
+ folder: string,
403
+ committed: readonly SkillFile[],
404
+ copy: { scope: "project" | "user"; name: string },
405
+ ): Promise<LocalCopy> {
406
+ const kept = (reason: string): LocalCopy => ({
407
+ ...copy,
408
+ action: "kept",
409
+ reason,
410
+ });
411
+ const mismatch = await copyMismatch(folder, committed, "were not published");
412
+ if (mismatch) return kept(mismatch);
413
+ // Delete only the files just verified, never a fresh listing: a file that
414
+ // appeared after the check was not published and stays. Each one is
415
+ // checked again first, as a regular file still holding its committed blob.
416
+ const files = committed.map((f) => f.path);
417
+ const regular = files.every((f) => {
418
+ try {
419
+ return fs.lstatSync(path.join(folder, f)).isFile();
420
+ } catch {
421
+ return false;
422
+ }
423
+ });
424
+ const again = regular ? await hashObject(folder, files) : null;
425
+ if (!again?.ok || committed.some((f, i) => again.value[i] !== f.oid)) {
426
+ return kept("changed since it was published");
427
+ }
428
+ for (const f of files) fs.rmSync(path.join(folder, f), { force: true });
429
+ // From here the folder no longer holds the published tree. If something
430
+ // else is left in it, its base and token would describe files that are
431
+ // gone, so both are dropped and the next publish asks how to compare.
432
+ const partly = (): LocalCopy => {
433
+ const left = inventory(folder).map((i) => i.path);
434
+ forgetBase(folder);
435
+ fs.rmSync(path.join(folder, ORIGIN_FILE), { force: true });
436
+ const names = left.slice(0, 5).join(", ");
437
+ return {
438
+ ...kept(
439
+ `files appeared while it was removed, so only they are left${names ? `: ${names}${left.length > 5 ? ", …" : ""}` : ""}`,
440
+ ),
441
+ partial: true,
442
+ };
443
+ };
444
+ const dirs = new Set<string>();
445
+ for (const f of files) {
446
+ let d = path.posix.dirname(f);
447
+ while (d !== "." && d !== "") {
448
+ dirs.add(d);
449
+ d = path.posix.dirname(d);
450
+ }
451
+ }
452
+ for (const d of [...dirs].sort((a, b) => b.length - a.length)) {
453
+ try {
454
+ fs.rmdirSync(path.join(folder, d));
455
+ } catch {
456
+ return partly();
457
+ }
458
+ }
459
+ if (inventory(folder).length > 0) return partly();
460
+ fs.rmSync(path.join(folder, ORIGIN_FILE), { force: true });
461
+ forgetBase(folder);
462
+ try {
463
+ fs.rmdirSync(folder);
464
+ } catch {
465
+ return partly();
466
+ }
467
+ return { ...copy, action: "removed" };
468
+ }