@skillsmith/core 0.12.2 → 0.12.4
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/CHANGELOG.md +173 -0
- package/README.md +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/src/config/owned-lock.acquire.d.ts +8 -0
- package/dist/src/config/owned-lock.acquire.d.ts.map +1 -1
- package/dist/src/config/owned-lock.acquire.js +19 -4
- package/dist/src/config/owned-lock.acquire.js.map +1 -1
- package/dist/src/config/owned-lock.claim.d.ts +4 -2
- package/dist/src/config/owned-lock.claim.d.ts.map +1 -1
- package/dist/src/config/owned-lock.claim.js +42 -8
- package/dist/src/config/owned-lock.claim.js.map +1 -1
- package/dist/src/config/owned-lock.d.ts.map +1 -1
- package/dist/src/config/owned-lock.js +1 -0
- package/dist/src/config/owned-lock.js.map +1 -1
- package/dist/src/config/owned-lock.test.js +126 -2
- package/dist/src/config/owned-lock.test.js.map +1 -1
- package/dist/src/config/owned-lock.types.d.ts +9 -0
- package/dist/src/config/owned-lock.types.d.ts.map +1 -1
- package/dist/src/db/createDatabase.d.ts.map +1 -1
- package/dist/src/db/createDatabase.js +0 -2
- package/dist/src/db/createDatabase.js.map +1 -1
- package/dist/src/exports/services.install.d.ts +1 -0
- package/dist/src/exports/services.install.d.ts.map +1 -1
- package/dist/src/exports/services.install.js +5 -0
- package/dist/src/exports/services.install.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/install/fan-out.cleanup.d.ts +37 -0
- package/dist/src/install/fan-out.cleanup.d.ts.map +1 -0
- package/dist/src/install/fan-out.cleanup.js +75 -0
- package/dist/src/install/fan-out.cleanup.js.map +1 -0
- package/dist/src/install/fan-out.d.ts +41 -31
- package/dist/src/install/fan-out.d.ts.map +1 -1
- package/dist/src/install/fan-out.js +213 -112
- package/dist/src/install/fan-out.js.map +1 -1
- package/dist/src/install/fan-out.leftovers.d.ts +60 -0
- package/dist/src/install/fan-out.leftovers.d.ts.map +1 -0
- package/dist/src/install/fan-out.leftovers.js +251 -0
- package/dist/src/install/fan-out.leftovers.js.map +1 -0
- package/dist/src/install/fan-out.locks.d.ts +12 -0
- package/dist/src/install/fan-out.locks.d.ts.map +1 -0
- package/dist/src/install/fan-out.locks.js +114 -0
- package/dist/src/install/fan-out.locks.js.map +1 -0
- package/dist/src/install/fan-out.manifest.d.ts +64 -0
- package/dist/src/install/fan-out.manifest.d.ts.map +1 -0
- package/dist/src/install/fan-out.manifest.js +130 -0
- package/dist/src/install/fan-out.manifest.js.map +1 -0
- package/dist/src/install/fan-out.overwrite.d.ts +51 -0
- package/dist/src/install/fan-out.overwrite.d.ts.map +1 -0
- package/dist/src/install/fan-out.overwrite.js +343 -0
- package/dist/src/install/fan-out.overwrite.js.map +1 -0
- package/dist/src/install/remove-if-same.d.ts +58 -0
- package/dist/src/install/remove-if-same.d.ts.map +1 -0
- package/dist/src/install/remove-if-same.js +222 -0
- package/dist/src/install/remove-if-same.js.map +1 -0
- package/dist/src/provenance/backfill.d.ts.map +1 -1
- package/dist/src/provenance/backfill.js +15 -2
- package/dist/src/provenance/backfill.js.map +1 -1
- package/dist/src/scripts/__tests__/scan-imported-skills.test.js +9 -1
- package/dist/src/scripts/__tests__/scan-imported-skills.test.js.map +1 -1
- package/dist/src/scripts/scan-imported-skills.d.ts +7 -1
- package/dist/src/scripts/scan-imported-skills.d.ts.map +1 -1
- package/dist/src/scripts/scan-imported-skills.js +15 -2
- package/dist/src/scripts/scan-imported-skills.js.map +1 -1
- package/dist/src/scripts/skill-scanner/allowlist.d.ts +10 -0
- package/dist/src/scripts/skill-scanner/allowlist.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/allowlist.js +10 -0
- package/dist/src/scripts/skill-scanner/allowlist.js.map +1 -1
- package/dist/src/scripts/skill-scanner/index.d.ts +8 -0
- package/dist/src/scripts/skill-scanner/index.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/index.js +13 -5
- package/dist/src/scripts/skill-scanner/index.js.map +1 -1
- package/dist/src/scripts/skill-scanner/trust-scorer.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/trust-scorer.js +6 -0
- package/dist/src/scripts/skill-scanner/trust-scorer.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.action-context.d.ts +15 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.js +209 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.d.ts +25 -0
- package/dist/src/security/scanner/SecurityScanner.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.d.ts +40 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.js +88 -51
- package/dist/src/security/scanner/SecurityScanner.helpers.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.js +39 -6
- package/dist/src/security/scanner/SecurityScanner.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.pii.d.ts +30 -0
- package/dist/src/security/scanner/SecurityScanner.pii.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.pii.js +8 -1
- package/dist/src/security/scanner/SecurityScanner.pii.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.d.ts +60 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.js +228 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.scanners.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.scanners.js +42 -5
- package/dist/src/security/scanner/SecurityScanner.scanners.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts +69 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.js +271 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts +55 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.js +308 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.js.map +1 -0
- package/dist/src/security/scanner/patterns.d.ts +61 -6
- package/dist/src/security/scanner/patterns.d.ts.map +1 -1
- package/dist/src/security/scanner/patterns.js +70 -61
- package/dist/src/security/scanner/patterns.js.map +1 -1
- package/dist/src/security/scanner/patterns.sensitive-path.d.ts +73 -0
- package/dist/src/security/scanner/patterns.sensitive-path.d.ts.map +1 -0
- package/dist/src/security/scanner/patterns.sensitive-path.js +192 -0
- package/dist/src/security/scanner/patterns.sensitive-path.js.map +1 -0
- package/dist/src/security/scanner/regex-utils.d.ts +31 -17
- package/dist/src/security/scanner/regex-utils.d.ts.map +1 -1
- package/dist/src/security/scanner/regex-utils.js +31 -17
- package/dist/src/security/scanner/regex-utils.js.map +1 -1
- package/dist/src/security/scanner/types.d.ts +9 -0
- package/dist/src/security/scanner/types.d.ts.map +1 -1
- package/dist/src/services/agent-pack/prompt-source.d.ts.map +1 -1
- package/dist/src/services/agent-pack/prompt-source.js +3 -2
- package/dist/src/services/agent-pack/prompt-source.js.map +1 -1
- package/dist/src/services/skill-installation.content.d.ts +7 -2
- package/dist/src/services/skill-installation.content.d.ts.map +1 -1
- package/dist/src/services/skill-installation.content.js +75 -28
- package/dist/src/services/skill-installation.content.js.map +1 -1
- package/dist/src/services/skill-installation.helpers.d.ts.map +1 -1
- package/dist/src/services/skill-installation.helpers.js +8 -0
- package/dist/src/services/skill-installation.helpers.js.map +1 -1
- package/dist/src/services/skill-installation.io.d.ts +1 -29
- package/dist/src/services/skill-installation.io.d.ts.map +1 -1
- package/dist/src/services/skill-installation.io.dirs.d.ts +15 -0
- package/dist/src/services/skill-installation.io.dirs.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.dirs.js +84 -0
- package/dist/src/services/skill-installation.io.dirs.js.map +1 -0
- package/dist/src/services/skill-installation.io.js +6 -176
- package/dist/src/services/skill-installation.io.js.map +1 -1
- package/dist/src/services/skill-installation.io.rollback.d.ts +138 -0
- package/dist/src/services/skill-installation.io.rollback.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.rollback.js +218 -0
- package/dist/src/services/skill-installation.io.rollback.js.map +1 -0
- package/dist/src/services/skill-installation.io.write.d.ts +69 -0
- package/dist/src/services/skill-installation.io.write.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.write.js +417 -0
- package/dist/src/services/skill-installation.io.write.js.map +1 -0
- package/dist/src/services/skill-installation.service.d.ts.map +1 -1
- package/dist/src/services/skill-installation.service.finalize.d.ts +62 -0
- package/dist/src/services/skill-installation.service.finalize.d.ts.map +1 -0
- package/dist/src/services/skill-installation.service.finalize.js +136 -0
- package/dist/src/services/skill-installation.service.finalize.js.map +1 -0
- package/dist/src/services/skill-installation.service.js +66 -80
- package/dist/src/services/skill-installation.service.js.map +1 -1
- package/dist/src/services/skill-installation.target-guard.d.ts +82 -0
- package/dist/src/services/skill-installation.target-guard.d.ts.map +1 -0
- package/dist/src/services/skill-installation.target-guard.js +410 -0
- package/dist/src/services/skill-installation.target-guard.js.map +1 -0
- package/dist/src/services/skill-installation.types.d.ts +13 -1
- package/dist/src/services/skill-installation.types.d.ts.map +1 -1
- package/dist/src/services/skill-installation.types.js.map +1 -1
- package/dist/src/services/skill-installation.uninstall.d.ts +3 -1
- package/dist/src/services/skill-installation.uninstall.d.ts.map +1 -1
- package/dist/src/services/skill-installation.uninstall.helpers.d.ts +59 -0
- package/dist/src/services/skill-installation.uninstall.helpers.d.ts.map +1 -0
- package/dist/src/services/skill-installation.uninstall.helpers.js +124 -0
- package/dist/src/services/skill-installation.uninstall.helpers.js.map +1 -0
- package/dist/src/services/skill-installation.uninstall.js +148 -25
- package/dist/src/services/skill-installation.uninstall.js.map +1 -1
- package/dist/src/utils/index.d.ts +1 -0
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/safe-fs.d.ts +69 -0
- package/dist/src/utils/safe-fs.d.ts.map +1 -1
- package/dist/src/utils/safe-fs.js +113 -2
- package/dist/src/utils/safe-fs.js.map +1 -1
- package/dist/src/utils/skill-name.d.ts +19 -0
- package/dist/src/utils/skill-name.d.ts.map +1 -0
- package/dist/src/utils/skill-name.js +26 -0
- package/dist/src/utils/skill-name.js.map +1 -0
- package/dist/src/utils/skill-name.test.d.ts +2 -0
- package/dist/src/utils/skill-name.test.d.ts.map +1 -0
- package/dist/src/utils/skill-name.test.js +34 -0
- package/dist/src/utils/skill-name.test.js.map +1 -0
- package/dist/tests/install/fan-out.test.js +1951 -13
- package/dist/tests/install/fan-out.test.js.map +1 -1
- package/dist/tests/install/remove-if-same.test.d.ts +2 -0
- package/dist/tests/install/remove-if-same.test.d.ts.map +1 -0
- package/dist/tests/install/remove-if-same.test.js +298 -0
- package/dist/tests/install/remove-if-same.test.js.map +1 -0
- package/dist/tests/integration/fan-out-lock-contention.test.d.ts +11 -0
- package/dist/tests/integration/fan-out-lock-contention.test.d.ts.map +1 -0
- package/dist/tests/integration/fan-out-lock-contention.test.js +53 -0
- package/dist/tests/integration/fan-out-lock-contention.test.js.map +1 -0
- package/dist/tests/provenance/backfill.test.js +43 -0
- package/dist/tests/provenance/backfill.test.js.map +1 -1
- package/dist/tests/security/ContinuousSecurity.performance.test.js +12 -7
- package/dist/tests/security/ContinuousSecurity.performance.test.js.map +1 -1
- package/dist/tests/security/ContinuousSecurity.test.js +14 -2
- package/dist/tests/security/ContinuousSecurity.test.js.map +1 -1
- package/dist/tests/security/ai-defence-multiline-promotion.test.d.ts +10 -12
- package/dist/tests/security/ai-defence-multiline-promotion.test.d.ts.map +1 -1
- package/dist/tests/security/ai-defence-multiline-promotion.test.js +26 -33
- package/dist/tests/security/ai-defence-multiline-promotion.test.js.map +1 -1
- package/dist/tests/security/nested-instruction-long-attribute.test.js +20 -10
- package/dist/tests/security/nested-instruction-long-attribute.test.js.map +1 -1
- package/dist/tests/security/scanner-evidence-tiers.test.js +15 -0
- package/dist/tests/security/scanner-evidence-tiers.test.js.map +1 -1
- package/dist/tests/security/scanner-multiline-cap.test.d.ts +21 -0
- package/dist/tests/security/scanner-multiline-cap.test.d.ts.map +1 -0
- package/dist/tests/security/scanner-multiline-cap.test.js +152 -0
- package/dist/tests/security/scanner-multiline-cap.test.js.map +1 -0
- package/dist/tests/security/scanner-regression-guard.test.js +66 -1
- package/dist/tests/security/scanner-regression-guard.test.js.map +1 -1
- package/dist/tests/security/scanner-witness-sw1.test.d.ts +32 -0
- package/dist/tests/security/scanner-witness-sw1.test.d.ts.map +1 -0
- package/dist/tests/security/scanner-witness-sw1.test.js +255 -0
- package/dist/tests/security/scanner-witness-sw1.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.js +313 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.js +151 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-fp.test.js +314 -0
- package/dist/tests/security/sensitive-path-fp.test.js.map +1 -1
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.js +176 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.js.map +1 -0
- package/dist/tests/security/smi5879-corroboration.core.test.js +44 -0
- package/dist/tests/security/smi5879-corroboration.core.test.js.map +1 -1
- package/dist/tests/security/weak-password-lexicon.test.d.ts +18 -0
- package/dist/tests/security/weak-password-lexicon.test.d.ts.map +1 -0
- package/dist/tests/security/weak-password-lexicon.test.js +132 -0
- package/dist/tests/security/weak-password-lexicon.test.js.map +1 -0
- package/dist/tests/skill-scanner/allowlist.test.js +58 -4
- package/dist/tests/skill-scanner/allowlist.test.js.map +1 -1
- package/dist/tests/skill-scanner/categorizer.test.d.ts +2 -0
- package/dist/tests/skill-scanner/categorizer.test.d.ts.map +1 -0
- package/dist/tests/skill-scanner/categorizer.test.js +71 -0
- package/dist/tests/skill-scanner/categorizer.test.js.map +1 -0
- package/dist/tests/skill-scanner/cli-guard.test.d.ts +23 -0
- package/dist/tests/skill-scanner/cli-guard.test.d.ts.map +1 -0
- package/dist/tests/skill-scanner/cli-guard.test.js +71 -0
- package/dist/tests/skill-scanner/cli-guard.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.content.test.js +59 -0
- package/dist/tests/unit/services/skill-installation.content.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js +35 -14
- package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.js +984 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js +148 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.error-codes.test.js +19 -0
- package/dist/tests/unit/services/skill-installation.service.error-codes.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.js +147 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.d.ts +29 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.js +200 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.test.js +43 -0
- package/dist/tests/unit/services/skill-installation.service.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.js +590 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js +473 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js.map +1 -0
- package/dist/tests/utils/safe-fs.test.js +129 -2
- package/dist/tests/utils/safe-fs.test.js.map +1 -1
- package/package.json +6 -1
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* per-client `~/.cursor/skills/` etc. live in an isolated tree, never
|
|
6
6
|
* touching the real filesystem.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdtemp, mkdir, readFile, rm, stat, lstat, symlink, writeFile } from 'node:fs/promises';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
8
|
+
import { mkdtemp, mkdir, readFile, readdir, readlink, rename, rm, stat, lstat, symlink, writeFile, } from 'node:fs/promises';
|
|
9
|
+
import { hostname, tmpdir } from 'node:os';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { mintDeadPid } from '../helpers/deterministic-dead-pid.js';
|
|
12
13
|
const ORIGINAL_HOME = process.env['HOME'];
|
|
13
14
|
const ORIGINAL_USERPROFILE = process.env['USERPROFILE'];
|
|
14
15
|
let homeDir;
|
|
@@ -45,6 +46,26 @@ async function seedSkill(skillId, files = {}) {
|
|
|
45
46
|
}
|
|
46
47
|
return skillDir;
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Make every link-manifest lock attempt fail, as when that lock can't be
|
|
51
|
+
* taken. `beforeFail` runs first, to act out another program's change at
|
|
52
|
+
* exactly that moment.
|
|
53
|
+
*/
|
|
54
|
+
function mockManifestLockFailure(beforeFail) {
|
|
55
|
+
vi.doMock('../../src/install/fan-out.overwrite.js', async () => {
|
|
56
|
+
const actual = await vi.importActual('../../src/install/fan-out.overwrite.js');
|
|
57
|
+
return {
|
|
58
|
+
...actual,
|
|
59
|
+
withFileLock: vi.fn(async (target, label, fn) => {
|
|
60
|
+
if (label.includes('manifest')) {
|
|
61
|
+
await beforeFail?.();
|
|
62
|
+
throw new Error('manifest lock unavailable');
|
|
63
|
+
}
|
|
64
|
+
return actual.withFileLock(target, label, fn);
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
48
69
|
describe('install/fan-out', () => {
|
|
49
70
|
describe('getLinkManifestPath', () => {
|
|
50
71
|
it('returns ~/.skillsmith/links/manifest.json', async () => {
|
|
@@ -178,21 +199,84 @@ describe('install/fan-out', () => {
|
|
|
178
199
|
await mkdir(destDir, { recursive: true });
|
|
179
200
|
await writeFile(path.join(destDir, 'SKILL.md'), '# different\n', 'utf-8');
|
|
180
201
|
await expect(addLink({ skillId: 'clash', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/already exists.*--force/);
|
|
181
|
-
// force
|
|
182
|
-
|
|
202
|
+
// SMI-6529 H3 (round 2) / N7 (round 4): `--force` no longer
|
|
203
|
+
// recursively deletes a pre-existing REAL directory at the
|
|
204
|
+
// destination unless it's a RECORDED Skillsmith copy (see the N7
|
|
205
|
+
// tests below) — an UNRECORDED real directory (this one was created
|
|
206
|
+
// by hand via mkdir/writeFile, never through addLink, so it has no
|
|
207
|
+
// manifest entry) is refused with a clear error, and its content is
|
|
208
|
+
// left completely untouched. N7 corrected the message: it no longer
|
|
209
|
+
// says "not a link Skillsmith created" (false for a recorded COPY,
|
|
210
|
+
// which round-2's H3 wording conflated with this genuinely-unrecorded
|
|
211
|
+
// case) — it now says "not a fan-out destination Skillsmith recorded".
|
|
212
|
+
await expect(addLink({
|
|
183
213
|
skillId: 'clash',
|
|
184
214
|
fromClient: 'claude-code',
|
|
185
215
|
toClient: 'cursor',
|
|
186
216
|
force: true,
|
|
217
|
+
})).rejects.toThrow(/not a fan-out destination Skillsmith recorded/);
|
|
218
|
+
const stillThere = await readFile(path.join(destDir, 'SKILL.md'), 'utf-8');
|
|
219
|
+
expect(stillThere).toBe('# different\n');
|
|
220
|
+
});
|
|
221
|
+
// SMI-6529 H3 regression: a pre-existing SYMLINK (Skillsmith's own prior
|
|
222
|
+
// fan-out) at `toDir` is still safely replaceable under `force: true` —
|
|
223
|
+
// the strict clear only refuses non-symlinks.
|
|
224
|
+
it('H3: force replaces a pre-existing SYMLINK destination', async () => {
|
|
225
|
+
const { addLink } = await loadModule();
|
|
226
|
+
await seedSkill('symclash');
|
|
227
|
+
const destDir = path.join(homeDir, '.cursor', 'skills', 'symclash');
|
|
228
|
+
const staleTarget = path.join(homeDir, 'stale-target');
|
|
229
|
+
await mkdir(staleTarget, { recursive: true });
|
|
230
|
+
await writeFile(path.join(staleTarget, 'SKILL.md'), '# stale\n', 'utf-8');
|
|
231
|
+
await mkdir(path.dirname(destDir), { recursive: true });
|
|
232
|
+
await symlink(staleTarget, destDir, 'dir');
|
|
233
|
+
await addLink({
|
|
234
|
+
skillId: 'symclash',
|
|
235
|
+
fromClient: 'claude-code',
|
|
236
|
+
toClient: 'cursor',
|
|
237
|
+
force: true,
|
|
187
238
|
});
|
|
239
|
+
const linkStat = await lstat(destDir);
|
|
240
|
+
expect(linkStat.isSymbolicLink()).toBe(false); // default kind is copy
|
|
188
241
|
const final = await readFile(path.join(destDir, 'SKILL.md'), 'utf-8');
|
|
189
|
-
expect(final).toBe('# test\n'); // matches seedSkill default
|
|
242
|
+
expect(final).toBe('# test\n'); // matches seedSkill default — real fan-out content
|
|
243
|
+
});
|
|
244
|
+
// SMI-6529 H3 regression (exact reviewer scenario): a real pre-existing
|
|
245
|
+
// directory containing `.git` and a user file must be left COMPLETELY
|
|
246
|
+
// untouched under `force: true` — the strict clear must never recurse
|
|
247
|
+
// into it, and the error must name the path. This directory is
|
|
248
|
+
// UNRECORDED (created by hand, never through addLink), so — per N7's
|
|
249
|
+
// check ordering (unrecorded-ness is checked BEFORE the `.git` check,
|
|
250
|
+
// since an unrecorded directory is refused regardless of its content)
|
|
251
|
+
// — it hits the "not a fan-out destination Skillsmith recorded"
|
|
252
|
+
// message, not the git-specific one (that message is reserved for a
|
|
253
|
+
// RECORDED copy that later grew a `.git` directory — see the N6/N7
|
|
254
|
+
// tests below for that exact scenario).
|
|
255
|
+
it('H3: force never deletes a real directory containing .git and user content', async () => {
|
|
256
|
+
const { addLink } = await loadModule();
|
|
257
|
+
await seedSkill('gitclash');
|
|
258
|
+
const destDir = path.join(homeDir, '.cursor', 'skills', 'gitclash');
|
|
259
|
+
await mkdir(path.join(destDir, '.git'), { recursive: true });
|
|
260
|
+
await writeFile(path.join(destDir, 'my-notes.md'), 'do not delete me\n', 'utf-8');
|
|
261
|
+
await expect(addLink({
|
|
262
|
+
skillId: 'gitclash',
|
|
263
|
+
fromClient: 'claude-code',
|
|
264
|
+
toClient: 'cursor',
|
|
265
|
+
force: true,
|
|
266
|
+
})).rejects.toThrow(new RegExp(`${destDir}.*not a fan-out destination Skillsmith recorded`));
|
|
267
|
+
// Untouched: both the .git entry and the user's own file survive.
|
|
268
|
+
await expect(stat(path.join(destDir, '.git'))).resolves.toBeDefined();
|
|
269
|
+
const notes = await readFile(path.join(destDir, 'my-notes.md'), 'utf-8');
|
|
270
|
+
expect(notes).toBe('do not delete me\n');
|
|
190
271
|
});
|
|
191
272
|
it('detects a reverse-direction cycle in an existing manifest entry', async () => {
|
|
192
273
|
const { addLink } = await loadModule();
|
|
193
|
-
// Seed forward link cursor → agents
|
|
194
|
-
await seedSkill('cycle', {});
|
|
195
274
|
// Seed an artificial source for the reverse hop (agents → claude-code)
|
|
275
|
+
// — NOT the canonical claude-code location itself (SMI-6529 H3: a
|
|
276
|
+
// force-overwrite of a REAL pre-existing directory there would now be
|
|
277
|
+
// correctly refused as "not a link Skillsmith created"; seeding only
|
|
278
|
+
// the agents-side source means claude-code's own `cycle` doesn't exist
|
|
279
|
+
// yet when this forward link is created, so no overwrite is needed).
|
|
196
280
|
const agentsDir = path.join(homeDir, '.agents', 'skills', 'cycle');
|
|
197
281
|
await mkdir(agentsDir, { recursive: true });
|
|
198
282
|
await writeFile(path.join(agentsDir, 'SKILL.md'), '# cycle\n', 'utf-8');
|
|
@@ -202,15 +286,23 @@ describe('install/fan-out', () => {
|
|
|
202
286
|
toClient: 'claude-code',
|
|
203
287
|
force: true,
|
|
204
288
|
});
|
|
205
|
-
// Reverse: claude-code → agents —
|
|
289
|
+
// Reverse: claude-code → agents — detectCycle() must fire BEFORE any
|
|
290
|
+
// overwrite is even considered (it runs ahead of the inspectPath/force
|
|
291
|
+
// check in addLink()), so this never needs to touch agents' own
|
|
292
|
+
// pre-existing `cycle` directory either.
|
|
206
293
|
await expect(addLink({ skillId: 'cycle', fromClient: 'claude-code', toClient: 'agents', force: true })).rejects.toThrow(/cycle detected/);
|
|
207
294
|
});
|
|
208
295
|
});
|
|
209
296
|
describe('removeLinks', () => {
|
|
210
|
-
|
|
297
|
+
// SMI-6529 N6 (round 4): `removeLinks` now returns `{removed, refused}`
|
|
298
|
+
// instead of a bare number, so a caller can distinguish "nothing was
|
|
299
|
+
// recorded" from "something was recorded but refused removal" (see the
|
|
300
|
+
// N6 test below). These two tests updated for the new return shape —
|
|
301
|
+
// behavior for the happy paths they cover is unchanged.
|
|
302
|
+
it('returns {removed: 0, refused: []} when no manifest exists', async () => {
|
|
211
303
|
const { removeLinks } = await loadModule();
|
|
212
|
-
const
|
|
213
|
-
expect(
|
|
304
|
+
const result = await removeLinks('nothing');
|
|
305
|
+
expect(result).toEqual({ removed: 0, refused: [] });
|
|
214
306
|
});
|
|
215
307
|
it('removes both copies and symlinks for a skillId', async () => {
|
|
216
308
|
const { addLink, removeLinks, listLinks } = await loadModule();
|
|
@@ -222,8 +314,9 @@ describe('install/fan-out', () => {
|
|
|
222
314
|
toClient: 'windsurf',
|
|
223
315
|
preferSymlink: true,
|
|
224
316
|
});
|
|
225
|
-
const
|
|
226
|
-
expect(removed).toBe(2);
|
|
317
|
+
const result = await removeLinks('multi');
|
|
318
|
+
expect(result.removed).toBe(2);
|
|
319
|
+
expect(result.refused).toEqual([]);
|
|
227
320
|
// both destinations are gone
|
|
228
321
|
await expect(stat(path.join(homeDir, '.cursor', 'skills', 'multi'))).rejects.toThrow(/ENOENT/);
|
|
229
322
|
await expect(lstat(path.join(homeDir, '.codeium', 'windsurf', 'skills', 'multi'))).rejects.toThrow(/ENOENT/);
|
|
@@ -233,6 +326,1851 @@ describe('install/fan-out', () => {
|
|
|
233
326
|
// canonical source is untouched
|
|
234
327
|
await expect(stat(path.join(homeDir, '.claude', 'skills', 'multi'))).resolves.toBeDefined();
|
|
235
328
|
});
|
|
329
|
+
// SMI-6529 N6 (round 4, reviewer probe-removelinks.mjs exact scenario):
|
|
330
|
+
// a RECORDED copy-mode fan-out destination that has since become the
|
|
331
|
+
// user's own git clone (with uncommitted work) must survive an
|
|
332
|
+
// uninstall's `removeLinks` call — it must be refused and reported, not
|
|
333
|
+
// recursively deleted on the strength of a stale manifest record alone.
|
|
334
|
+
it('N6: refuses to recursively delete a recorded copy that has since become a .git working tree', async () => {
|
|
335
|
+
const { addLink, removeLinks, listLinks } = await loadModule();
|
|
336
|
+
await seedSkill('gitreplaced');
|
|
337
|
+
const { record } = await addLink({
|
|
338
|
+
skillId: 'gitreplaced',
|
|
339
|
+
fromClient: 'claude-code',
|
|
340
|
+
toClient: 'cursor',
|
|
341
|
+
});
|
|
342
|
+
expect(record.kind).toBe('copy');
|
|
343
|
+
// The user later replaces the fan-out copy with their own git clone +
|
|
344
|
+
// uncommitted work.
|
|
345
|
+
await rm(record.to, { recursive: true, force: true });
|
|
346
|
+
await mkdir(path.join(record.to, '.git'), { recursive: true });
|
|
347
|
+
await writeFile(path.join(record.to, 'SKILL.md'), 'UNCOMMITTED USER WORK', 'utf-8');
|
|
348
|
+
const result = await removeLinks('gitreplaced');
|
|
349
|
+
expect(result.removed).toBe(0);
|
|
350
|
+
expect(result.refused).toHaveLength(1);
|
|
351
|
+
expect(result.refused[0]?.to).toBe(record.to);
|
|
352
|
+
expect(result.refused[0]?.reason).toMatch(/\.git directory/);
|
|
353
|
+
// The clone survives completely untouched.
|
|
354
|
+
await expect(stat(path.join(record.to, '.git'))).resolves.toBeDefined();
|
|
355
|
+
expect(await readFile(path.join(record.to, 'SKILL.md'), 'utf-8')).toBe('UNCOMMITTED USER WORK');
|
|
356
|
+
// The manifest entry is KEPT (not silently dropped) so a future
|
|
357
|
+
// uninstall retry is still possible once the user clears the path.
|
|
358
|
+
const remaining = await listLinks('gitreplaced');
|
|
359
|
+
expect(remaining).toHaveLength(1);
|
|
360
|
+
});
|
|
361
|
+
// SMI-6529 N6 (round 4): a RECORDED symlink is still just unlinked
|
|
362
|
+
// outright — the git-at-root refusal is specific to a recorded COPY.
|
|
363
|
+
it('N6: still unlinks a recorded SYMLINK outright, even if its target now contains .git', async () => {
|
|
364
|
+
const { addLink, removeLinks } = await loadModule();
|
|
365
|
+
await seedSkill('symlinkstillremoved');
|
|
366
|
+
const { record } = await addLink({
|
|
367
|
+
skillId: 'symlinkstillremoved',
|
|
368
|
+
fromClient: 'claude-code',
|
|
369
|
+
toClient: 'windsurf',
|
|
370
|
+
preferSymlink: true,
|
|
371
|
+
});
|
|
372
|
+
expect(record.kind).toBe('symlink');
|
|
373
|
+
// A `.git` dir at the SYMLINK TARGET (the canonical source) must not
|
|
374
|
+
// matter — only the recorded destination's OWN type (symlink vs
|
|
375
|
+
// real directory) governs whether it's disposable.
|
|
376
|
+
await mkdir(path.join(homeDir, '.claude', 'skills', 'symlinkstillremoved', '.git'), {
|
|
377
|
+
recursive: true,
|
|
378
|
+
});
|
|
379
|
+
const result = await removeLinks('symlinkstillremoved');
|
|
380
|
+
expect(result.removed).toBe(1);
|
|
381
|
+
expect(result.refused).toEqual([]);
|
|
382
|
+
await expect(lstat(record.to)).rejects.toThrow(/ENOENT/);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
// SMI-6529 review round 5: the force-refresh backup must never delete an
|
|
386
|
+
// existing path (R2), a backup stranded by a crash must be restored (R3),
|
|
387
|
+
// and a dangling `.git` symlink must still count as a git working tree (R4).
|
|
388
|
+
describe('SMI-6529 round 5: fan-out overwrite backups', () => {
|
|
389
|
+
it('R2: a force refresh never removes an existing path at a backup-like name', async () => {
|
|
390
|
+
const { addLink } = await loadModule();
|
|
391
|
+
await seedSkill('bkclash');
|
|
392
|
+
const { record } = await addLink({
|
|
393
|
+
skillId: 'bkclash',
|
|
394
|
+
fromClient: 'claude-code',
|
|
395
|
+
toClient: 'cursor',
|
|
396
|
+
});
|
|
397
|
+
// The pre-round-5 fixed backup name, and a dot-prefixed look-alike,
|
|
398
|
+
// both holding user data.
|
|
399
|
+
const legacyName = `${record.to}.smi6529-backup.${process.pid}.tmp`;
|
|
400
|
+
await mkdir(path.join(legacyName, '.git'), { recursive: true });
|
|
401
|
+
await writeFile(path.join(legacyName, 'KEEP.md'), 'keep me', 'utf-8');
|
|
402
|
+
const lookAlike = path.join(path.dirname(record.to), '.bkclash.skillsmith-backup-userdata');
|
|
403
|
+
await mkdir(lookAlike, { recursive: true });
|
|
404
|
+
await writeFile(path.join(lookAlike, 'KEEP.md'), 'keep me too', 'utf-8');
|
|
405
|
+
await addLink({
|
|
406
|
+
skillId: 'bkclash',
|
|
407
|
+
fromClient: 'claude-code',
|
|
408
|
+
toClient: 'cursor',
|
|
409
|
+
force: true,
|
|
410
|
+
});
|
|
411
|
+
expect(await readFile(path.join(legacyName, 'KEEP.md'), 'utf-8')).toBe('keep me');
|
|
412
|
+
await expect(stat(path.join(legacyName, '.git'))).resolves.toBeDefined();
|
|
413
|
+
expect(await readFile(path.join(lookAlike, 'KEEP.md'), 'utf-8')).toBe('keep me too');
|
|
414
|
+
// The refresh left no backup container of its own behind.
|
|
415
|
+
const leftovers = (await readdir(path.dirname(record.to))).filter((name) => name.startsWith('.bkclash.skillsmith-backup-') && name !== path.basename(lookAlike));
|
|
416
|
+
expect(leftovers).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
it('R3: restores a destination stranded in a backup by a crash before rewriting', async () => {
|
|
419
|
+
const { addLink } = await loadModule();
|
|
420
|
+
await seedSkill('stranded');
|
|
421
|
+
const { record } = await addLink({
|
|
422
|
+
skillId: 'stranded',
|
|
423
|
+
fromClient: 'claude-code',
|
|
424
|
+
toClient: 'cursor',
|
|
425
|
+
});
|
|
426
|
+
await writeFile(path.join(record.to, 'LOCAL.md'), 'recorded copy content', 'utf-8');
|
|
427
|
+
// A crash after the rename-aside: the destination is gone and the
|
|
428
|
+
// original sits inside a backup container.
|
|
429
|
+
const container = path.join(path.dirname(record.to), '.stranded.skillsmith-backup-crash1');
|
|
430
|
+
await mkdir(container, { recursive: true });
|
|
431
|
+
await rename(record.to, path.join(container, 'original'));
|
|
432
|
+
// A plain addLink restores the original first, then reports that the
|
|
433
|
+
// destination exists.
|
|
434
|
+
await expect(addLink({ skillId: 'stranded', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/already exists/);
|
|
435
|
+
expect(await readFile(path.join(record.to, 'LOCAL.md'), 'utf-8')).toBe('recorded copy content');
|
|
436
|
+
await expect(stat(container)).rejects.toThrow(/ENOENT/);
|
|
437
|
+
});
|
|
438
|
+
it('R4: a dangling .git symlink in a recorded copy still blocks overwrite and uninstall', async () => {
|
|
439
|
+
const { addLink, removeLinks } = await loadModule();
|
|
440
|
+
await seedSkill('danglinggit');
|
|
441
|
+
const { record } = await addLink({
|
|
442
|
+
skillId: 'danglinggit',
|
|
443
|
+
fromClient: 'claude-code',
|
|
444
|
+
toClient: 'cursor',
|
|
445
|
+
});
|
|
446
|
+
await writeFile(path.join(record.to, 'LOCAL-EDIT.md'), 'mine', 'utf-8');
|
|
447
|
+
await symlink(path.join(homeDir, 'does-not-exist'), path.join(record.to, '.git'));
|
|
448
|
+
await expect(addLink({
|
|
449
|
+
skillId: 'danglinggit',
|
|
450
|
+
fromClient: 'claude-code',
|
|
451
|
+
toClient: 'cursor',
|
|
452
|
+
force: true,
|
|
453
|
+
})).rejects.toThrow(/\.git directory/);
|
|
454
|
+
expect(await readFile(path.join(record.to, 'LOCAL-EDIT.md'), 'utf-8')).toBe('mine');
|
|
455
|
+
const result = await removeLinks('danglinggit');
|
|
456
|
+
expect(result.removed).toBe(0);
|
|
457
|
+
expect(result.refused).toHaveLength(1);
|
|
458
|
+
expect(await readFile(path.join(record.to, 'LOCAL-EDIT.md'), 'utf-8')).toBe('mine');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
// SMI-6529 review round 6: refreshes run under a per-destination lock and
|
|
462
|
+
// write to a staging folder that is swapped into place, so concurrent calls
|
|
463
|
+
// never lose content, a failed write never touches the destination, stale
|
|
464
|
+
// folders are cleaned or ignored safely, and names match exactly.
|
|
465
|
+
describe('SMI-6529 round 6: locked, staged refresh', () => {
|
|
466
|
+
it('concurrent force refreshes of one destination all succeed and never lose content', async () => {
|
|
467
|
+
const { addLink, listLinks } = await loadModule();
|
|
468
|
+
const files = { 'SKILL.md': '# race\n' };
|
|
469
|
+
for (let i = 0; i < 40; i++)
|
|
470
|
+
files[`f${i}.md`] = `file ${i}\n`;
|
|
471
|
+
await seedSkill('race', files);
|
|
472
|
+
await addLink({ skillId: 'race', fromClient: 'claude-code', toClient: 'cursor' });
|
|
473
|
+
const results = await Promise.allSettled(Array.from({ length: 5 }, () => addLink({ skillId: 'race', fromClient: 'claude-code', toClient: 'cursor', force: true })));
|
|
474
|
+
expect(results.filter((r) => r.status === 'rejected')).toEqual([]);
|
|
475
|
+
const dest = path.join(homeDir, '.cursor', 'skills', 'race');
|
|
476
|
+
expect((await readdir(dest)).length).toBe(41);
|
|
477
|
+
expect(await listLinks('race')).toHaveLength(1);
|
|
478
|
+
const leftovers = (await readdir(path.dirname(dest))).filter((name) => name.includes('skillsmith-backup-') || name.includes('skillsmith-staging-'));
|
|
479
|
+
expect(leftovers).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
it('keeps and reports a stale staging folder, and never restores a backup whose original is not a directory', async () => {
|
|
482
|
+
const { addLink } = await loadModule();
|
|
483
|
+
await seedSkill('leftovers');
|
|
484
|
+
const { record } = await addLink({
|
|
485
|
+
skillId: 'leftovers',
|
|
486
|
+
fromClient: 'claude-code',
|
|
487
|
+
toClient: 'cursor',
|
|
488
|
+
});
|
|
489
|
+
const parent = path.dirname(record.to);
|
|
490
|
+
const staleStaging = path.join(parent, '.leftovers.skillsmith-staging-abc123');
|
|
491
|
+
await mkdir(path.join(staleStaging, 'content'), { recursive: true });
|
|
492
|
+
// A backup whose `original` is a symlink, with the destination missing.
|
|
493
|
+
const oddBackup = path.join(parent, '.leftovers.skillsmith-backup-sym001');
|
|
494
|
+
await mkdir(oddBackup, { recursive: true });
|
|
495
|
+
await symlink(homeDir, path.join(oddBackup, 'original'));
|
|
496
|
+
await rm(record.to, { recursive: true, force: true });
|
|
497
|
+
const result = await addLink({
|
|
498
|
+
skillId: 'leftovers',
|
|
499
|
+
fromClient: 'claude-code',
|
|
500
|
+
toClient: 'cursor',
|
|
501
|
+
});
|
|
502
|
+
// Round 14: a crashed write's staging folder is kept and reported, never deleted.
|
|
503
|
+
expect((await lstat(path.join(staleStaging, 'content'))).isDirectory()).toBe(true);
|
|
504
|
+
expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining(staleStaging)]));
|
|
505
|
+
expect((await lstat(record.to)).isDirectory()).toBe(true);
|
|
506
|
+
expect((await lstat(path.join(oddBackup, 'original'))).isSymbolicLink()).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
it("never touches another skill's folders that share a name prefix", async () => {
|
|
509
|
+
const { addLink } = await loadModule();
|
|
510
|
+
await seedSkill('foo');
|
|
511
|
+
const { record } = await addLink({
|
|
512
|
+
skillId: 'foo',
|
|
513
|
+
fromClient: 'claude-code',
|
|
514
|
+
toClient: 'cursor',
|
|
515
|
+
});
|
|
516
|
+
const parent = path.dirname(record.to);
|
|
517
|
+
// Folders of a different skill literally named `foo.skillsmith-backup-x`,
|
|
518
|
+
// and a look-alike staging name with a 7-character suffix.
|
|
519
|
+
const siblingBackup = path.join(parent, '.foo.skillsmith-backup-x.skillsmith-backup-abc123');
|
|
520
|
+
await mkdir(path.join(siblingBackup, 'original'), { recursive: true });
|
|
521
|
+
await writeFile(path.join(siblingBackup, 'original', 'KEEP.md'), 'sibling', 'utf-8');
|
|
522
|
+
const siblingStaging = path.join(parent, '.foo.skillsmith-staging-toolong');
|
|
523
|
+
await mkdir(siblingStaging, { recursive: true });
|
|
524
|
+
await rm(record.to, { recursive: true, force: true });
|
|
525
|
+
await addLink({ skillId: 'foo', fromClient: 'claude-code', toClient: 'cursor' });
|
|
526
|
+
expect(await readFile(path.join(siblingBackup, 'original', 'KEEP.md'), 'utf-8')).toBe('sibling');
|
|
527
|
+
await expect(stat(siblingStaging)).resolves.toBeDefined();
|
|
528
|
+
await expect(stat(path.join(record.to, 'KEEP.md'))).rejects.toThrow(/ENOENT/);
|
|
529
|
+
});
|
|
530
|
+
it('reports a failed .git check as unverifiable, not as a git working tree', async () => {
|
|
531
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
532
|
+
await seedSkill('noaccess');
|
|
533
|
+
const first = await setupAddLink({
|
|
534
|
+
skillId: 'noaccess',
|
|
535
|
+
fromClient: 'claude-code',
|
|
536
|
+
toClient: 'cursor',
|
|
537
|
+
});
|
|
538
|
+
vi.doMock('node:fs/promises', async () => {
|
|
539
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
540
|
+
return {
|
|
541
|
+
...actual,
|
|
542
|
+
default: actual,
|
|
543
|
+
lstat: vi.fn(async (...args) => {
|
|
544
|
+
if (String(args[0]).endsWith(`${path.sep}.git`)) {
|
|
545
|
+
const err = new Error('EACCES: permission denied');
|
|
546
|
+
err.code = 'EACCES';
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
return actual.lstat(...args);
|
|
550
|
+
}),
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
try {
|
|
554
|
+
vi.resetModules();
|
|
555
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
556
|
+
await expect(addLink({
|
|
557
|
+
skillId: 'noaccess',
|
|
558
|
+
fromClient: 'claude-code',
|
|
559
|
+
toClient: 'cursor',
|
|
560
|
+
force: true,
|
|
561
|
+
})).rejects.toThrow(/could not check .* for a \.git directory \(EACCES\)/);
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
vi.doUnmock('node:fs/promises');
|
|
565
|
+
vi.resetModules();
|
|
566
|
+
}
|
|
567
|
+
expect(await readFile(path.join(first.record.to, 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
// SMI-6529 review round 7: waiting for the destination lock never blocks
|
|
571
|
+
// the event loop, a symlinked spelling shares the in-process queue, and a
|
|
572
|
+
// leftover backup from an interrupted refresh is reported, not hidden.
|
|
573
|
+
describe('SMI-6529 round 7: non-blocking lock and leftover warnings', () => {
|
|
574
|
+
it('two spellings of one destination serialize without blocking the event loop', async () => {
|
|
575
|
+
const { withDestinationLock } = await import('../../src/install/fan-out.overwrite.js');
|
|
576
|
+
const realParent = path.join(homeDir, 'real-parent');
|
|
577
|
+
await mkdir(realParent, { recursive: true });
|
|
578
|
+
const aliasParent = path.join(homeDir, 'alias-parent');
|
|
579
|
+
await symlink(realParent, aliasParent);
|
|
580
|
+
let releaseFirst = () => { };
|
|
581
|
+
const firstHeld = new Promise((resolve) => {
|
|
582
|
+
releaseFirst = resolve;
|
|
583
|
+
});
|
|
584
|
+
const order = [];
|
|
585
|
+
const first = withDestinationLock(path.join(realParent, 'dest'), async () => {
|
|
586
|
+
order.push('first-start');
|
|
587
|
+
await firstHeld;
|
|
588
|
+
order.push('first-end');
|
|
589
|
+
});
|
|
590
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
591
|
+
const second = withDestinationLock(path.join(aliasParent, 'dest'), async () => {
|
|
592
|
+
order.push('second');
|
|
593
|
+
});
|
|
594
|
+
let ticks = 0;
|
|
595
|
+
const timer = setInterval(() => {
|
|
596
|
+
ticks++;
|
|
597
|
+
}, 10);
|
|
598
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
599
|
+
clearInterval(timer);
|
|
600
|
+
expect(ticks).toBeGreaterThan(5);
|
|
601
|
+
expect(order).toEqual(['first-start']);
|
|
602
|
+
releaseFirst();
|
|
603
|
+
await Promise.all([first, second]);
|
|
604
|
+
expect(order).toEqual(['first-start', 'first-end', 'second']);
|
|
605
|
+
});
|
|
606
|
+
it('waits for a lock held elsewhere without blocking the event loop', async () => {
|
|
607
|
+
const { withDestinationLock } = await import('../../src/install/fan-out.overwrite.js');
|
|
608
|
+
const { acquireOwnedLock } = await import('../../src/config/owned-lock.js');
|
|
609
|
+
const parent = path.join(homeDir, 'held-parent');
|
|
610
|
+
await mkdir(parent, { recursive: true });
|
|
611
|
+
const releaseOther = acquireOwnedLock(path.join(parent, '.dest.skillsmith-fanout'));
|
|
612
|
+
let ran = false;
|
|
613
|
+
const waiting = withDestinationLock(path.join(parent, 'dest'), async () => {
|
|
614
|
+
ran = true;
|
|
615
|
+
});
|
|
616
|
+
let ticks = 0;
|
|
617
|
+
const timer = setInterval(() => {
|
|
618
|
+
ticks++;
|
|
619
|
+
}, 10);
|
|
620
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
621
|
+
clearInterval(timer);
|
|
622
|
+
expect(ticks).toBeGreaterThan(5);
|
|
623
|
+
expect(ran).toBe(false);
|
|
624
|
+
releaseOther();
|
|
625
|
+
await waiting;
|
|
626
|
+
expect(ran).toBe(true);
|
|
627
|
+
});
|
|
628
|
+
it('reports a leftover backup from an interrupted refresh as a warning, and keeps it', async () => {
|
|
629
|
+
const { addLink, removeLinks } = await loadModule();
|
|
630
|
+
await seedSkill('leftwarn');
|
|
631
|
+
const { record } = await addLink({
|
|
632
|
+
skillId: 'leftwarn',
|
|
633
|
+
fromClient: 'claude-code',
|
|
634
|
+
toClient: 'cursor',
|
|
635
|
+
});
|
|
636
|
+
const backupFolder = path.join(path.dirname(record.to), '.leftwarn.skillsmith-backup-old001');
|
|
637
|
+
await mkdir(path.join(backupFolder, 'original'), { recursive: true });
|
|
638
|
+
await writeFile(path.join(backupFolder, 'original', 'OLD.md'), 'old copy', 'utf-8');
|
|
639
|
+
const refreshed = await addLink({
|
|
640
|
+
skillId: 'leftwarn',
|
|
641
|
+
fromClient: 'claude-code',
|
|
642
|
+
toClient: 'cursor',
|
|
643
|
+
force: true,
|
|
644
|
+
});
|
|
645
|
+
expect(refreshed.warnings).toEqual([expect.stringContaining(backupFolder)]);
|
|
646
|
+
expect(await readFile(path.join(backupFolder, 'original', 'OLD.md'), 'utf-8')).toBe('old copy');
|
|
647
|
+
const removed = await removeLinks('leftwarn');
|
|
648
|
+
expect(removed.warnings).toEqual([expect.stringContaining(backupFolder)]);
|
|
649
|
+
expect(await readFile(path.join(backupFolder, 'original', 'OLD.md'), 'utf-8')).toBe('old copy');
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
// SMI-6529 review round 8: a stale backup is never restored over a skill
|
|
653
|
+
// that was uninstalled since, an empty backup folder is removed, and the
|
|
654
|
+
// lock wait never blocks on an orphaned reclaim lock or gives up while a
|
|
655
|
+
// live holder has the lock with auto-reclaim turned off.
|
|
656
|
+
describe('SMI-6529 round 8: backup recovery and lock waits', () => {
|
|
657
|
+
function claimFor(pid) {
|
|
658
|
+
return (JSON.stringify({
|
|
659
|
+
v: 1,
|
|
660
|
+
pid,
|
|
661
|
+
token: 'a'.repeat(16),
|
|
662
|
+
host: hostname(),
|
|
663
|
+
acquiredAt: Date.now(),
|
|
664
|
+
}) + '\n');
|
|
665
|
+
}
|
|
666
|
+
it('a reinstall after an uninstall never restores a superseded backup', async () => {
|
|
667
|
+
const { addLink, removeLinks, listLinks } = await loadModule();
|
|
668
|
+
await seedSkill('stale');
|
|
669
|
+
const { record } = await addLink({
|
|
670
|
+
skillId: 'stale',
|
|
671
|
+
fromClient: 'claude-code',
|
|
672
|
+
toClient: 'cursor',
|
|
673
|
+
});
|
|
674
|
+
// A superseded copy left behind by a crash after an earlier swap.
|
|
675
|
+
const backupFolder = path.join(path.dirname(record.to), '.stale.skillsmith-backup-old001');
|
|
676
|
+
await mkdir(path.join(backupFolder, 'original'), { recursive: true });
|
|
677
|
+
await writeFile(path.join(backupFolder, 'original', 'SKILL.md'), '# stale\n', 'utf-8');
|
|
678
|
+
const removed = await removeLinks('stale');
|
|
679
|
+
expect(removed.removed).toBe(1);
|
|
680
|
+
await expect(stat(record.to)).rejects.toThrow(/ENOENT/);
|
|
681
|
+
const reinstalled = await addLink({
|
|
682
|
+
skillId: 'stale',
|
|
683
|
+
fromClient: 'claude-code',
|
|
684
|
+
toClient: 'cursor',
|
|
685
|
+
});
|
|
686
|
+
expect(await readFile(path.join(record.to, 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
687
|
+
expect(await listLinks('stale')).toHaveLength(1);
|
|
688
|
+
expect(reinstalled.warnings).toEqual([expect.stringContaining(backupFolder)]);
|
|
689
|
+
expect(await readFile(path.join(backupFolder, 'original', 'SKILL.md'), 'utf-8')).toBe('# stale\n');
|
|
690
|
+
});
|
|
691
|
+
it('removes an empty backup folder left by a crash instead of reporting it', async () => {
|
|
692
|
+
const { addLink, removeLinks } = await loadModule();
|
|
693
|
+
await seedSkill('emptybk');
|
|
694
|
+
const { record } = await addLink({
|
|
695
|
+
skillId: 'emptybk',
|
|
696
|
+
fromClient: 'claude-code',
|
|
697
|
+
toClient: 'cursor',
|
|
698
|
+
});
|
|
699
|
+
const parent = path.dirname(record.to);
|
|
700
|
+
const first = path.join(parent, '.emptybk.skillsmith-backup-empty1');
|
|
701
|
+
await mkdir(first);
|
|
702
|
+
const refreshed = await addLink({
|
|
703
|
+
skillId: 'emptybk',
|
|
704
|
+
fromClient: 'claude-code',
|
|
705
|
+
toClient: 'cursor',
|
|
706
|
+
force: true,
|
|
707
|
+
});
|
|
708
|
+
expect(refreshed.warnings).toBeUndefined();
|
|
709
|
+
await expect(stat(first)).rejects.toThrow(/ENOENT/);
|
|
710
|
+
const second = path.join(parent, '.emptybk.skillsmith-backup-empty2');
|
|
711
|
+
await mkdir(second);
|
|
712
|
+
const removed = await removeLinks('emptybk');
|
|
713
|
+
expect(removed.warnings).toBeUndefined();
|
|
714
|
+
await expect(stat(second)).rejects.toThrow(/ENOENT/);
|
|
715
|
+
});
|
|
716
|
+
it('waits on an orphaned reclaim lock without blocking the event loop', async () => {
|
|
717
|
+
const { withDestinationLock } = await import('../../src/install/fan-out.overwrite.js');
|
|
718
|
+
const parent = path.join(homeDir, 'orphan-parent');
|
|
719
|
+
await mkdir(parent, { recursive: true });
|
|
720
|
+
const lockPath = path.join(parent, '.dest.skillsmith-fanout.lock');
|
|
721
|
+
// A crashed holder, plus a reclaim lock orphaned by a crash mid-reclaim.
|
|
722
|
+
await writeFile(lockPath, claimFor(mintDeadPid()));
|
|
723
|
+
await writeFile(`${lockPath}.reclaim`, claimFor(process.pid));
|
|
724
|
+
let ran = false;
|
|
725
|
+
const waiting = withDestinationLock(path.join(parent, 'dest'), async () => {
|
|
726
|
+
ran = true;
|
|
727
|
+
});
|
|
728
|
+
let last = performance.now();
|
|
729
|
+
let maxGap = 0;
|
|
730
|
+
const timer = setInterval(() => {
|
|
731
|
+
const now = performance.now();
|
|
732
|
+
maxGap = Math.max(maxGap, now - last);
|
|
733
|
+
last = now;
|
|
734
|
+
}, 10);
|
|
735
|
+
let ranBeforeUnstick = false;
|
|
736
|
+
try {
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
// Unstick before any assertion, so a failure never leaves the wait
|
|
741
|
+
// polling into a deleted temp HOME.
|
|
742
|
+
clearInterval(timer);
|
|
743
|
+
ranBeforeUnstick = ran;
|
|
744
|
+
await rm(`${lockPath}.reclaim`); // the documented manual unstick
|
|
745
|
+
}
|
|
746
|
+
await waiting;
|
|
747
|
+
expect(ranBeforeUnstick).toBe(false);
|
|
748
|
+
// Each attempt used to sleep 500 ms synchronously on the reclaim lock.
|
|
749
|
+
expect(maxGap).toBeLessThan(200);
|
|
750
|
+
expect(ran).toBe(true);
|
|
751
|
+
});
|
|
752
|
+
it('waits for a live holder when auto-reclaim is turned off', async () => {
|
|
753
|
+
const previous = process.env['SKILLSMITH_LOCK_NO_AUTO_RECLAIM'];
|
|
754
|
+
process.env['SKILLSMITH_LOCK_NO_AUTO_RECLAIM'] = '1';
|
|
755
|
+
try {
|
|
756
|
+
const { withDestinationLock } = await import('../../src/install/fan-out.overwrite.js');
|
|
757
|
+
const { acquireOwnedLock } = await import('../../src/config/owned-lock.js');
|
|
758
|
+
const parent = path.join(homeDir, 'noreclaim-parent');
|
|
759
|
+
await mkdir(parent, { recursive: true });
|
|
760
|
+
const releaseOther = acquireOwnedLock(path.join(parent, '.dest.skillsmith-fanout'));
|
|
761
|
+
let ran = false;
|
|
762
|
+
const outcome = withDestinationLock(path.join(parent, 'dest'), async () => {
|
|
763
|
+
ran = true;
|
|
764
|
+
}).then(() => 'ok', (err) => err);
|
|
765
|
+
let ranBeforeRelease = false;
|
|
766
|
+
try {
|
|
767
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
768
|
+
ranBeforeRelease = ran;
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
releaseOther();
|
|
772
|
+
}
|
|
773
|
+
expect(ranBeforeRelease).toBe(false);
|
|
774
|
+
expect(await outcome).toBe('ok');
|
|
775
|
+
expect(ran).toBe(true);
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
if (previous === undefined)
|
|
779
|
+
delete process.env['SKILLSMITH_LOCK_NO_AUTO_RECLAIM'];
|
|
780
|
+
else
|
|
781
|
+
process.env['SKILLSMITH_LOCK_NO_AUTO_RECLAIM'] = previous;
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
// SMI-6529 review round 9: only an unambiguous stranded backup is restored,
|
|
786
|
+
// the link manifest is changed under its own lock and never silently
|
|
787
|
+
// replaced, and a case-variant backup is reported where the volume ignores
|
|
788
|
+
// case.
|
|
789
|
+
describe('SMI-6529 round 9: manifest lock and ambiguous backups', () => {
|
|
790
|
+
it('restores nothing when two backups could be the stranded copy, in either name order', async () => {
|
|
791
|
+
const { addLink } = await loadModule();
|
|
792
|
+
const orders = [
|
|
793
|
+
['twoa', 'AAAAAA', 'zzzzzz'],
|
|
794
|
+
['twob', 'zzzzzz', 'AAAAAA'],
|
|
795
|
+
];
|
|
796
|
+
for (const [skillId, olderSuffix, newerSuffix] of orders) {
|
|
797
|
+
await seedSkill(skillId);
|
|
798
|
+
const { record } = await addLink({ skillId, fromClient: 'claude-code', toClient: 'cursor' });
|
|
799
|
+
// Stranded: the destination is gone, the record is still there.
|
|
800
|
+
await rm(record.to, { recursive: true, force: true });
|
|
801
|
+
const parent = path.dirname(record.to);
|
|
802
|
+
const older = path.join(parent, `.${skillId}.skillsmith-backup-${olderSuffix}`);
|
|
803
|
+
const newer = path.join(parent, `.${skillId}.skillsmith-backup-${newerSuffix}`);
|
|
804
|
+
for (const [folder, content] of [
|
|
805
|
+
[older, '# older\n'],
|
|
806
|
+
[newer, '# newer\n'],
|
|
807
|
+
]) {
|
|
808
|
+
await mkdir(path.join(folder, 'original'), { recursive: true });
|
|
809
|
+
await writeFile(path.join(folder, 'original', 'SKILL.md'), content, 'utf-8');
|
|
810
|
+
}
|
|
811
|
+
const result = await addLink({ skillId, fromClient: 'claude-code', toClient: 'cursor' });
|
|
812
|
+
expect(await readFile(path.join(record.to, 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
813
|
+
expect(result.warnings).toHaveLength(2);
|
|
814
|
+
expect(await readFile(path.join(older, 'original', 'SKILL.md'), 'utf-8')).toBe('# older\n');
|
|
815
|
+
expect(await readFile(path.join(newer, 'original', 'SKILL.md'), 'utf-8')).toBe('# newer\n');
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
it('keeps every record when different skills fan out at the same time', async () => {
|
|
819
|
+
const { addLink, listLinks } = await loadModule();
|
|
820
|
+
const ids = Array.from({ length: 12 }, (_, i) => `par${i}`);
|
|
821
|
+
for (const id of ids)
|
|
822
|
+
await seedSkill(id);
|
|
823
|
+
const results = await Promise.allSettled(ids.map((skillId) => addLink({ skillId, fromClient: 'claude-code', toClient: 'cursor' })));
|
|
824
|
+
expect(results.filter((r) => r.status === 'rejected')).toEqual([]);
|
|
825
|
+
expect((await listLinks()).map((l) => l.skillId).sort()).toEqual([...ids].sort());
|
|
826
|
+
});
|
|
827
|
+
it('moves a corrupt manifest aside with a warning instead of wiping it', async () => {
|
|
828
|
+
const { addLink, removeLinks, listLinks, getLinkManifestPath } = await loadModule();
|
|
829
|
+
await seedSkill('fresh');
|
|
830
|
+
const manifestPath = getLinkManifestPath();
|
|
831
|
+
const corrupt = '{"version":1,"links":[{"skillId":"other"';
|
|
832
|
+
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
833
|
+
await writeFile(manifestPath, corrupt, 'utf-8');
|
|
834
|
+
// An uninstall writes nothing, and says it couldn't use the manifest.
|
|
835
|
+
expect(await removeLinks('other')).toEqual({
|
|
836
|
+
removed: 0,
|
|
837
|
+
refused: [],
|
|
838
|
+
warnings: [expect.stringContaining('could not be parsed')],
|
|
839
|
+
});
|
|
840
|
+
expect(await readFile(manifestPath, 'utf-8')).toBe(corrupt);
|
|
841
|
+
const result = await addLink({
|
|
842
|
+
skillId: 'fresh',
|
|
843
|
+
fromClient: 'claude-code',
|
|
844
|
+
toClient: 'cursor',
|
|
845
|
+
});
|
|
846
|
+
const aside = (await readdir(path.dirname(manifestPath))).filter((n) => n.startsWith('manifest.json.corrupt-'));
|
|
847
|
+
expect(aside).toHaveLength(1);
|
|
848
|
+
expect(await readFile(path.join(path.dirname(manifestPath), String(aside[0])), 'utf-8')).toBe(corrupt);
|
|
849
|
+
expect(result.warnings).toEqual([expect.stringContaining('could not be parsed')]);
|
|
850
|
+
expect((await listLinks()).map((l) => l.skillId)).toEqual(['fresh']);
|
|
851
|
+
});
|
|
852
|
+
it('refuses to overwrite a manifest it cannot read, before writing any copy', async () => {
|
|
853
|
+
const { addLink, getLinkManifestPath } = await loadModule();
|
|
854
|
+
await seedSkill('blocked');
|
|
855
|
+
const manifestPath = getLinkManifestPath();
|
|
856
|
+
await mkdir(manifestPath, { recursive: true }); // a directory: EISDIR on read
|
|
857
|
+
await expect(addLink({ skillId: 'blocked', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/could not read the fan-out link manifest/);
|
|
858
|
+
expect((await stat(manifestPath)).isDirectory()).toBe(true);
|
|
859
|
+
// No unrecorded copy was left behind.
|
|
860
|
+
await expect(stat(path.join(homeDir, '.cursor', 'skills', 'blocked'))).rejects.toThrow(/ENOENT/);
|
|
861
|
+
});
|
|
862
|
+
it('reports a case-variant backup only where the volume ignores case, and never removes it', async () => {
|
|
863
|
+
const { listLeftoverBackups } = await import('../../src/install/fan-out.overwrite.js');
|
|
864
|
+
const parent = path.join(homeDir, 'case-parent');
|
|
865
|
+
await mkdir(parent, { recursive: true });
|
|
866
|
+
await writeFile(path.join(parent, 'probe'), '');
|
|
867
|
+
const ignoresCase = await stat(path.join(parent, 'PROBE')).then(() => true, () => false);
|
|
868
|
+
const variant = path.join(parent, '.CV.skillsmith-backup-AbC123');
|
|
869
|
+
await mkdir(path.join(variant, 'original'), { recursive: true });
|
|
870
|
+
await writeFile(path.join(variant, 'original', 'SKILL.md'), '# variant\n', 'utf-8');
|
|
871
|
+
const emptyVariant = path.join(parent, '.CV.skillsmith-backup-Emp123');
|
|
872
|
+
await mkdir(emptyVariant);
|
|
873
|
+
const leftovers = await listLeftoverBackups(path.join(parent, 'cv'));
|
|
874
|
+
expect(leftovers.folders).toEqual(ignoresCase ? [variant] : []);
|
|
875
|
+
await expect(stat(variant)).resolves.toBeDefined();
|
|
876
|
+
await expect(stat(emptyVariant)).resolves.toBeDefined();
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
// SMI-6529 review round 10: an uninstall never drops a concurrent re-link's
|
|
880
|
+
// record, a newer manifest version is never moved aside, a corrupt manifest
|
|
881
|
+
// is named in a force-refresh refusal, and a fresh copy whose record can't
|
|
882
|
+
// be saved is taken back out.
|
|
883
|
+
describe('SMI-6529 round 10: record integrity under races and bad manifests', () => {
|
|
884
|
+
it('an uninstall never drops the record of a copy a concurrent re-link just made', async () => {
|
|
885
|
+
const { addLink, removeLinks, listLinks } = await loadModule();
|
|
886
|
+
const { acquireOwnedLock } = await import('../../src/config/owned-lock.js');
|
|
887
|
+
await seedSkill('rmadd');
|
|
888
|
+
const cursor = await addLink({
|
|
889
|
+
skillId: 'rmadd',
|
|
890
|
+
fromClient: 'claude-code',
|
|
891
|
+
toClient: 'cursor',
|
|
892
|
+
});
|
|
893
|
+
const windsurf = await addLink({
|
|
894
|
+
skillId: 'rmadd',
|
|
895
|
+
fromClient: 'claude-code',
|
|
896
|
+
toClient: 'windsurf',
|
|
897
|
+
});
|
|
898
|
+
// Hold windsurf's destination lock so the uninstall pauses after cursor.
|
|
899
|
+
const releaseWindsurf = acquireOwnedLock(path.join(path.dirname(windsurf.record.to), '.rmadd.skillsmith-fanout'));
|
|
900
|
+
let removal = Promise.resolve();
|
|
901
|
+
try {
|
|
902
|
+
removal = removeLinks('rmadd');
|
|
903
|
+
for (let i = 0; i < 150; i++) {
|
|
904
|
+
const present = await stat(cursor.record.to).then(() => true, () => false);
|
|
905
|
+
if (!present)
|
|
906
|
+
break;
|
|
907
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
908
|
+
}
|
|
909
|
+
await expect(stat(cursor.record.to)).rejects.toThrow(/ENOENT/);
|
|
910
|
+
// A re-link of the same skill to cursor lands mid-uninstall.
|
|
911
|
+
await addLink({ skillId: 'rmadd', fromClient: 'claude-code', toClient: 'cursor' });
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
releaseWindsurf();
|
|
915
|
+
}
|
|
916
|
+
await removal;
|
|
917
|
+
expect((await listLinks('rmadd')).map((l) => l.to)).toEqual([cursor.record.to]);
|
|
918
|
+
await expect(stat(cursor.record.to)).resolves.toBeDefined();
|
|
919
|
+
});
|
|
920
|
+
it('refuses to overwrite a manifest written by a newer version', async () => {
|
|
921
|
+
const { addLink, getLinkManifestPath } = await loadModule();
|
|
922
|
+
await seedSkill('newer');
|
|
923
|
+
const manifestPath = getLinkManifestPath();
|
|
924
|
+
const newer = '{"version":2,"links":[{"skillId":"future"}]}';
|
|
925
|
+
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
926
|
+
await writeFile(manifestPath, newer, 'utf-8');
|
|
927
|
+
await expect(addLink({ skillId: 'newer', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/newer Skillsmith/);
|
|
928
|
+
expect(await readFile(manifestPath, 'utf-8')).toBe(newer);
|
|
929
|
+
const aside = (await readdir(path.dirname(manifestPath))).filter((n) => n.includes('.corrupt-'));
|
|
930
|
+
expect(aside).toEqual([]);
|
|
931
|
+
});
|
|
932
|
+
it('names the corrupt manifest when refusing a force refresh over an existing copy', async () => {
|
|
933
|
+
const { addLink, getLinkManifestPath } = await loadModule();
|
|
934
|
+
await seedSkill('corruptforce');
|
|
935
|
+
await addLink({ skillId: 'corruptforce', fromClient: 'claude-code', toClient: 'cursor' });
|
|
936
|
+
await writeFile(getLinkManifestPath(), 'not json', 'utf-8');
|
|
937
|
+
await expect(addLink({
|
|
938
|
+
skillId: 'corruptforce',
|
|
939
|
+
fromClient: 'claude-code',
|
|
940
|
+
toClient: 'cursor',
|
|
941
|
+
force: true,
|
|
942
|
+
})).rejects.toThrow(/could not be parsed/);
|
|
943
|
+
});
|
|
944
|
+
it('takes a fresh copy back out when its record cannot be saved', async () => {
|
|
945
|
+
mockManifestLockFailure();
|
|
946
|
+
try {
|
|
947
|
+
vi.resetModules();
|
|
948
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
949
|
+
await seedSkill('norecord');
|
|
950
|
+
await expect(addLink({ skillId: 'norecord', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/manifest lock unavailable/);
|
|
951
|
+
await expect(stat(path.join(homeDir, '.cursor', 'skills', 'norecord'))).rejects.toThrow(/ENOENT/);
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
vi.doUnmock('../../src/install/fan-out.overwrite.js');
|
|
955
|
+
vi.resetModules();
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
// SMI-6529 review round 11: an uninstall drops every current record for a
|
|
960
|
+
// destination it removes, a refreshed symlink is put back when its record
|
|
961
|
+
// can't be saved, a byte-order mark isn't corruption, and an uninstall says
|
|
962
|
+
// when it couldn't use the manifest.
|
|
963
|
+
describe('SMI-6529 round 11: record integrity, second pass', () => {
|
|
964
|
+
it('an uninstall that runs after a force refresh leaves no record for the folder it removed', async () => {
|
|
965
|
+
const { addLink, removeLinks, listLinks } = await loadModule();
|
|
966
|
+
const { acquireOwnedLock } = await import('../../src/config/owned-lock.js');
|
|
967
|
+
await seedSkill('addrm');
|
|
968
|
+
const { record } = await addLink({
|
|
969
|
+
skillId: 'addrm',
|
|
970
|
+
fromClient: 'claude-code',
|
|
971
|
+
toClient: 'cursor',
|
|
972
|
+
});
|
|
973
|
+
// Hold the destination's lock so both calls queue on it: the refresh
|
|
974
|
+
// first, then the uninstall, which has already read the old record.
|
|
975
|
+
const release = acquireOwnedLock(path.join(path.dirname(record.to), '.addrm.skillsmith-fanout'));
|
|
976
|
+
let refresh = Promise.resolve();
|
|
977
|
+
let removal = Promise.resolve();
|
|
978
|
+
try {
|
|
979
|
+
refresh = addLink({
|
|
980
|
+
skillId: 'addrm',
|
|
981
|
+
fromClient: 'claude-code',
|
|
982
|
+
toClient: 'cursor',
|
|
983
|
+
force: true,
|
|
984
|
+
});
|
|
985
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
986
|
+
removal = removeLinks('addrm');
|
|
987
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
release();
|
|
991
|
+
}
|
|
992
|
+
await refresh;
|
|
993
|
+
await removal;
|
|
994
|
+
expect(await listLinks('addrm')).toEqual([]);
|
|
995
|
+
await expect(stat(record.to)).rejects.toThrow(/ENOENT/);
|
|
996
|
+
});
|
|
997
|
+
it('puts a refreshed symlink back when its record cannot be saved', async () => {
|
|
998
|
+
const { addLink } = await loadModule();
|
|
999
|
+
await seedSkill('linkundo');
|
|
1000
|
+
const first = await addLink({
|
|
1001
|
+
skillId: 'linkundo',
|
|
1002
|
+
fromClient: 'claude-code',
|
|
1003
|
+
toClient: 'windsurf',
|
|
1004
|
+
preferSymlink: true,
|
|
1005
|
+
});
|
|
1006
|
+
expect(first.record.kind).toBe('symlink');
|
|
1007
|
+
const target = await readlink(first.record.to);
|
|
1008
|
+
mockManifestLockFailure();
|
|
1009
|
+
try {
|
|
1010
|
+
vi.resetModules();
|
|
1011
|
+
const { addLink: failingAddLink } = await import('../../src/install/fan-out.js');
|
|
1012
|
+
await expect(failingAddLink({
|
|
1013
|
+
skillId: 'linkundo',
|
|
1014
|
+
fromClient: 'claude-code',
|
|
1015
|
+
toClient: 'windsurf',
|
|
1016
|
+
force: true,
|
|
1017
|
+
})).rejects.toThrow(/manifest lock unavailable/);
|
|
1018
|
+
}
|
|
1019
|
+
finally {
|
|
1020
|
+
vi.doUnmock('../../src/install/fan-out.overwrite.js');
|
|
1021
|
+
vi.resetModules();
|
|
1022
|
+
}
|
|
1023
|
+
expect((await lstat(first.record.to)).isSymbolicLink()).toBe(true);
|
|
1024
|
+
expect(await readlink(first.record.to)).toBe(target);
|
|
1025
|
+
});
|
|
1026
|
+
it('reads a manifest that starts with a byte-order mark', async () => {
|
|
1027
|
+
const { addLink, listLinks, getLinkManifestPath } = await loadModule();
|
|
1028
|
+
await seedSkill('bomkeep');
|
|
1029
|
+
await seedSkill('bomnew');
|
|
1030
|
+
const { record } = await addLink({
|
|
1031
|
+
skillId: 'bomkeep',
|
|
1032
|
+
fromClient: 'claude-code',
|
|
1033
|
+
toClient: 'cursor',
|
|
1034
|
+
});
|
|
1035
|
+
const manifestPath = getLinkManifestPath();
|
|
1036
|
+
await writeFile(manifestPath, '\uFEFF' + (await readFile(manifestPath, 'utf-8')), 'utf-8');
|
|
1037
|
+
expect((await listLinks()).map((l) => l.to)).toEqual([record.to]);
|
|
1038
|
+
const result = await addLink({
|
|
1039
|
+
skillId: 'bomnew',
|
|
1040
|
+
fromClient: 'claude-code',
|
|
1041
|
+
toClient: 'cursor',
|
|
1042
|
+
});
|
|
1043
|
+
expect(result.warnings).toBeUndefined();
|
|
1044
|
+
expect((await listLinks()).map((l) => l.skillId).sort()).toEqual(['bomkeep', 'bomnew']);
|
|
1045
|
+
});
|
|
1046
|
+
it('says which client folders it could not check, instead of "nothing to clean up"', async () => {
|
|
1047
|
+
const manifestPath = (await loadModule()).getLinkManifestPath();
|
|
1048
|
+
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
1049
|
+
await writeFile(manifestPath, '{"version":2,"links":[{"skillId":"future"}]}', 'utf-8');
|
|
1050
|
+
const cursorDir = path.join(homeDir, '.cursor', 'skills', 'unreadableclient');
|
|
1051
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1052
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1053
|
+
const lstat = vi.fn(async (p, ...rest) => {
|
|
1054
|
+
if (String(p) === cursorDir) {
|
|
1055
|
+
throw Object.assign(new Error(`EACCES: permission denied, lstat '${cursorDir}'`), {
|
|
1056
|
+
code: 'EACCES',
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
return actual.lstat(p, ...rest);
|
|
1060
|
+
});
|
|
1061
|
+
return { ...actual, default: { ...actual, lstat }, lstat };
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
vi.resetModules();
|
|
1065
|
+
const { removeLinks } = await import('../../src/install/fan-out.js');
|
|
1066
|
+
const result = await removeLinks('unreadableclient');
|
|
1067
|
+
// Round 27: a folder that could not be inspected was counted as absent,
|
|
1068
|
+
// and the warning then promised there was nothing to clean up.
|
|
1069
|
+
const warning = (result.warnings ?? []).join('\n');
|
|
1070
|
+
expect(warning).toContain('could not be checked');
|
|
1071
|
+
expect(warning).toContain(cursorDir);
|
|
1072
|
+
expect(warning).not.toContain('nothing to clean up');
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
vi.doUnmock('node:fs/promises');
|
|
1076
|
+
vi.resetModules();
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
it('tells the user when an uninstall could not use the manifest, and where copies remain', async () => {
|
|
1080
|
+
const { removeLinks, getLinkManifestPath } = await loadModule();
|
|
1081
|
+
const manifestPath = getLinkManifestPath();
|
|
1082
|
+
const newer = '{"version":2,"links":[{"skillId":"future"}]}';
|
|
1083
|
+
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
1084
|
+
await writeFile(manifestPath, newer, 'utf-8');
|
|
1085
|
+
// No other client has a folder for it: nothing to clean up.
|
|
1086
|
+
const none = await removeLinks('future');
|
|
1087
|
+
expect(none.removed).toBe(0);
|
|
1088
|
+
expect(none.warnings).toEqual([expect.stringContaining('newer Skillsmith')]);
|
|
1089
|
+
expect(none.warnings?.[0]).toContain('nothing to clean up');
|
|
1090
|
+
// A leftover folder in another client's skills folder is named, with
|
|
1091
|
+
// what to do; the canonical folder is never listed.
|
|
1092
|
+
const leftover = path.join(homeDir, '.cursor', 'skills', 'future');
|
|
1093
|
+
const canonical = path.join(homeDir, '.claude', 'skills', 'future');
|
|
1094
|
+
await mkdir(leftover, { recursive: true });
|
|
1095
|
+
await mkdir(canonical, { recursive: true });
|
|
1096
|
+
const some = await removeLinks('future');
|
|
1097
|
+
expect(some.warnings?.[0]).toContain(leftover);
|
|
1098
|
+
expect(some.warnings?.[0]).toContain('delete it yourself');
|
|
1099
|
+
expect(some.warnings?.[0]).not.toContain(canonical);
|
|
1100
|
+
await expect(stat(leftover)).resolves.toBeDefined();
|
|
1101
|
+
expect(await readFile(manifestPath, 'utf-8')).toBe(newer);
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
// SMI-6529 round 13 (cross-model review): the destination lock keeps other
|
|
1105
|
+
// Skillsmith calls out, not other programs, so a staging or backup folder is
|
|
1106
|
+
// only deleted while it is still the folder we made.
|
|
1107
|
+
describe('SMI-6529 round 13: cleanup never deletes a folder something else put in place', () => {
|
|
1108
|
+
it('keeps a folder that replaced the staging folder before a failed write is cleaned up', async () => {
|
|
1109
|
+
const { replaceDestination } = await import('../../src/install/fan-out.overwrite.js');
|
|
1110
|
+
const parent = path.join(homeDir, 'swap-staging');
|
|
1111
|
+
await mkdir(parent, { recursive: true });
|
|
1112
|
+
let stagingFolder = '';
|
|
1113
|
+
await expect(replaceDestination(path.join(parent, 'dest'), async (staged) => {
|
|
1114
|
+
stagingFolder = path.dirname(staged);
|
|
1115
|
+
await mkdir(staged);
|
|
1116
|
+
await writeFile(path.join(staged, 'partial.md'), 'partial', 'utf-8');
|
|
1117
|
+
// Another program swaps its own folder in at the staging path.
|
|
1118
|
+
await rename(stagingFolder, `${stagingFolder}-moved`);
|
|
1119
|
+
await mkdir(stagingFolder);
|
|
1120
|
+
await writeFile(path.join(stagingFolder, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1121
|
+
throw new Error('write failed');
|
|
1122
|
+
})).rejects.toThrow(/write failed.*replaced by something else/);
|
|
1123
|
+
expect(await readFile(path.join(stagingFolder, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1124
|
+
});
|
|
1125
|
+
it('keeps a folder that replaced the backup folder, and reports it', async () => {
|
|
1126
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
1127
|
+
await seedSkill('swapbk');
|
|
1128
|
+
const { record } = await setupAddLink({
|
|
1129
|
+
skillId: 'swapbk',
|
|
1130
|
+
fromClient: 'claude-code',
|
|
1131
|
+
toClient: 'cursor',
|
|
1132
|
+
});
|
|
1133
|
+
const parent = path.dirname(record.to);
|
|
1134
|
+
let swappedIn = '';
|
|
1135
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1136
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1137
|
+
const rename = async (from, to) => {
|
|
1138
|
+
await actual.rename(from, to);
|
|
1139
|
+
// Right after the new copy lands, another program swaps its own
|
|
1140
|
+
// folder in at the backup path.
|
|
1141
|
+
if (String(to) === record.to && String(from).endsWith(`${path.sep}content`)) {
|
|
1142
|
+
const name = (await actual.readdir(parent)).find((n) => n.startsWith('.swapbk.skillsmith-backup-'));
|
|
1143
|
+
if (name !== undefined) {
|
|
1144
|
+
swappedIn = path.join(parent, name);
|
|
1145
|
+
await actual.rename(swappedIn, `${swappedIn}-moved`);
|
|
1146
|
+
await actual.mkdir(swappedIn);
|
|
1147
|
+
await actual.writeFile(path.join(swappedIn, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1152
|
+
});
|
|
1153
|
+
try {
|
|
1154
|
+
vi.resetModules();
|
|
1155
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1156
|
+
const refreshed = await addLink({
|
|
1157
|
+
skillId: 'swapbk',
|
|
1158
|
+
fromClient: 'claude-code',
|
|
1159
|
+
toClient: 'cursor',
|
|
1160
|
+
force: true,
|
|
1161
|
+
});
|
|
1162
|
+
expect(swappedIn).not.toBe('');
|
|
1163
|
+
expect(await readFile(path.join(swappedIn, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1164
|
+
expect(refreshed.warnings).toEqual([expect.stringContaining(swappedIn)]);
|
|
1165
|
+
}
|
|
1166
|
+
finally {
|
|
1167
|
+
vi.doUnmock('node:fs/promises');
|
|
1168
|
+
vi.resetModules();
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
it('keeps a stale staging folder that holds something other than a partial copy', async () => {
|
|
1172
|
+
const { addLink } = await loadModule();
|
|
1173
|
+
await seedSkill('foreignstage');
|
|
1174
|
+
const parent = path.join(homeDir, '.cursor', 'skills');
|
|
1175
|
+
const foreign = path.join(parent, '.foreignstage.skillsmith-staging-abc123');
|
|
1176
|
+
await mkdir(foreign, { recursive: true });
|
|
1177
|
+
await writeFile(path.join(foreign, 'KEEP.md'), 'not a partial copy', 'utf-8');
|
|
1178
|
+
await addLink({ skillId: 'foreignstage', fromClient: 'claude-code', toClient: 'cursor' });
|
|
1179
|
+
expect(await readFile(path.join(foreign, 'KEEP.md'), 'utf-8')).toBe('not a partial copy');
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
// SMI-6529 round 14 (cross-model and Opus confirmation reviews): a crashed
|
|
1183
|
+
// staging folder is reported, never deleted; a folder left in place is
|
|
1184
|
+
// named with the reason; and an undo or an uninstall removes only the
|
|
1185
|
+
// entry it saw.
|
|
1186
|
+
describe('SMI-6529 round 14: every delete checks what it removes', () => {
|
|
1187
|
+
it('reports a crashed staging folder shaped like our own, and never deletes it', async () => {
|
|
1188
|
+
const { addLink } = await loadModule();
|
|
1189
|
+
await seedSkill('stagingshape');
|
|
1190
|
+
const { record } = await addLink({
|
|
1191
|
+
skillId: 'stagingshape',
|
|
1192
|
+
fromClient: 'claude-code',
|
|
1193
|
+
toClient: 'cursor',
|
|
1194
|
+
});
|
|
1195
|
+
const shaped = path.join(path.dirname(record.to), '.stagingshape.skillsmith-staging-abc123');
|
|
1196
|
+
await mkdir(path.join(shaped, 'content'), { recursive: true });
|
|
1197
|
+
await writeFile(path.join(shaped, 'content', 'KEEP.md'), 'someone else', 'utf-8');
|
|
1198
|
+
const refreshed = await addLink({
|
|
1199
|
+
skillId: 'stagingshape',
|
|
1200
|
+
fromClient: 'claude-code',
|
|
1201
|
+
toClient: 'cursor',
|
|
1202
|
+
force: true,
|
|
1203
|
+
});
|
|
1204
|
+
expect(await readFile(path.join(shaped, 'content', 'KEEP.md'), 'utf-8')).toBe('someone else');
|
|
1205
|
+
expect(refreshed.warnings).toEqual([expect.stringContaining(shaped)]);
|
|
1206
|
+
expect(refreshed.warnings?.[0]).toContain('a partial copy');
|
|
1207
|
+
});
|
|
1208
|
+
it('names why a failed write left its staging folder in place, and leaves it there', async () => {
|
|
1209
|
+
const failing = { mode: '' };
|
|
1210
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1211
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1212
|
+
const eacces = (p) => Object.assign(new Error(`EACCES: permission denied, '${String(p)}'`), { code: 'EACCES' });
|
|
1213
|
+
const isStagingFolder = (p) => /\.skillsmith-staging-[A-Za-z0-9]{6}$/.test(String(p));
|
|
1214
|
+
// Round 15: the delete checks and removes the folder under a parked name.
|
|
1215
|
+
const isParkedStaging = (p) => /\.skillsmith-staging-[A-Za-z0-9]{6}\.skillsmith-removing-[0-9a-f]{32}$/.test(String(p));
|
|
1216
|
+
const rename = vi.fn(async (from, to) => {
|
|
1217
|
+
if (failing.mode === 'rename' && isStagingFolder(from))
|
|
1218
|
+
throw eacces(from);
|
|
1219
|
+
return actual.rename(from, to);
|
|
1220
|
+
});
|
|
1221
|
+
const lstat = vi.fn(async (...args) => {
|
|
1222
|
+
if (failing.mode === 'lstat' && isParkedStaging(args[0]))
|
|
1223
|
+
throw eacces(args[0]);
|
|
1224
|
+
return actual.lstat(...args);
|
|
1225
|
+
});
|
|
1226
|
+
const rm = vi.fn(async (...args) => {
|
|
1227
|
+
if (failing.mode === 'rm' && isParkedStaging(args[0]))
|
|
1228
|
+
throw eacces(args[0]);
|
|
1229
|
+
return actual.rm(...args);
|
|
1230
|
+
});
|
|
1231
|
+
return { ...actual, default: { ...actual, rename, lstat, rm }, rename, lstat, rm };
|
|
1232
|
+
});
|
|
1233
|
+
try {
|
|
1234
|
+
// Group 1 is the folder the message says was left; it must really be there.
|
|
1235
|
+
const cases = [
|
|
1236
|
+
[
|
|
1237
|
+
'rename',
|
|
1238
|
+
/write failed; the staging folder (\S+) could not be moved aside to be removed \(EACCES\), so it was left in place$/,
|
|
1239
|
+
],
|
|
1240
|
+
[
|
|
1241
|
+
'lstat',
|
|
1242
|
+
/write failed; the staging folder \S+ could not be checked \(EACCES\) and is now at (\S+)$/,
|
|
1243
|
+
],
|
|
1244
|
+
[
|
|
1245
|
+
'rm',
|
|
1246
|
+
/write failed; the staging folder \S+ could not be removed \(EACCES\); what is left of it is at (\S+)$/,
|
|
1247
|
+
],
|
|
1248
|
+
];
|
|
1249
|
+
for (const [mode, expected] of cases) {
|
|
1250
|
+
failing.mode = mode;
|
|
1251
|
+
vi.resetModules();
|
|
1252
|
+
const { replaceDestination } = await import('../../src/install/fan-out.overwrite.js');
|
|
1253
|
+
const parent = path.join(homeDir, `named-${mode}`);
|
|
1254
|
+
await mkdir(parent, { recursive: true });
|
|
1255
|
+
const err = await replaceDestination(path.join(parent, 'dest'), async () => {
|
|
1256
|
+
throw new Error('write failed');
|
|
1257
|
+
}).catch((e) => e);
|
|
1258
|
+
const match = expected.exec(err instanceof Error ? err.message : String(err));
|
|
1259
|
+
expect(match, `${mode}: ${String(err)}`).not.toBeNull();
|
|
1260
|
+
expect((await lstat(match?.[1] ?? '')).isDirectory()).toBe(true);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
finally {
|
|
1264
|
+
failing.mode = '';
|
|
1265
|
+
vi.doUnmock('node:fs/promises');
|
|
1266
|
+
vi.resetModules();
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
it('an undo leaves a folder that replaced its new copy, and says so', async () => {
|
|
1270
|
+
await seedSkill('undoswap');
|
|
1271
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'undoswap');
|
|
1272
|
+
mockManifestLockFailure(async () => {
|
|
1273
|
+
// Another program swaps its own folder in before the undo runs.
|
|
1274
|
+
await rename(toDir, `${toDir}-moved`);
|
|
1275
|
+
await mkdir(toDir);
|
|
1276
|
+
await writeFile(path.join(toDir, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1277
|
+
});
|
|
1278
|
+
try {
|
|
1279
|
+
vi.resetModules();
|
|
1280
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1281
|
+
await expect(addLink({ skillId: 'undoswap', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/could not be undone: \S+undoswap was replaced by something else/);
|
|
1282
|
+
expect(await readFile(path.join(toDir, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1283
|
+
}
|
|
1284
|
+
finally {
|
|
1285
|
+
vi.doUnmock('../../src/install/fan-out.overwrite.js');
|
|
1286
|
+
vi.resetModules();
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
it('an uninstall leaves a folder that replaced the copy after its .git check', async () => {
|
|
1290
|
+
const { addLink } = await loadModule();
|
|
1291
|
+
await seedSkill('rmswap');
|
|
1292
|
+
const { record } = await addLink({
|
|
1293
|
+
skillId: 'rmswap',
|
|
1294
|
+
fromClient: 'claude-code',
|
|
1295
|
+
toClient: 'cursor',
|
|
1296
|
+
});
|
|
1297
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1298
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1299
|
+
let swapped = false;
|
|
1300
|
+
const lstat = vi.fn(async (...args) => {
|
|
1301
|
+
// The .git check: another program swaps its own folder in right now.
|
|
1302
|
+
if (!swapped && String(args[0]) === path.join(record.to, '.git')) {
|
|
1303
|
+
swapped = true;
|
|
1304
|
+
await actual.rename(record.to, `${record.to}-moved`);
|
|
1305
|
+
await actual.mkdir(record.to);
|
|
1306
|
+
await actual.writeFile(path.join(record.to, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1307
|
+
}
|
|
1308
|
+
return actual.lstat(...args);
|
|
1309
|
+
});
|
|
1310
|
+
return { ...actual, default: { ...actual, lstat }, lstat };
|
|
1311
|
+
});
|
|
1312
|
+
try {
|
|
1313
|
+
vi.resetModules();
|
|
1314
|
+
const { removeLinks, listLinks } = await import('../../src/install/fan-out.js');
|
|
1315
|
+
const result = await removeLinks('rmswap');
|
|
1316
|
+
expect(result.removed).toBe(0);
|
|
1317
|
+
expect(result.refused).toEqual([
|
|
1318
|
+
{ to: record.to, reason: expect.stringContaining('replaced by something else') },
|
|
1319
|
+
]);
|
|
1320
|
+
expect(await readFile(path.join(record.to, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1321
|
+
expect(await listLinks('rmswap')).toHaveLength(1);
|
|
1322
|
+
}
|
|
1323
|
+
finally {
|
|
1324
|
+
vi.doUnmock('node:fs/promises');
|
|
1325
|
+
vi.resetModules();
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
// SMI-6529 round 15 (Opus round 14 and cross-model round 3 confirmations).
|
|
1330
|
+
describe('SMI-6529 round 15: identity taken before the swap, and cleanup that can fail', () => {
|
|
1331
|
+
it('an undo never deletes a folder swapped in just after the new copy landed', async () => {
|
|
1332
|
+
await seedSkill('gapswap');
|
|
1333
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'gapswap');
|
|
1334
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1335
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1336
|
+
let swapped = false;
|
|
1337
|
+
const rmdir = vi.fn(async (...args) => {
|
|
1338
|
+
// The staging folder's rmdir, right after the swap: another program
|
|
1339
|
+
// moves the new copy aside and puts its own folder there. The
|
|
1340
|
+
// identity used to be recorded after this moment.
|
|
1341
|
+
if (!swapped && /\.gapswap\.skillsmith-staging-[A-Za-z0-9]{6}$/.test(String(args[0]))) {
|
|
1342
|
+
swapped = true;
|
|
1343
|
+
await actual.rename(toDir, `${toDir}-moved`);
|
|
1344
|
+
await actual.mkdir(toDir);
|
|
1345
|
+
await actual.writeFile(path.join(toDir, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1346
|
+
}
|
|
1347
|
+
return actual.rmdir(...args);
|
|
1348
|
+
});
|
|
1349
|
+
return { ...actual, default: { ...actual, rmdir }, rmdir };
|
|
1350
|
+
});
|
|
1351
|
+
mockManifestLockFailure();
|
|
1352
|
+
try {
|
|
1353
|
+
vi.resetModules();
|
|
1354
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1355
|
+
await expect(addLink({ skillId: 'gapswap', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/could not be undone: \S+gapswap was replaced by something else/);
|
|
1356
|
+
expect(await readFile(path.join(toDir, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1357
|
+
}
|
|
1358
|
+
finally {
|
|
1359
|
+
vi.doUnmock('node:fs/promises');
|
|
1360
|
+
vi.doUnmock('../../src/install/fan-out.overwrite.js');
|
|
1361
|
+
vi.resetModules();
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
it('an uninstall keeps the record of a copy it could not check', async () => {
|
|
1365
|
+
const { addLink } = await loadModule();
|
|
1366
|
+
await seedSkill('rmcheck');
|
|
1367
|
+
const { record } = await addLink({
|
|
1368
|
+
skillId: 'rmcheck',
|
|
1369
|
+
fromClient: 'claude-code',
|
|
1370
|
+
toClient: 'cursor',
|
|
1371
|
+
});
|
|
1372
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1373
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1374
|
+
const lstat = vi.fn(async (...args) => {
|
|
1375
|
+
if (String(args[0]) === record.to) {
|
|
1376
|
+
throw Object.assign(new Error(`EACCES: permission denied, lstat '${record.to}'`), {
|
|
1377
|
+
code: 'EACCES',
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
return actual.lstat(...args);
|
|
1381
|
+
});
|
|
1382
|
+
return { ...actual, default: { ...actual, lstat }, lstat };
|
|
1383
|
+
});
|
|
1384
|
+
try {
|
|
1385
|
+
vi.resetModules();
|
|
1386
|
+
const { removeLinks, listLinks } = await import('../../src/install/fan-out.js');
|
|
1387
|
+
const result = await removeLinks('rmcheck');
|
|
1388
|
+
expect(result.removed).toBe(0);
|
|
1389
|
+
expect(result.refused).toEqual([
|
|
1390
|
+
{ to: record.to, reason: expect.stringContaining('could not be checked') },
|
|
1391
|
+
]);
|
|
1392
|
+
expect(await listLinks('rmcheck')).toHaveLength(1);
|
|
1393
|
+
expect((await lstat(record.to)).isDirectory()).toBe(true);
|
|
1394
|
+
}
|
|
1395
|
+
finally {
|
|
1396
|
+
vi.doUnmock('node:fs/promises');
|
|
1397
|
+
vi.resetModules();
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
it('reports what a failed removal left under a parked name, and only for this skill', async () => {
|
|
1401
|
+
const { addLink } = await loadModule();
|
|
1402
|
+
await seedSkill('parkleft');
|
|
1403
|
+
const { record } = await addLink({
|
|
1404
|
+
skillId: 'parkleft',
|
|
1405
|
+
fromClient: 'claude-code',
|
|
1406
|
+
toClient: 'cursor',
|
|
1407
|
+
});
|
|
1408
|
+
const parent = path.dirname(record.to);
|
|
1409
|
+
const parked = path.join(parent, '.parkleft.skillsmith-removing-0123456789abcdef0123456789abcdef');
|
|
1410
|
+
await mkdir(parked);
|
|
1411
|
+
await writeFile(path.join(parked, 'part.md'), 'partial', 'utf-8');
|
|
1412
|
+
// A sibling skill's parked name is not this skill's leftover.
|
|
1413
|
+
await mkdir(path.join(parent, '.parkleft.x.skillsmith-removing-0123456789abcdef0123456789abcdef'));
|
|
1414
|
+
const refreshed = await addLink({
|
|
1415
|
+
skillId: 'parkleft',
|
|
1416
|
+
fromClient: 'claude-code',
|
|
1417
|
+
toClient: 'cursor',
|
|
1418
|
+
force: true,
|
|
1419
|
+
});
|
|
1420
|
+
expect(refreshed.warnings).toEqual([expect.stringContaining(parked)]);
|
|
1421
|
+
expect(refreshed.warnings?.[0]).toContain('an interrupted removal');
|
|
1422
|
+
expect(refreshed.warnings?.[0]).toContain('restore or delete it yourself');
|
|
1423
|
+
expect(await readFile(path.join(parked, 'part.md'), 'utf-8')).toBe('partial');
|
|
1424
|
+
});
|
|
1425
|
+
// Round 16 (cross-model review): the reason a superseded copy was kept
|
|
1426
|
+
// used to be dropped, leaving a later sweep to call it "an earlier copy".
|
|
1427
|
+
// Round 20 (cross-model review): publishing used a plain rename, which
|
|
1428
|
+
// replaces its destination, so an entry another program created after the
|
|
1429
|
+
// destination was checked could be destroyed. A directory is published by
|
|
1430
|
+
// claiming the name with `mkdir`, a symlink by `symlink` — both refuse.
|
|
1431
|
+
it('refuses to publish a copy over something that took the path', async () => {
|
|
1432
|
+
await seedSkill('claimrace');
|
|
1433
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'claimrace');
|
|
1434
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1435
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1436
|
+
let taken = false;
|
|
1437
|
+
const mkdir = vi.fn(async (p, opts) => {
|
|
1438
|
+
if (!taken && String(p) === toDir) {
|
|
1439
|
+
taken = true;
|
|
1440
|
+
await actual.mkdir(toDir, { recursive: true });
|
|
1441
|
+
await actual.writeFile(path.join(toDir, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1442
|
+
}
|
|
1443
|
+
return actual.mkdir(p, opts);
|
|
1444
|
+
});
|
|
1445
|
+
return { ...actual, default: { ...actual, mkdir }, mkdir };
|
|
1446
|
+
});
|
|
1447
|
+
try {
|
|
1448
|
+
vi.resetModules();
|
|
1449
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1450
|
+
await expect(addLink({ skillId: 'claimrace', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/EEXIST/);
|
|
1451
|
+
expect(await readFile(path.join(toDir, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1452
|
+
}
|
|
1453
|
+
finally {
|
|
1454
|
+
vi.doUnmock('node:fs/promises');
|
|
1455
|
+
vi.resetModules();
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
// Round 21 (Opus): `symlink` creates a new entry, so the identity has to be
|
|
1459
|
+
// read back — and something can replace the path in that instant. Reporting
|
|
1460
|
+
// that identity would let the undo delete another program's entry.
|
|
1461
|
+
it('never deletes what replaced a symlink it had just published', async () => {
|
|
1462
|
+
await seedSkill('symundo');
|
|
1463
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'symundo');
|
|
1464
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1465
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1466
|
+
let swapped = false;
|
|
1467
|
+
const symlink = vi.fn(async (target, p, type) => {
|
|
1468
|
+
const made = await actual.symlink(target, p, type);
|
|
1469
|
+
if (!swapped && String(p) === toDir) {
|
|
1470
|
+
swapped = true;
|
|
1471
|
+
await actual.unlink(toDir);
|
|
1472
|
+
await actual.mkdir(toDir);
|
|
1473
|
+
await actual.writeFile(path.join(toDir, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1474
|
+
}
|
|
1475
|
+
return made;
|
|
1476
|
+
});
|
|
1477
|
+
return { ...actual, default: { ...actual, symlink }, symlink };
|
|
1478
|
+
});
|
|
1479
|
+
mockManifestLockFailure();
|
|
1480
|
+
try {
|
|
1481
|
+
vi.resetModules();
|
|
1482
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1483
|
+
await expect(addLink({
|
|
1484
|
+
skillId: 'symundo',
|
|
1485
|
+
fromClient: 'claude-code',
|
|
1486
|
+
toClient: 'cursor',
|
|
1487
|
+
preferSymlink: true,
|
|
1488
|
+
})).rejects.toThrow(/could not identify/);
|
|
1489
|
+
expect(await readFile(path.join(toDir, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1490
|
+
}
|
|
1491
|
+
finally {
|
|
1492
|
+
vi.doUnmock('node:fs/promises');
|
|
1493
|
+
vi.doUnmock('../../src/install/fan-out.overwrite.js');
|
|
1494
|
+
vi.resetModules();
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
// Round 23 (Opus): the ADR now rests on the claim that this rename can only
|
|
1498
|
+
// ever replace an EMPTY directory, so both refusals are pinned here.
|
|
1499
|
+
it('refuses to publish when something is written into its claim', async () => {
|
|
1500
|
+
await seedSkill('claimfill');
|
|
1501
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'claimfill');
|
|
1502
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1503
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1504
|
+
let filled = false;
|
|
1505
|
+
const rename = vi.fn(async (from, to) => {
|
|
1506
|
+
if (!filled && String(to) === toDir && String(from).endsWith(`${path.sep}content`)) {
|
|
1507
|
+
filled = true;
|
|
1508
|
+
await actual.writeFile(path.join(toDir, 'THEIRS.md'), 'not ours', 'utf-8');
|
|
1509
|
+
}
|
|
1510
|
+
return actual.rename(from, to);
|
|
1511
|
+
});
|
|
1512
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1513
|
+
});
|
|
1514
|
+
try {
|
|
1515
|
+
vi.resetModules();
|
|
1516
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1517
|
+
await expect(addLink({ skillId: 'claimfill', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/ENOTEMPTY/);
|
|
1518
|
+
expect(await readFile(path.join(toDir, 'THEIRS.md'), 'utf-8')).toBe('not ours');
|
|
1519
|
+
}
|
|
1520
|
+
finally {
|
|
1521
|
+
vi.doUnmock('node:fs/promises');
|
|
1522
|
+
vi.resetModules();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
it('refuses to publish when a file takes the place of its claim', async () => {
|
|
1526
|
+
await seedSkill('claimfile');
|
|
1527
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'claimfile');
|
|
1528
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1529
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1530
|
+
let swapped = false;
|
|
1531
|
+
const rename = vi.fn(async (from, to) => {
|
|
1532
|
+
if (!swapped && String(to) === toDir && String(from).endsWith(`${path.sep}content`)) {
|
|
1533
|
+
swapped = true;
|
|
1534
|
+
await actual.rmdir(toDir);
|
|
1535
|
+
await actual.writeFile(toDir, 'not ours', 'utf-8');
|
|
1536
|
+
}
|
|
1537
|
+
return actual.rename(from, to);
|
|
1538
|
+
});
|
|
1539
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1540
|
+
});
|
|
1541
|
+
try {
|
|
1542
|
+
vi.resetModules();
|
|
1543
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1544
|
+
await expect(addLink({ skillId: 'claimfile', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/ENOTDIR/);
|
|
1545
|
+
expect(await readFile(toDir, 'utf-8')).toBe('not ours');
|
|
1546
|
+
}
|
|
1547
|
+
finally {
|
|
1548
|
+
vi.doUnmock('node:fs/promises');
|
|
1549
|
+
vi.resetModules();
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
// Round 24, kept and strengthened in round 25: a directory another program
|
|
1553
|
+
// substituted for this call's claim survives a failed publication. Round 24
|
|
1554
|
+
// achieved that with an identity check; round 25 removes nothing at all, and
|
|
1555
|
+
// this test holds either way — it fails the moment a cleanup is reintroduced.
|
|
1556
|
+
it('leaves an empty directory another program substituted for its claim', async () => {
|
|
1557
|
+
await seedSkill('claimswap');
|
|
1558
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'claimswap');
|
|
1559
|
+
const theirs = { ino: 0, claimIno: 0 };
|
|
1560
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1561
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1562
|
+
let swapped = false;
|
|
1563
|
+
const rename = vi.fn(async (from, to) => {
|
|
1564
|
+
if (!swapped && String(to) === toDir && String(from).endsWith(`${path.sep}content`)) {
|
|
1565
|
+
swapped = true;
|
|
1566
|
+
// Their own empty directory replaces the claim, and the publish
|
|
1567
|
+
// then fails: theirs must survive. It is made elsewhere and renamed
|
|
1568
|
+
// in, because a directory created at the path the claim just freed
|
|
1569
|
+
// can reuse the claim's own inode number (round 15), which would
|
|
1570
|
+
// have let this test pass against round 24's identity check.
|
|
1571
|
+
theirs.claimIno = Number((await actual.lstat(toDir)).ino);
|
|
1572
|
+
const theirDir = path.join(homeDir, 'their-claimswap');
|
|
1573
|
+
await actual.mkdir(theirDir);
|
|
1574
|
+
theirs.ino = Number((await actual.lstat(theirDir)).ino);
|
|
1575
|
+
await actual.rename(theirDir, toDir);
|
|
1576
|
+
throw Object.assign(new Error('EIO: simulated publish failure'), { code: 'EIO' });
|
|
1577
|
+
}
|
|
1578
|
+
return actual.rename(from, to);
|
|
1579
|
+
});
|
|
1580
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1581
|
+
});
|
|
1582
|
+
try {
|
|
1583
|
+
vi.resetModules();
|
|
1584
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1585
|
+
await expect(addLink({ skillId: 'claimswap', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/EIO/);
|
|
1586
|
+
expect(theirs.ino).not.toBe(theirs.claimIno);
|
|
1587
|
+
expect(Number((await lstat(toDir)).ino)).toBe(theirs.ino);
|
|
1588
|
+
}
|
|
1589
|
+
finally {
|
|
1590
|
+
vi.doUnmock('node:fs/promises');
|
|
1591
|
+
vi.resetModules();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
it('says when it could not look for leftovers', async () => {
|
|
1595
|
+
const parent = path.join(homeDir, 'unreadable-parent');
|
|
1596
|
+
await mkdir(parent, { recursive: true });
|
|
1597
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1598
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1599
|
+
const readdir = vi.fn(async (...args) => {
|
|
1600
|
+
if (String(args[0]) === parent) {
|
|
1601
|
+
throw Object.assign(new Error(`EACCES: permission denied, scandir '${parent}'`), {
|
|
1602
|
+
code: 'EACCES',
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
return actual.readdir(...args);
|
|
1606
|
+
});
|
|
1607
|
+
return { ...actual, default: { ...actual, readdir }, readdir };
|
|
1608
|
+
});
|
|
1609
|
+
try {
|
|
1610
|
+
vi.resetModules();
|
|
1611
|
+
const { listLeftoverBackups } = await import('../../src/install/fan-out.overwrite.js');
|
|
1612
|
+
const scan = await listLeftoverBackups(path.join(parent, 'somewhere'));
|
|
1613
|
+
// "Could not look" is not "nothing is there".
|
|
1614
|
+
expect(scan.folders).toEqual([]);
|
|
1615
|
+
expect(scan.unreadable).toContain('could not be listed (EACCES)');
|
|
1616
|
+
}
|
|
1617
|
+
finally {
|
|
1618
|
+
vi.doUnmock('node:fs/promises');
|
|
1619
|
+
vi.resetModules();
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
// Round 25 (cross-model review): round 24's identity check cannot hold —
|
|
1623
|
+
// `mkdir` and the `lstat` that records the identity are two syscalls, so
|
|
1624
|
+
// what it records may already be another program's directory, and the
|
|
1625
|
+
// check and the `rmdir` are two more. The claim is left and named instead.
|
|
1626
|
+
it('leaves the empty directory it claimed when publication fails, and names it', async () => {
|
|
1627
|
+
await seedSkill('claimkeep');
|
|
1628
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'claimkeep');
|
|
1629
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1630
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1631
|
+
let failed = false;
|
|
1632
|
+
const rename = vi.fn(async (from, to) => {
|
|
1633
|
+
if (!failed && String(to) === toDir && String(from).endsWith(`${path.sep}content`)) {
|
|
1634
|
+
failed = true;
|
|
1635
|
+
throw Object.assign(new Error('EIO: simulated publish failure'), { code: 'EIO' });
|
|
1636
|
+
}
|
|
1637
|
+
return actual.rename(from, to);
|
|
1638
|
+
});
|
|
1639
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1640
|
+
});
|
|
1641
|
+
try {
|
|
1642
|
+
vi.resetModules();
|
|
1643
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1644
|
+
const err = await addLink({
|
|
1645
|
+
skillId: 'claimkeep',
|
|
1646
|
+
fromClient: 'claude-code',
|
|
1647
|
+
toClient: 'cursor',
|
|
1648
|
+
}).catch((e) => e);
|
|
1649
|
+
expect(err.message).toMatch(/EIO/);
|
|
1650
|
+
expect(err.message).toContain(toDir);
|
|
1651
|
+
expect(err.message).toMatch(/left in place/);
|
|
1652
|
+
// Still there: nothing removes what it cannot identify.
|
|
1653
|
+
expect((await lstat(toDir)).isDirectory()).toBe(true);
|
|
1654
|
+
}
|
|
1655
|
+
finally {
|
|
1656
|
+
vi.doUnmock('node:fs/promises');
|
|
1657
|
+
vi.resetModules();
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
it('surfaces an unreadable scan through addLink, not only through the scan itself', async () => {
|
|
1661
|
+
await seedSkill('scanwarn');
|
|
1662
|
+
const parent = path.join(homeDir, '.cursor', 'skills');
|
|
1663
|
+
await mkdir(parent, { recursive: true });
|
|
1664
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1665
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1666
|
+
const readdir = vi.fn(async (...args) => {
|
|
1667
|
+
if (String(args[0]) === parent) {
|
|
1668
|
+
throw Object.assign(new Error(`EACCES: permission denied, scandir '${parent}'`), {
|
|
1669
|
+
code: 'EACCES',
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
return actual.readdir(...args);
|
|
1673
|
+
});
|
|
1674
|
+
return { ...actual, default: { ...actual, readdir }, readdir };
|
|
1675
|
+
});
|
|
1676
|
+
try {
|
|
1677
|
+
vi.resetModules();
|
|
1678
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1679
|
+
const result = await addLink({
|
|
1680
|
+
skillId: 'scanwarn',
|
|
1681
|
+
fromClient: 'claude-code',
|
|
1682
|
+
toClient: 'cursor',
|
|
1683
|
+
});
|
|
1684
|
+
// Round 25: a direct call to the scan proves nothing about whether its
|
|
1685
|
+
// caller passes the message on. Both scans run here, so both report.
|
|
1686
|
+
const unreadable = (result.warnings ?? []).filter((w) => w.includes('could not be listed'));
|
|
1687
|
+
expect(unreadable).toHaveLength(2);
|
|
1688
|
+
expect(unreadable.join('\n')).toContain('(EACCES)');
|
|
1689
|
+
}
|
|
1690
|
+
finally {
|
|
1691
|
+
vi.doUnmock('node:fs/promises');
|
|
1692
|
+
vi.resetModules();
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
it('reports a leftover backup it could not inspect, instead of passing over it', async () => {
|
|
1696
|
+
await seedSkill('inspectfail');
|
|
1697
|
+
const parent = path.join(homeDir, '.cursor', 'skills');
|
|
1698
|
+
const backup = path.join(parent, '.inspectfail.skillsmith-backup-abc123');
|
|
1699
|
+
const probed = path.join(backup, 'original');
|
|
1700
|
+
await mkdir(probed, { recursive: true });
|
|
1701
|
+
await writeFile(path.join(probed, 'SKILL.md'), '# older copy\n', 'utf-8');
|
|
1702
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1703
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1704
|
+
const lstat = vi.fn(async (p, ...rest) => {
|
|
1705
|
+
if (String(p) === probed) {
|
|
1706
|
+
throw Object.assign(new Error(`EACCES: permission denied, lstat '${probed}'`), {
|
|
1707
|
+
code: 'EACCES',
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
return actual.lstat(p, ...rest);
|
|
1711
|
+
});
|
|
1712
|
+
return { ...actual, default: { ...actual, lstat }, lstat };
|
|
1713
|
+
});
|
|
1714
|
+
try {
|
|
1715
|
+
vi.resetModules();
|
|
1716
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1717
|
+
const result = await addLink({
|
|
1718
|
+
skillId: 'inspectfail',
|
|
1719
|
+
fromClient: 'claude-code',
|
|
1720
|
+
toClient: 'cursor',
|
|
1721
|
+
});
|
|
1722
|
+
// "Could not inspect" is not "not a candidate".
|
|
1723
|
+
expect((result.warnings ?? []).join('\n')).toMatch(/could not be inspected \(EACCES\)/);
|
|
1724
|
+
}
|
|
1725
|
+
finally {
|
|
1726
|
+
vi.doUnmock('node:fs/promises');
|
|
1727
|
+
vi.resetModules();
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
it('says the source could not be checked, not that it is missing', async () => {
|
|
1731
|
+
await seedSkill('srcunreadable');
|
|
1732
|
+
const fromDir = path.join(homeDir, '.claude', 'skills', 'srcunreadable');
|
|
1733
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1734
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1735
|
+
const access = vi.fn(async (p, mode) => {
|
|
1736
|
+
if (String(p) === fromDir) {
|
|
1737
|
+
throw Object.assign(new Error(`EACCES: permission denied, access '${fromDir}'`), {
|
|
1738
|
+
code: 'EACCES',
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
return actual.access(p, mode);
|
|
1742
|
+
});
|
|
1743
|
+
return { ...actual, default: { ...actual, access }, access };
|
|
1744
|
+
});
|
|
1745
|
+
try {
|
|
1746
|
+
vi.resetModules();
|
|
1747
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1748
|
+
// Round 27: this said the skill "does not exist", sending the user to
|
|
1749
|
+
// reinstall something that is sitting right there.
|
|
1750
|
+
await expect(addLink({ skillId: 'srcunreadable', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/could not be checked \(EACCES\)/);
|
|
1751
|
+
}
|
|
1752
|
+
finally {
|
|
1753
|
+
vi.doUnmock('node:fs/promises');
|
|
1754
|
+
vi.resetModules();
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
it('says the destination could not be checked, not that it was replaced', async () => {
|
|
1758
|
+
const { addLink } = await loadModule();
|
|
1759
|
+
await seedSkill('destunreadable');
|
|
1760
|
+
await addLink({ skillId: 'destunreadable', fromClient: 'claude-code', toClient: 'cursor' });
|
|
1761
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'destunreadable');
|
|
1762
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1763
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1764
|
+
// Arm only once the backup folder exists, so the failing lstat is the
|
|
1765
|
+
// pre-move check and not addLink's own earlier existence check.
|
|
1766
|
+
const armed = { on: false };
|
|
1767
|
+
const mkdtemp = vi.fn(async (prefix) => {
|
|
1768
|
+
const made = await actual.mkdtemp(prefix);
|
|
1769
|
+
if (String(prefix).includes('skillsmith-backup-'))
|
|
1770
|
+
armed.on = true;
|
|
1771
|
+
return made;
|
|
1772
|
+
});
|
|
1773
|
+
const lstat = vi.fn(async (p, ...rest) => {
|
|
1774
|
+
if (armed.on && String(p) === toDir) {
|
|
1775
|
+
armed.on = false;
|
|
1776
|
+
throw Object.assign(new Error(`EACCES: permission denied, lstat '${toDir}'`), {
|
|
1777
|
+
code: 'EACCES',
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
return actual.lstat(p, ...rest);
|
|
1781
|
+
});
|
|
1782
|
+
return { ...actual, default: { ...actual, lstat, mkdtemp }, lstat, mkdtemp };
|
|
1783
|
+
});
|
|
1784
|
+
try {
|
|
1785
|
+
vi.resetModules();
|
|
1786
|
+
const { addLink: addLinkMocked } = await import('../../src/install/fan-out.js');
|
|
1787
|
+
await expect(addLinkMocked({
|
|
1788
|
+
skillId: 'destunreadable',
|
|
1789
|
+
fromClient: 'claude-code',
|
|
1790
|
+
toClient: 'cursor',
|
|
1791
|
+
force: true,
|
|
1792
|
+
})).rejects.toThrow(/could not be checked \(EACCES\)/);
|
|
1793
|
+
// The copy is untouched: the refusal still fails closed.
|
|
1794
|
+
expect((await lstat(toDir)).isDirectory()).toBe(true);
|
|
1795
|
+
}
|
|
1796
|
+
finally {
|
|
1797
|
+
vi.doUnmock('node:fs/promises');
|
|
1798
|
+
vi.resetModules();
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
it('reports a case-variant backup it could not inspect', async () => {
|
|
1802
|
+
const parent = path.join(homeDir, 'cv-unreadable');
|
|
1803
|
+
const variant = path.join(parent, '.CVX.skillsmith-backup-AbC123');
|
|
1804
|
+
await mkdir(variant, { recursive: true });
|
|
1805
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1806
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1807
|
+
const lstat = vi.fn(async (p, ...rest) => {
|
|
1808
|
+
if (String(p) === variant) {
|
|
1809
|
+
throw Object.assign(new Error(`EACCES: permission denied, lstat '${variant}'`), {
|
|
1810
|
+
code: 'EACCES',
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
return actual.lstat(p, ...rest);
|
|
1814
|
+
});
|
|
1815
|
+
return { ...actual, default: { ...actual, lstat }, lstat };
|
|
1816
|
+
});
|
|
1817
|
+
try {
|
|
1818
|
+
vi.resetModules();
|
|
1819
|
+
const { listLeftoverBackups } = await import('../../src/install/fan-out.overwrite.js');
|
|
1820
|
+
const scan = await listLeftoverBackups(path.join(parent, 'cvx'));
|
|
1821
|
+
// Round 25: this answered "not a variant" and reported nothing at all.
|
|
1822
|
+
expect(scan.folders).toEqual([]);
|
|
1823
|
+
expect(scan.unreadable).toContain('could not be inspected (EACCES)');
|
|
1824
|
+
}
|
|
1825
|
+
finally {
|
|
1826
|
+
vi.doUnmock('node:fs/promises');
|
|
1827
|
+
vi.resetModules();
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
it('explains an empty destination on the force path too', async () => {
|
|
1831
|
+
const { addLink } = await loadModule();
|
|
1832
|
+
await seedSkill('forceempty');
|
|
1833
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'forceempty');
|
|
1834
|
+
await mkdir(toDir, { recursive: true });
|
|
1835
|
+
await expect(addLink({
|
|
1836
|
+
skillId: 'forceempty',
|
|
1837
|
+
fromClient: 'claude-code',
|
|
1838
|
+
toClient: 'cursor',
|
|
1839
|
+
force: true,
|
|
1840
|
+
})).rejects.toThrow(/not a fan-out destination Skillsmith recorded[\s\S]*empty directory/);
|
|
1841
|
+
expect((await lstat(toDir)).isDirectory()).toBe(true);
|
|
1842
|
+
});
|
|
1843
|
+
it('does not call a leftover backup this skill own copy', async () => {
|
|
1844
|
+
const { leftoverBackupWarning } = await import('../../src/install/fan-out.leftovers.js');
|
|
1845
|
+
const text = leftoverBackupWarning('/x/.foo.skillsmith-backup-abc123');
|
|
1846
|
+
expect(text).toContain('either what this skill replaced, or something another program put');
|
|
1847
|
+
expect(text).not.toContain('an earlier copy');
|
|
1848
|
+
});
|
|
1849
|
+
// Round 22 (cross-model review): an empty directory the user made looks
|
|
1850
|
+
// exactly like one a crashed claim left, so Skillsmith refuses instead of
|
|
1851
|
+
// removing it, and says what it probably is.
|
|
1852
|
+
it('refuses an empty destination rather than removing what it cannot identify', async () => {
|
|
1853
|
+
const { addLink } = await loadModule();
|
|
1854
|
+
await seedSkill('emptyclaim');
|
|
1855
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'emptyclaim');
|
|
1856
|
+
await mkdir(toDir, { recursive: true });
|
|
1857
|
+
await expect(addLink({ skillId: 'emptyclaim', fromClient: 'claude-code', toClient: 'cursor' })).rejects.toThrow(/already exists.*interrupted install may have left it/s);
|
|
1858
|
+
expect((await lstat(toDir)).isDirectory()).toBe(true);
|
|
1859
|
+
});
|
|
1860
|
+
it('refuses to move aside a copy that was replaced before the refresh got to it', async () => {
|
|
1861
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
1862
|
+
await seedSkill('moveaside');
|
|
1863
|
+
const { record } = await setupAddLink({
|
|
1864
|
+
skillId: 'moveaside',
|
|
1865
|
+
fromClient: 'claude-code',
|
|
1866
|
+
toClient: 'cursor',
|
|
1867
|
+
});
|
|
1868
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1869
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1870
|
+
let swapped = false;
|
|
1871
|
+
const mkdtemp = vi.fn(async (prefix, opts) => {
|
|
1872
|
+
const made = await actual.mkdtemp(prefix, opts);
|
|
1873
|
+
// The backup folder exists; another program replaces the copy
|
|
1874
|
+
// before this refresh can move it aside.
|
|
1875
|
+
if (!swapped && String(prefix).includes('.moveaside.skillsmith-backup-')) {
|
|
1876
|
+
swapped = true;
|
|
1877
|
+
await actual.rename(record.to, `${record.to}-moved`);
|
|
1878
|
+
await actual.mkdir(record.to);
|
|
1879
|
+
await actual.writeFile(path.join(record.to, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1880
|
+
}
|
|
1881
|
+
return made;
|
|
1882
|
+
});
|
|
1883
|
+
return { ...actual, default: { ...actual, mkdtemp }, mkdtemp };
|
|
1884
|
+
});
|
|
1885
|
+
try {
|
|
1886
|
+
vi.resetModules();
|
|
1887
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1888
|
+
await expect(addLink({
|
|
1889
|
+
skillId: 'moveaside',
|
|
1890
|
+
fromClient: 'claude-code',
|
|
1891
|
+
toClient: 'cursor',
|
|
1892
|
+
force: true,
|
|
1893
|
+
})).rejects.toThrow(/was replaced by something else before this refresh could move it aside/);
|
|
1894
|
+
// Their folder is untouched, and ours is where their swap put it.
|
|
1895
|
+
expect(await readFile(path.join(record.to, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
1896
|
+
expect(await readFile(path.join(`${record.to}-moved`, 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
1897
|
+
}
|
|
1898
|
+
finally {
|
|
1899
|
+
vi.doUnmock('node:fs/promises');
|
|
1900
|
+
vi.resetModules();
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
it('refuses to publish a symlink over a file that took the path', async () => {
|
|
1904
|
+
await seedSkill('symclaim');
|
|
1905
|
+
const toDir = path.join(homeDir, '.cursor', 'skills', 'symclaim');
|
|
1906
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1907
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1908
|
+
let taken = false;
|
|
1909
|
+
const symlink = vi.fn(async (target, p, type) => {
|
|
1910
|
+
if (!taken && String(p) === toDir) {
|
|
1911
|
+
taken = true;
|
|
1912
|
+
await actual.mkdir(path.dirname(toDir), { recursive: true });
|
|
1913
|
+
await actual.writeFile(toDir, 'not ours', 'utf-8');
|
|
1914
|
+
}
|
|
1915
|
+
return actual.symlink(target, p, type);
|
|
1916
|
+
});
|
|
1917
|
+
return { ...actual, default: { ...actual, symlink }, symlink };
|
|
1918
|
+
});
|
|
1919
|
+
try {
|
|
1920
|
+
vi.resetModules();
|
|
1921
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1922
|
+
await expect(addLink({
|
|
1923
|
+
skillId: 'symclaim',
|
|
1924
|
+
fromClient: 'claude-code',
|
|
1925
|
+
toClient: 'cursor',
|
|
1926
|
+
preferSymlink: true,
|
|
1927
|
+
})).rejects.toThrow(/EEXIST/);
|
|
1928
|
+
expect(await readFile(toDir, 'utf-8')).toBe('not ours');
|
|
1929
|
+
}
|
|
1930
|
+
finally {
|
|
1931
|
+
vi.doUnmock('node:fs/promises');
|
|
1932
|
+
vi.resetModules();
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
// Round 19 (Opus): the symlink branch used to `unlink` the destination two
|
|
1936
|
+
// syscalls after the check that said "symlink".
|
|
1937
|
+
it('refuses when a file takes the path of the symlink it was replacing', async () => {
|
|
1938
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
1939
|
+
await seedSkill('symswap');
|
|
1940
|
+
const { record } = await setupAddLink({
|
|
1941
|
+
skillId: 'symswap',
|
|
1942
|
+
fromClient: 'claude-code',
|
|
1943
|
+
toClient: 'cursor',
|
|
1944
|
+
preferSymlink: true,
|
|
1945
|
+
});
|
|
1946
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1947
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1948
|
+
let swapped = false;
|
|
1949
|
+
const rename = vi.fn(async (from, to) => {
|
|
1950
|
+
if (!swapped && String(from) === record.to) {
|
|
1951
|
+
swapped = true;
|
|
1952
|
+
// Written elsewhere and renamed in, so it is a distinct inode.
|
|
1953
|
+
const theirs = `${record.to}.theirs`;
|
|
1954
|
+
await actual.writeFile(theirs, 'not ours', 'utf-8');
|
|
1955
|
+
await actual.rename(theirs, record.to);
|
|
1956
|
+
}
|
|
1957
|
+
return actual.rename(from, to);
|
|
1958
|
+
});
|
|
1959
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1960
|
+
});
|
|
1961
|
+
try {
|
|
1962
|
+
vi.resetModules();
|
|
1963
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
1964
|
+
await expect(addLink({
|
|
1965
|
+
skillId: 'symswap',
|
|
1966
|
+
fromClient: 'claude-code',
|
|
1967
|
+
toClient: 'cursor',
|
|
1968
|
+
preferSymlink: true,
|
|
1969
|
+
force: true,
|
|
1970
|
+
})).rejects.toThrow(/was replaced by something else/);
|
|
1971
|
+
expect(await readFile(record.to, 'utf-8')).toBe('not ours');
|
|
1972
|
+
}
|
|
1973
|
+
finally {
|
|
1974
|
+
vi.doUnmock('node:fs/promises');
|
|
1975
|
+
vi.resetModules();
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
it('leaves the copy it replaced in its backup folder when something takes the path', async () => {
|
|
1979
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
1980
|
+
await seedSkill('restoreswap');
|
|
1981
|
+
const { record } = await setupAddLink({
|
|
1982
|
+
skillId: 'restoreswap',
|
|
1983
|
+
fromClient: 'claude-code',
|
|
1984
|
+
toClient: 'cursor',
|
|
1985
|
+
});
|
|
1986
|
+
vi.doMock('node:fs/promises', async () => {
|
|
1987
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
1988
|
+
const rename = vi.fn(async (from, to) => {
|
|
1989
|
+
const moved = await actual.rename(from, to);
|
|
1990
|
+
// The copy being replaced has just been moved aside; another program
|
|
1991
|
+
// takes the path before the new one can be published.
|
|
1992
|
+
if (String(from) === record.to && String(to).endsWith(`${path.sep}original`)) {
|
|
1993
|
+
await actual.mkdir(record.to);
|
|
1994
|
+
await actual.writeFile(path.join(record.to, 'KEEP.md'), 'not ours', 'utf-8');
|
|
1995
|
+
}
|
|
1996
|
+
return moved;
|
|
1997
|
+
});
|
|
1998
|
+
return { ...actual, default: { ...actual, rename }, rename };
|
|
1999
|
+
});
|
|
2000
|
+
try {
|
|
2001
|
+
vi.resetModules();
|
|
2002
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
2003
|
+
await expect(addLink({
|
|
2004
|
+
skillId: 'restoreswap',
|
|
2005
|
+
fromClient: 'claude-code',
|
|
2006
|
+
toClient: 'cursor',
|
|
2007
|
+
force: true,
|
|
2008
|
+
})).rejects.toThrow(/something else is at .* now, so the original was left at /);
|
|
2009
|
+
expect(await readFile(path.join(record.to, 'KEEP.md'), 'utf-8')).toBe('not ours');
|
|
2010
|
+
const backup = (await readdir(path.dirname(record.to))).find((n) => n.startsWith('.restoreswap.skillsmith-backup-'));
|
|
2011
|
+
expect(backup).toBeDefined();
|
|
2012
|
+
expect(await readFile(path.join(path.dirname(record.to), backup ?? '', 'original', 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
2013
|
+
}
|
|
2014
|
+
finally {
|
|
2015
|
+
vi.doUnmock('node:fs/promises');
|
|
2016
|
+
vi.resetModules();
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
it('says why the copy a refresh replaced was kept', async () => {
|
|
2020
|
+
const { addLink: setupAddLink } = await loadModule();
|
|
2021
|
+
await seedSkill('keptbackup');
|
|
2022
|
+
const { record } = await setupAddLink({
|
|
2023
|
+
skillId: 'keptbackup',
|
|
2024
|
+
fromClient: 'claude-code',
|
|
2025
|
+
toClient: 'cursor',
|
|
2026
|
+
});
|
|
2027
|
+
vi.doMock('node:fs/promises', async () => {
|
|
2028
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
2029
|
+
const rm = vi.fn(async (...args) => {
|
|
2030
|
+
const parkedBackup = /\.skillsmith-backup-[A-Za-z0-9]{6}\.skillsmith-removing-[0-9a-f]{32}$/;
|
|
2031
|
+
if (parkedBackup.test(String(args[0]))) {
|
|
2032
|
+
throw Object.assign(new Error(`EACCES: permission denied, rm '${String(args[0])}'`), {
|
|
2033
|
+
code: 'EACCES',
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
return actual.rm(...args);
|
|
2037
|
+
});
|
|
2038
|
+
return { ...actual, default: { ...actual, rm }, rm };
|
|
2039
|
+
});
|
|
2040
|
+
try {
|
|
2041
|
+
vi.resetModules();
|
|
2042
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
2043
|
+
const refreshed = await addLink({
|
|
2044
|
+
skillId: 'keptbackup',
|
|
2045
|
+
fromClient: 'claude-code',
|
|
2046
|
+
toClient: 'cursor',
|
|
2047
|
+
force: true,
|
|
2048
|
+
});
|
|
2049
|
+
expect(refreshed.warnings).toEqual(expect.arrayContaining([
|
|
2050
|
+
expect.stringContaining('the copy this refresh replaced was kept'),
|
|
2051
|
+
]));
|
|
2052
|
+
expect(refreshed.warnings?.join(' ')).toContain('EACCES');
|
|
2053
|
+
expect(await readFile(path.join(record.to, 'SKILL.md'), 'utf-8')).toBe('# test\n');
|
|
2054
|
+
}
|
|
2055
|
+
finally {
|
|
2056
|
+
vi.doUnmock('node:fs/promises');
|
|
2057
|
+
vi.resetModules();
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
// SMI-6529 N7 (round 4): `--force --also-link` must be able to refresh a
|
|
2062
|
+
// COPY Skillsmith itself recorded (the default fan-out kind) — round-2's
|
|
2063
|
+
// H3 fix incorrectly refused this too, treating every non-symlink `toDir`
|
|
2064
|
+
// as unrecorded. A recorded copy with no `.git` is now replaceable: since
|
|
2065
|
+
// round 6 the new copy is written to a staging folder and swapped into
|
|
2066
|
+
// place, never written over the destination or recursively deleted.
|
|
2067
|
+
describe('N7: force-overwrite of a RECORDED copy', () => {
|
|
2068
|
+
it('replaces a recorded copy (no .git) with fresh content, replacing (not duplicating) its manifest record', async () => {
|
|
2069
|
+
const { addLink, listLinks } = await loadModule();
|
|
2070
|
+
await seedSkill('refreshme', { 'SKILL.md': '# v1\n' });
|
|
2071
|
+
const first = await addLink({
|
|
2072
|
+
skillId: 'refreshme',
|
|
2073
|
+
fromClient: 'claude-code',
|
|
2074
|
+
toClient: 'cursor',
|
|
2075
|
+
});
|
|
2076
|
+
expect(first.record.kind).toBe('copy');
|
|
2077
|
+
// The canonical source moves on to a new version.
|
|
2078
|
+
await writeFile(path.join(homeDir, '.claude', 'skills', 'refreshme', 'SKILL.md'), '# v2\n', 'utf-8');
|
|
2079
|
+
const second = await addLink({
|
|
2080
|
+
skillId: 'refreshme',
|
|
2081
|
+
fromClient: 'claude-code',
|
|
2082
|
+
toClient: 'cursor',
|
|
2083
|
+
force: true,
|
|
2084
|
+
});
|
|
2085
|
+
expect(second.record.kind).toBe('copy');
|
|
2086
|
+
const dest = path.join(homeDir, '.cursor', 'skills', 'refreshme');
|
|
2087
|
+
expect(await readFile(path.join(dest, 'SKILL.md'), 'utf-8')).toBe('# v2\n');
|
|
2088
|
+
// Manifest carries exactly ONE record for this destination — the
|
|
2089
|
+
// force-overwrite REPLACED it, never appended a duplicate.
|
|
2090
|
+
const listed = await listLinks('refreshme');
|
|
2091
|
+
expect(listed).toHaveLength(1);
|
|
2092
|
+
expect(listed[0]?.to).toBe(dest);
|
|
2093
|
+
});
|
|
2094
|
+
it('refuses to overwrite a recorded copy that has since grown a .git directory (does not delete it)', async () => {
|
|
2095
|
+
const { addLink } = await loadModule();
|
|
2096
|
+
await seedSkill('recordedthengit');
|
|
2097
|
+
const { record } = await addLink({
|
|
2098
|
+
skillId: 'recordedthengit',
|
|
2099
|
+
fromClient: 'claude-code',
|
|
2100
|
+
toClient: 'cursor',
|
|
2101
|
+
});
|
|
2102
|
+
// The recorded copy later becomes a real git working tree.
|
|
2103
|
+
await mkdir(path.join(record.to, '.git'), { recursive: true });
|
|
2104
|
+
await writeFile(path.join(record.to, 'my-work.md'), 'do not delete me\n', 'utf-8');
|
|
2105
|
+
await expect(addLink({
|
|
2106
|
+
skillId: 'recordedthengit',
|
|
2107
|
+
fromClient: 'claude-code',
|
|
2108
|
+
toClient: 'cursor',
|
|
2109
|
+
force: true,
|
|
2110
|
+
})).rejects.toThrow(/recorded Skillsmith fan-out copy but now contains a \.git directory/);
|
|
2111
|
+
// Untouched.
|
|
2112
|
+
await expect(stat(path.join(record.to, '.git'))).resolves.toBeDefined();
|
|
2113
|
+
expect(await readFile(path.join(record.to, 'my-work.md'), 'utf-8')).toBe('do not delete me\n');
|
|
2114
|
+
});
|
|
2115
|
+
// SMI-6529 N7: a failure during the write phase (AFTER the recorded
|
|
2116
|
+
// copy was renamed aside) must restore the original content — never a
|
|
2117
|
+
// delete-then-write that could strand the user with neither the old NOR
|
|
2118
|
+
// the new copy. Mocks `node:fs/promises`' `copyFile` (module-scoped to
|
|
2119
|
+
// just this test, mirroring the existing "falls back to copy on EPERM"
|
|
2120
|
+
// test's own `vi.doMock('node:fs/promises', ...)` precedent above) to
|
|
2121
|
+
// fail partway through the replacement copy.
|
|
2122
|
+
it('restores the original recorded copy if the replacement write itself fails', async () => {
|
|
2123
|
+
const { addLink: addLinkForSetup } = await loadModule();
|
|
2124
|
+
await seedSkill('restoreonfail', { 'SKILL.md': '# original\n' });
|
|
2125
|
+
const first = await addLinkForSetup({
|
|
2126
|
+
skillId: 'restoreonfail',
|
|
2127
|
+
fromClient: 'claude-code',
|
|
2128
|
+
toClient: 'cursor',
|
|
2129
|
+
});
|
|
2130
|
+
expect(first.record.kind).toBe('copy');
|
|
2131
|
+
const dest = first.record.to;
|
|
2132
|
+
vi.doMock('node:fs/promises', async () => {
|
|
2133
|
+
const actual = await vi.importActual('node:fs/promises');
|
|
2134
|
+
return {
|
|
2135
|
+
...actual,
|
|
2136
|
+
default: actual,
|
|
2137
|
+
copyFile: vi.fn(async (src, destPath) => {
|
|
2138
|
+
if (destPath.toString().endsWith('SKILL.md')) {
|
|
2139
|
+
throw new Error('Simulated ENOSPC during replacement copy');
|
|
2140
|
+
}
|
|
2141
|
+
return actual.copyFile(src, destPath);
|
|
2142
|
+
}),
|
|
2143
|
+
};
|
|
2144
|
+
});
|
|
2145
|
+
try {
|
|
2146
|
+
// `fan-out.js` was already imported (and cached) above via
|
|
2147
|
+
// `loadModule()` for the setup call — without an explicit
|
|
2148
|
+
// `vi.resetModules()` here, this second `import()` would return
|
|
2149
|
+
// that SAME cached instance, bound to the real (unmocked)
|
|
2150
|
+
// `node:fs/promises`, and the doMock above would never take
|
|
2151
|
+
// effect. Mirrors the established EPERM-fallback test's precedent,
|
|
2152
|
+
// extended for the two-phase (real setup, then mocked) shape this
|
|
2153
|
+
// test needs.
|
|
2154
|
+
vi.resetModules();
|
|
2155
|
+
const { addLink } = await import('../../src/install/fan-out.js');
|
|
2156
|
+
await expect(addLink({
|
|
2157
|
+
skillId: 'restoreonfail',
|
|
2158
|
+
fromClient: 'claude-code',
|
|
2159
|
+
toClient: 'cursor',
|
|
2160
|
+
force: true,
|
|
2161
|
+
})).rejects.toThrow(/Simulated ENOSPC/);
|
|
2162
|
+
// The ORIGINAL recorded copy is still in place at the original path —
|
|
2163
|
+
// never left gone, never left as a half-written new copy.
|
|
2164
|
+
expect(await readFile(path.join(dest, 'SKILL.md'), 'utf-8')).toBe('# original\n');
|
|
2165
|
+
// Round 6: the failed write left no staging or backup folder behind.
|
|
2166
|
+
const leftovers = (await readdir(path.dirname(dest))).filter((name) => name.includes('skillsmith-backup-') || name.includes('skillsmith-staging-'));
|
|
2167
|
+
expect(leftovers).toEqual([]);
|
|
2168
|
+
}
|
|
2169
|
+
finally {
|
|
2170
|
+
vi.doUnmock('node:fs/promises');
|
|
2171
|
+
vi.resetModules();
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
236
2174
|
});
|
|
237
2175
|
// SMI-6343 Wave 1 follow-up (adversarial review): saveManifest() here
|
|
238
2176
|
// writes `~/.skillsmith/links/manifest.json` — homedir-derived with no
|