@wipcomputer/wip-ldm-os 0.4.85-alpha.29 → 0.4.85-alpha.30
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/SKILL.md +7 -0
- package/bin/ldm.js +38 -0
- package/lib/registry-migrations.mjs +82 -0
- package/package.json +1 -1
- package/scripts/test-legacy-npm-sources-migration.mjs +161 -0
package/SKILL.md
CHANGED
|
@@ -77,6 +77,8 @@ First-time CLI bootstrap commands use the same selected track:
|
|
|
77
77
|
- beta/latest beta: `npm install -g @wipcomputer/wip-ldm-os@beta`
|
|
78
78
|
- alpha/latest alpha: `npm install -g @wipcomputer/wip-ldm-os@alpha`
|
|
79
79
|
|
|
80
|
+
The `ldm install --<track>` command self-updates the LDM CLI to the matching npm dist-tag before running the install. **Do not run `npm install -g @wipcomputer/wip-ldm-os@latest` ahead of an alpha or beta install:** `@latest` resolves to the stable dist-tag and will downgrade an alpha-pinned CLI to stable.
|
|
81
|
+
|
|
80
82
|
If the user already named a track, do not force a generic chooser. Show the exact package, available version, track, and command you will run. Then wait for dry-run or install consent as appropriate.
|
|
81
83
|
|
|
82
84
|
If the user has not named a track, show what is installed and what is available, then ask which track they want to dry run or install.
|
|
@@ -276,6 +278,11 @@ Tell the user, scaled to the track they're on:
|
|
|
276
278
|
- **beta**: stabilization candidate. Same shape as alpha but feature-frozen for the cut.
|
|
277
279
|
- **stable**: production. The user should be on this unless they've asked otherwise.
|
|
278
280
|
|
|
281
|
+
Roadmap caveats that apply to every track right now:
|
|
282
|
+
|
|
283
|
+
- Registry source-type migration is mid-flight. After Phase 2 ships, `ldm status` will categorize every extension by source type (`npm` / `git` / `bundled` / `private`). Until then, some entries appear under "Untracked extensions" with a `ldm doctor --reclassify-sources` remediation pointer.
|
|
284
|
+
- LDM OS is the canonical pattern source for child packages (Codex Remote Control, future tools). Install-prompt structure changes here propagate downstream; child packages should not lead the parent.
|
|
285
|
+
|
|
279
286
|
## Reference files
|
|
280
287
|
|
|
281
288
|
For detailed information, read these on demand (not on every activation):
|
package/bin/ldm.js
CHANGED
|
@@ -1807,6 +1807,7 @@ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
|
|
|
1807
1807
|
planLegacyNpmSourcesMigration,
|
|
1808
1808
|
summaryHasChanges,
|
|
1809
1809
|
npmPackageExists,
|
|
1810
|
+
executeDirectoryMoves,
|
|
1810
1811
|
} = await import('../lib/registry-migrations.mjs');
|
|
1811
1812
|
|
|
1812
1813
|
const registry = readJSON(REGISTRY_PATH);
|
|
@@ -1836,6 +1837,20 @@ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
|
|
|
1836
1837
|
copyFileSync(REGISTRY_PATH, backupPath);
|
|
1837
1838
|
summary.backupPath = backupPath;
|
|
1838
1839
|
writeJSON(REGISTRY_PATH, newRegistry);
|
|
1840
|
+
|
|
1841
|
+
// Execute directory moves AFTER the registry write. Each move sends a
|
|
1842
|
+
// deduplicated extension's on-disk directory to ~/.ldm/_trash/<name>-
|
|
1843
|
+
// deduplicated-<timestamp> so autoDetectExtensions (which only scans
|
|
1844
|
+
// ~/.ldm/extensions/) cannot find it on the next install pass.
|
|
1845
|
+
// Without this, the registry dedup reverts within the same install run.
|
|
1846
|
+
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
|
|
1847
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
1848
|
+
directoryMoves: summary.directoryMoves,
|
|
1849
|
+
extensionsRoot: LDM_EXTENSIONS,
|
|
1850
|
+
trashRoot: join(LDM_ROOT, '_trash'),
|
|
1851
|
+
});
|
|
1852
|
+
summary.directoryMovesPerformed.push(...performed);
|
|
1853
|
+
summary.directoryMovesSkipped.push(...skipped);
|
|
1839
1854
|
}
|
|
1840
1855
|
|
|
1841
1856
|
return summary;
|
|
@@ -1879,6 +1894,29 @@ function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
|
|
|
1879
1894
|
console.log(` ${d.removed} (canonical: ${d.keep})`);
|
|
1880
1895
|
}
|
|
1881
1896
|
}
|
|
1897
|
+
if (summary.directoryMoves && summary.directoryMoves.length > 0) {
|
|
1898
|
+
if (dryRun) {
|
|
1899
|
+
console.log(` + Would move ${summary.directoryMoves.length} duplicate director${summary.directoryMoves.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (so autoDetectExtensions cannot re-register):`);
|
|
1900
|
+
for (const m of summary.directoryMoves) {
|
|
1901
|
+
console.log(` ~/.ldm/extensions/${m.name} -> ~/.ldm/_trash/${m.trashName}`);
|
|
1902
|
+
}
|
|
1903
|
+
} else {
|
|
1904
|
+
const performed = summary.directoryMovesPerformed || [];
|
|
1905
|
+
const skipped = summary.directoryMovesSkipped || [];
|
|
1906
|
+
if (performed.length > 0) {
|
|
1907
|
+
console.log(` + Moved ${performed.length} duplicate director${performed.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (autoDetectExtensions cannot re-register):`);
|
|
1908
|
+
for (const m of performed) {
|
|
1909
|
+
console.log(` ${m.name} -> ${m.destPath.replace(HOME, '~')}`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
if (skipped.length > 0) {
|
|
1913
|
+
console.log(` ! ${skipped.length} duplicate director${skipped.length === 1 ? 'y' : 'ies'} could not be moved (registry entries are removed; autoDetect may re-add on next install):`);
|
|
1914
|
+
for (const m of skipped) {
|
|
1915
|
+
console.log(` ${m.name} (${m.reason})`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1882
1920
|
if (summary.probeFailures && summary.probeFailures.length > 0) {
|
|
1883
1921
|
console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
|
|
1884
1922
|
for (const f of summary.probeFailures) {
|
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
// Called by bin/ldm.js during `ldm install`. Idempotent: entries that already
|
|
5
5
|
// carry `updateSource` are skipped.
|
|
6
6
|
//
|
|
7
|
+
// `planLegacyNpmSourcesMigration` is pure (no filesystem I/O). The companion
|
|
8
|
+
// `executeDirectoryMoves` IS side-effecting; the planner emits a list of
|
|
9
|
+
// directory-move plans and the executor performs them. The split lets tests
|
|
10
|
+
// drive the planner with in-memory fixtures and exercise the executor against
|
|
11
|
+
// a real temp filesystem.
|
|
12
|
+
//
|
|
7
13
|
// See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
8
14
|
// and the parent design ai/product/bugs/installer/2026-05-13--cc-mini--installer-registry-source-types-architecture.md
|
|
9
15
|
|
|
16
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
10
19
|
const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
11
20
|
|
|
12
21
|
// Phase 1 expedient. The known duplicate pairs surfaced on Parker's machine
|
|
@@ -31,6 +40,16 @@ export function emptyLegacyNpmSourcesSummary() {
|
|
|
31
40
|
migrated: [],
|
|
32
41
|
phantomsRemoved: [],
|
|
33
42
|
duplicatesRemoved: [],
|
|
43
|
+
// Directory moves to perform AFTER the registry write. The planner
|
|
44
|
+
// emits these as a parallel list to duplicatesRemoved (1:1); the
|
|
45
|
+
// wrapper in bin/ldm.js executes them. See
|
|
46
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
|
|
47
|
+
// for the bug fix: without moving the on-disk directory out of
|
|
48
|
+
// ~/.ldm/extensions/, autoDetectExtensions re-registers the duplicate
|
|
49
|
+
// on the same install run and the dedup never persists.
|
|
50
|
+
directoryMoves: [],
|
|
51
|
+
directoryMovesPerformed: [],
|
|
52
|
+
directoryMovesSkipped: [],
|
|
34
53
|
probedCount: 0,
|
|
35
54
|
probeFailures: [],
|
|
36
55
|
timestamp: new Date().toISOString(),
|
|
@@ -96,9 +115,20 @@ export async function planLegacyNpmSourcesMigration({
|
|
|
96
115
|
|
|
97
116
|
// Step 2: dedupe known pairs. Pure structural fix; the canonical row stays
|
|
98
117
|
// untouched. Future drift is the hygiene-audit ticket's job, not this one.
|
|
118
|
+
//
|
|
119
|
+
// Each removed duplicate also emits a directoryMoves entry: the wrapper
|
|
120
|
+
// moves ~/.ldm/extensions/<remove> to ~/.ldm/_trash/<remove>-deduplicated-<ts>
|
|
121
|
+
// after the registry write so autoDetectExtensions cannot re-register
|
|
122
|
+
// the duplicate on the same install run.
|
|
123
|
+
const trashStamp = summary.timestamp.replace(/[:.]/g, '-');
|
|
99
124
|
for (const { keep, remove } of KNOWN_DUPLICATE_PAIRS) {
|
|
100
125
|
if (newRegistry.extensions[keep] && newRegistry.extensions[remove]) {
|
|
101
126
|
summary.duplicatesRemoved.push({ keep, removed: remove });
|
|
127
|
+
summary.directoryMoves.push({
|
|
128
|
+
name: remove,
|
|
129
|
+
reason: 'deduplicated',
|
|
130
|
+
trashName: `${remove}-deduplicated-${trashStamp}`,
|
|
131
|
+
});
|
|
102
132
|
delete newRegistry.extensions[remove];
|
|
103
133
|
}
|
|
104
134
|
}
|
|
@@ -159,6 +189,58 @@ export async function planLegacyNpmSourcesMigration({
|
|
|
159
189
|
return { newRegistry, summary };
|
|
160
190
|
}
|
|
161
191
|
|
|
192
|
+
// Execute the directory-move plans emitted by planLegacyNpmSourcesMigration.
|
|
193
|
+
// Side-effecting: moves directories from `extensionsRoot/<name>` to
|
|
194
|
+
// `trashRoot/<trashName>`. Creates `trashRoot` if needed. Skips moves whose
|
|
195
|
+
// source is missing or whose rename fails. Idempotent: a second call with the
|
|
196
|
+
// same plan returns all-skipped (source-missing) once the moves are done.
|
|
197
|
+
//
|
|
198
|
+
// Returns `{ performed: [...], skipped: [...] }` for the caller to append to
|
|
199
|
+
// the migration summary. Each performed entry gains a `destPath`; each skipped
|
|
200
|
+
// entry gains a `reason`.
|
|
201
|
+
//
|
|
202
|
+
// The contract is split from the planner so:
|
|
203
|
+
// - the planner stays pure and trivially testable with in-memory fixtures,
|
|
204
|
+
// - the executor can be exercised against a real temp filesystem in a test
|
|
205
|
+
// fixture without spawning a full `ldm install` run,
|
|
206
|
+
// - tests can inject `fs` primitives via the optional `fs` option for
|
|
207
|
+
// paranoid scenarios.
|
|
208
|
+
export function executeDirectoryMoves({
|
|
209
|
+
directoryMoves,
|
|
210
|
+
extensionsRoot,
|
|
211
|
+
trashRoot,
|
|
212
|
+
fs,
|
|
213
|
+
}) {
|
|
214
|
+
const _existsSync = fs?.existsSync || existsSync;
|
|
215
|
+
const _mkdirSync = fs?.mkdirSync || mkdirSync;
|
|
216
|
+
const _renameSync = fs?.renameSync || renameSync;
|
|
217
|
+
|
|
218
|
+
const performed = [];
|
|
219
|
+
const skipped = [];
|
|
220
|
+
if (!directoryMoves || directoryMoves.length === 0) {
|
|
221
|
+
return { performed, skipped };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_mkdirSync(trashRoot, { recursive: true });
|
|
225
|
+
|
|
226
|
+
for (const move of directoryMoves) {
|
|
227
|
+
const src = join(extensionsRoot, move.name);
|
|
228
|
+
const dest = join(trashRoot, move.trashName);
|
|
229
|
+
if (!_existsSync(src)) {
|
|
230
|
+
skipped.push({ ...move, reason: 'source-missing' });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
_renameSync(src, dest);
|
|
235
|
+
performed.push({ ...move, destPath: dest });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
skipped.push({ ...move, reason: `rename-failed: ${err.message}` });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { performed, skipped };
|
|
242
|
+
}
|
|
243
|
+
|
|
162
244
|
// Real npm-registry probe. Returns true/false/null per the planner contract.
|
|
163
245
|
// Mirrors the fetch pattern used by npmViewVersionForStatus in bin/ldm.js.
|
|
164
246
|
export async function npmPackageExists(pkgName, opts = {}) {
|
package/package.json
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
// See lib/registry-migrations.mjs and
|
|
4
4
|
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
|
|
5
5
|
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
6
10
|
import {
|
|
7
11
|
planLegacyNpmSourcesMigration,
|
|
8
12
|
summaryHasChanges,
|
|
9
13
|
emptyLegacyNpmSourcesSummary,
|
|
14
|
+
executeDirectoryMoves,
|
|
10
15
|
} from '../lib/registry-migrations.mjs';
|
|
11
16
|
|
|
12
17
|
function fail(msg) {
|
|
@@ -181,6 +186,29 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
|
181
186
|
if (!newRegistry.extensions['cc-session-export']) fail('canonical cc-session-export removed');
|
|
182
187
|
if (!newRegistry.extensions['wip-branch-guard']) fail('canonical wip-branch-guard removed');
|
|
183
188
|
|
|
189
|
+
// Each removed duplicate must also produce a directoryMoves entry. The
|
|
190
|
+
// wrapper in bin/ldm.js executes these moves after the registry write so
|
|
191
|
+
// autoDetectExtensions cannot re-register the duplicate on the same install
|
|
192
|
+
// run. Without this contract, the registry dedup reverts (see
|
|
193
|
+
// ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
194
|
+
assertEqual(summary.directoryMoves.length, 2, 'directoryMoves.length');
|
|
195
|
+
const moveNames = summary.directoryMoves.map(m => m.name).sort();
|
|
196
|
+
assertDeepEqual(moveNames, ['package', 'session-export'], 'directoryMoves names');
|
|
197
|
+
for (const m of summary.directoryMoves) {
|
|
198
|
+
assertEqual(m.reason, 'deduplicated', `directoryMoves[${m.name}].reason`);
|
|
199
|
+
if (!m.trashName.startsWith(`${m.name}-deduplicated-`)) {
|
|
200
|
+
fail(`directoryMoves[${m.name}].trashName should start with "${m.name}-deduplicated-" but was "${m.trashName}"`);
|
|
201
|
+
}
|
|
202
|
+
if (!m.trashName.includes('2026-05-13T18-00-00-000Z')) {
|
|
203
|
+
fail(`directoryMoves[${m.name}].trashName should embed the fixed-now timestamp but was "${m.trashName}"`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The planner is pure: it must NOT pre-populate directoryMovesPerformed or
|
|
208
|
+
// directoryMovesSkipped. Those are wrapper outputs from real filesystem I/O.
|
|
209
|
+
assertEqual(summary.directoryMovesPerformed.length, 0, 'directoryMovesPerformed should be empty from pure planner');
|
|
210
|
+
assertEqual(summary.directoryMovesSkipped.length, 0, 'directoryMovesSkipped should be empty from pure planner');
|
|
211
|
+
|
|
184
212
|
// Migrated entries: cc-session-export, compaction-indicator, lesa-bridge,
|
|
185
213
|
// run, plus the two custom-path entries (custom-path-untracked,
|
|
186
214
|
// legacy-custom-path). `session-export` and `package` were deduped before
|
|
@@ -275,6 +303,7 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
|
275
303
|
assertEqual(summary.migrated.length, 0, 'migrated.length on idempotent run');
|
|
276
304
|
assertEqual(summary.phantomsRemoved.length, 0, 'phantomsRemoved.length on idempotent run');
|
|
277
305
|
assertEqual(summary.duplicatesRemoved.length, 0, 'duplicatesRemoved.length on idempotent run');
|
|
306
|
+
assertEqual(summary.directoryMoves.length, 0, 'directoryMoves.length on idempotent run');
|
|
278
307
|
assertEqual(summaryHasChanges(summary), false, 'summaryHasChanges on empty summary');
|
|
279
308
|
assertDeepEqual(
|
|
280
309
|
Object.keys(newRegistry.extensions).sort(),
|
|
@@ -287,6 +316,9 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
|
287
316
|
{
|
|
288
317
|
const e = emptyLegacyNpmSourcesSummary();
|
|
289
318
|
assertDeepEqual(Object.keys(e).sort(), [
|
|
319
|
+
'directoryMoves',
|
|
320
|
+
'directoryMovesPerformed',
|
|
321
|
+
'directoryMovesSkipped',
|
|
290
322
|
'duplicatesRemoved',
|
|
291
323
|
'migrated',
|
|
292
324
|
'phantomsRemoved',
|
|
@@ -294,6 +326,135 @@ const FIXED_NOW = () => new Date('2026-05-13T18:00:00.000Z');
|
|
|
294
326
|
'probedCount',
|
|
295
327
|
'timestamp',
|
|
296
328
|
], 'empty summary keys');
|
|
329
|
+
// Wrapper-output fields start empty even though the planner doesn't
|
|
330
|
+
// populate them ... the wrapper is responsible for appending.
|
|
331
|
+
assertEqual(e.directoryMoves.length, 0, 'empty.directoryMoves');
|
|
332
|
+
assertEqual(e.directoryMovesPerformed.length, 0, 'empty.directoryMovesPerformed');
|
|
333
|
+
assertEqual(e.directoryMovesSkipped.length, 0, 'empty.directoryMovesSkipped');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Test 4: executeDirectoryMoves against a real temp filesystem ──────────
|
|
337
|
+
// Regression guard for the bug fixed by this PR
|
|
338
|
+
// (ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md).
|
|
339
|
+
// The planner emits directoryMoves entries; the executor must actually move
|
|
340
|
+
// the on-disk directories into trash so autoDetectExtensions cannot
|
|
341
|
+
// re-register them on the next install scan.
|
|
342
|
+
{
|
|
343
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-'));
|
|
344
|
+
try {
|
|
345
|
+
const extensionsRoot = join(tmpHome, 'extensions');
|
|
346
|
+
const trashRoot = join(tmpHome, '_trash');
|
|
347
|
+
mkdirSync(extensionsRoot, { recursive: true });
|
|
348
|
+
|
|
349
|
+
// Stand up the two duplicate directories the planner would dedup.
|
|
350
|
+
for (const name of ['session-export', 'package']) {
|
|
351
|
+
const dir = join(extensionsRoot, name);
|
|
352
|
+
mkdirSync(dir, { recursive: true });
|
|
353
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }) + '\n');
|
|
354
|
+
}
|
|
355
|
+
// And a non-duplicate that must be left alone (proxy for cc-session-export).
|
|
356
|
+
const ccsePath = join(extensionsRoot, 'cc-session-export');
|
|
357
|
+
mkdirSync(ccsePath, { recursive: true });
|
|
358
|
+
writeFileSync(join(ccsePath, 'package.json'), JSON.stringify({ name: 'cc-session-export', version: '1.0.0' }) + '\n');
|
|
359
|
+
|
|
360
|
+
// Run the planner with the fixture to get a real directoryMoves plan.
|
|
361
|
+
const registry = {
|
|
362
|
+
extensions: {
|
|
363
|
+
'cc-session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
364
|
+
'session-export': { source: { npm: 'session-export' }, installed: { version: '1.0.0' } },
|
|
365
|
+
'wip-branch-guard': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
366
|
+
'package': { source: { npm: '@wipcomputer/wip-branch-guard' }, installed: { version: '1.0.0' } },
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const { summary } = await planLegacyNpmSourcesMigration({
|
|
370
|
+
registry,
|
|
371
|
+
probeNpm: (name) => Promise.resolve(name === '@wipcomputer/wip-branch-guard'),
|
|
372
|
+
extensionExists: () => true,
|
|
373
|
+
now: FIXED_NOW,
|
|
374
|
+
});
|
|
375
|
+
assertEqual(summary.directoryMoves.length, 2, 'dedup plan produces 2 directoryMoves');
|
|
376
|
+
|
|
377
|
+
// Execute the moves against the temp filesystem.
|
|
378
|
+
const { performed, skipped } = executeDirectoryMoves({
|
|
379
|
+
directoryMoves: summary.directoryMoves,
|
|
380
|
+
extensionsRoot,
|
|
381
|
+
trashRoot,
|
|
382
|
+
});
|
|
383
|
+
assertEqual(performed.length, 2, 'executor performed 2 moves');
|
|
384
|
+
assertEqual(skipped.length, 0, 'executor did not skip any moves');
|
|
385
|
+
|
|
386
|
+
// Source directories are gone.
|
|
387
|
+
if (existsSync(join(extensionsRoot, 'session-export'))) {
|
|
388
|
+
fail('session-export directory should have been moved out of extensions/');
|
|
389
|
+
}
|
|
390
|
+
if (existsSync(join(extensionsRoot, 'package'))) {
|
|
391
|
+
fail('package directory should have been moved out of extensions/');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Trash directory now has the moved entries with the deduplicated suffix.
|
|
395
|
+
const trashContents = readdirSync(trashRoot);
|
|
396
|
+
if (!trashContents.some(name => name.startsWith('session-export-deduplicated-'))) {
|
|
397
|
+
fail(`trash should contain session-export-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
398
|
+
}
|
|
399
|
+
if (!trashContents.some(name => name.startsWith('package-deduplicated-'))) {
|
|
400
|
+
fail(`trash should contain package-deduplicated-* but had: ${trashContents.join(', ')}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Non-duplicate must NOT have been touched.
|
|
404
|
+
if (!existsSync(ccsePath)) fail('cc-session-export directory should be untouched');
|
|
405
|
+
|
|
406
|
+
// autoDetectExtensions simulation: a fresh scan of extensionsRoot must
|
|
407
|
+
// NOT see the moved duplicates. We replicate the production logic
|
|
408
|
+
// (bin/ldm.js autoDetectExtensions): scan top-level dirs in
|
|
409
|
+
// extensionsRoot, skip dirs named `_trash` or starting with `.` or
|
|
410
|
+
// `ldm-install-`, and treat any remaining dir with a package.json as a
|
|
411
|
+
// candidate for auto-registration.
|
|
412
|
+
const candidatesAfterMove = readdirSync(extensionsRoot, { withFileTypes: true })
|
|
413
|
+
.filter(d => d.isDirectory())
|
|
414
|
+
.map(d => d.name)
|
|
415
|
+
.filter(name => name !== '_trash' && !name.startsWith('.') && !name.startsWith('ldm-install-'))
|
|
416
|
+
.filter(name => existsSync(join(extensionsRoot, name, 'package.json')))
|
|
417
|
+
.sort();
|
|
418
|
+
assertDeepEqual(
|
|
419
|
+
candidatesAfterMove,
|
|
420
|
+
['cc-session-export'],
|
|
421
|
+
'autoDetect should see only the non-duplicate after the moves; duplicates must be gone',
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Idempotency: running executeDirectoryMoves again must skip all
|
|
425
|
+
// (source-missing) and not fail.
|
|
426
|
+
const second = executeDirectoryMoves({
|
|
427
|
+
directoryMoves: summary.directoryMoves,
|
|
428
|
+
extensionsRoot,
|
|
429
|
+
trashRoot,
|
|
430
|
+
});
|
|
431
|
+
assertEqual(second.performed.length, 0, 'second execute call performs nothing');
|
|
432
|
+
assertEqual(second.skipped.length, 2, 'second execute call skips both moves');
|
|
433
|
+
for (const s of second.skipped) {
|
|
434
|
+
assertEqual(s.reason, 'source-missing', `second-call skip reason for ${s.name}`);
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Test 5: executeDirectoryMoves with no moves is a no-op ───────────────
|
|
442
|
+
{
|
|
443
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'ldm-dedup-trash-empty-'));
|
|
444
|
+
try {
|
|
445
|
+
const result = executeDirectoryMoves({
|
|
446
|
+
directoryMoves: [],
|
|
447
|
+
extensionsRoot: join(tmpHome, 'extensions'),
|
|
448
|
+
trashRoot: join(tmpHome, '_trash'),
|
|
449
|
+
});
|
|
450
|
+
assertEqual(result.performed.length, 0, 'empty plan -> no performed');
|
|
451
|
+
assertEqual(result.skipped.length, 0, 'empty plan -> no skipped');
|
|
452
|
+
if (existsSync(join(tmpHome, '_trash'))) {
|
|
453
|
+
fail('executor should not pre-create trashRoot when there are no moves');
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
457
|
+
}
|
|
297
458
|
}
|
|
298
459
|
|
|
299
460
|
console.log('test-legacy-npm-sources-migration: all tests passed');
|