dsh-plugin-capabilities 0.3.4 → 0.3.5
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/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +88 -51
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/repos.test.ts +5 -2
- package/src/repos.ts +6 -5
- package/src/rmtree.test.ts +26 -0
- package/src/rmtree.ts +46 -0
- package/src/routes.ts +8 -2
- package/src/state.test.ts +8 -3
- package/src/state.ts +12 -8
package/package.json
CHANGED
package/src/repos.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
6
6
|
import { addGitRepo, addLocalRepo, detectSkillRoots, parseGitHubSource } from './repos.ts'
|
|
7
7
|
import { loadState, removeSkillRoot } from './state.ts'
|
|
8
|
+
import { removeTree } from './rmtree.ts'
|
|
8
9
|
|
|
9
10
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-repos-'))
|
|
10
11
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
@@ -129,8 +130,10 @@ describe('addLocalRepo', () => {
|
|
|
129
130
|
expect(children).toHaveLength(1)
|
|
130
131
|
expect(readFileSync(join(entry.roots[0], children[0], 'SKILL.md'), 'utf8')).toContain('solo')
|
|
131
132
|
|
|
132
|
-
// Removing the entry
|
|
133
|
-
|
|
133
|
+
// Removing the entry deregisters; the caller then deletes the material
|
|
134
|
+
// dir (the junction wrapper) — never the linked source.
|
|
135
|
+
expect(removeSkillRoot(entry.id, home)).toMatchObject({ id: entry.id })
|
|
136
|
+
removeTree(entry.roots[0])
|
|
134
137
|
expect(existsSync(entry.roots[0])).toBe(false)
|
|
135
138
|
expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
|
|
136
139
|
})
|
package/src/repos.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* provider.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync,
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync } from 'node:fs'
|
|
12
12
|
import { basename, join, resolve } from 'node:path'
|
|
13
|
+
import { removeTree } from './rmtree.ts'
|
|
13
14
|
import { addSkillRoot, findRootByUrl, materialDirFor, newEntryId, type SkillRootEntry } from './state.ts'
|
|
14
15
|
import { extractTarGz } from './tar.ts'
|
|
15
16
|
|
|
@@ -141,12 +142,12 @@ export async function addLocalRepo(path: string, dshHome?: string): Promise<Skil
|
|
|
141
142
|
// the path in a dedicated directory holding a single link to it.
|
|
142
143
|
const id = newEntryId('local')
|
|
143
144
|
const material = materialDirFor(id, dshHome)
|
|
144
|
-
|
|
145
|
+
removeTree(material)
|
|
145
146
|
mkdirSync(material, { recursive: true })
|
|
146
147
|
try {
|
|
147
148
|
symlinkSync(resolved, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
148
149
|
} catch {
|
|
149
|
-
|
|
150
|
+
removeTree(material)
|
|
150
151
|
throw new Error('single-skill local folders need a directory link; try adding their parent folder instead')
|
|
151
152
|
}
|
|
152
153
|
return addSkillRoot({ id, kind: 'local', label: basename(resolved), path: resolved, roots: [material], materialDir: material }, dshHome)
|
|
@@ -175,7 +176,7 @@ export async function addGitRepo(
|
|
|
175
176
|
|
|
176
177
|
const id = newEntryId('git')
|
|
177
178
|
const material = materialDirFor(id, options.dshHome)
|
|
178
|
-
|
|
179
|
+
removeTree(material)
|
|
179
180
|
const checkout = join(material, 'repo')
|
|
180
181
|
try {
|
|
181
182
|
mkdirSync(checkout, { recursive: true })
|
|
@@ -192,7 +193,7 @@ export async function addGitRepo(
|
|
|
192
193
|
options.dshHome,
|
|
193
194
|
)
|
|
194
195
|
} catch (error) {
|
|
195
|
-
|
|
196
|
+
removeTree(material)
|
|
196
197
|
throw error
|
|
197
198
|
}
|
|
198
199
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import { removeTree } from './rmtree.ts'
|
|
6
|
+
|
|
7
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-rmtree-'))
|
|
8
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
9
|
+
|
|
10
|
+
describe('removeTree', () => {
|
|
11
|
+
it('deletes a tree holding read-only files and folders', () => {
|
|
12
|
+
const dir = join(root, 'readonly')
|
|
13
|
+
mkdirSync(join(dir, 'nested'), { recursive: true })
|
|
14
|
+
writeFileSync(join(dir, 'nested', 'SKILL.md'), '---\nname: ro\n---\n')
|
|
15
|
+
// Read-only entries are the Windows EPERM source: plain rmSync cannot
|
|
16
|
+
// unlink them, removeTree clears the attribute before removing.
|
|
17
|
+
chmodSync(join(dir, 'nested', 'SKILL.md'), 0o444)
|
|
18
|
+
chmodSync(join(dir, 'nested'), 0o555)
|
|
19
|
+
removeTree(dir)
|
|
20
|
+
expect(existsSync(dir)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('treats a missing path as done (force semantics)', () => {
|
|
24
|
+
expect(() => removeTree(join(root, 'never-existed'))).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/rmtree.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows-hardened recursive delete for repository material. The skill
|
|
3
|
+
* provider watches these trees live, and antivirus or search indexers
|
|
4
|
+
* briefly hold handles onto freshly written files, so a plain rmSync
|
|
5
|
+
* races open handles and dies with EPERM. Two mitigations: clear the
|
|
6
|
+
* read-only attribute first (unlinking a read-only entry fails on
|
|
7
|
+
* Windows), then retry the removal so short-lived locks run out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, readdirSync, rmSync, type Dirent } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** Windows lock holders usually let go within a second or two. */
|
|
14
|
+
const RETRIES = { maxRetries: 10, retryDelay: 200 } as const
|
|
15
|
+
|
|
16
|
+
function clearReadOnly(dir: string): void {
|
|
17
|
+
let entries: Dirent[]
|
|
18
|
+
try {
|
|
19
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
20
|
+
} catch {
|
|
21
|
+
return // unreadable: rmSync will surface the real error
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const child = join(dir, entry.name)
|
|
25
|
+
// Links read as neither file nor directory here; chmod would hit the
|
|
26
|
+
// link TARGET (possibly the user's own folder), so they are skipped —
|
|
27
|
+
// rmSync removes the link itself without following it.
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
try { chmodSync(child, 0o777) } catch { /* racing delete */ }
|
|
30
|
+
clearReadOnly(child)
|
|
31
|
+
} else if (entry.isFile()) {
|
|
32
|
+
try { chmodSync(child, 0o666) } catch { /* racing delete */ }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try { chmodSync(dir, 0o777) } catch { /* racing delete */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Delete one directory tree (or lone file), tolerating Windows lock races. */
|
|
39
|
+
export function removeTree(path: string): void {
|
|
40
|
+
try {
|
|
41
|
+
clearReadOnly(path)
|
|
42
|
+
} catch {
|
|
43
|
+
// walk failure: rmSync reports the underlying error anyway
|
|
44
|
+
}
|
|
45
|
+
rmSync(path, { recursive: true, force: true, ...RETRIES })
|
|
46
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { addGitRepo, addLocalRepo, rootExists } from './repos.ts'
|
|
|
11
11
|
import { dshLaunch, restartOwnedByShell, scheduleRestart, trustedRestartRequest } from './restart.ts'
|
|
12
12
|
import { deleteSkill, setSkillPolicy, updateSkillFile, userSkillsDir, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
13
13
|
import { findRootByUrl, loadState, pluginStateDir, removeSkillRoot, type SkillRootEntry } from './state.ts'
|
|
14
|
+
import { removeTree } from './rmtree.ts'
|
|
14
15
|
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
15
16
|
import type { CapabilitiesHost, HostSkill } from './types.ts'
|
|
16
17
|
|
|
@@ -354,12 +355,17 @@ export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: Capabili
|
|
|
354
355
|
sendJson(response, 400, { error: 'id is required' })
|
|
355
356
|
return
|
|
356
357
|
}
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
358
|
+
const removed = removeSkillRoot(body.id)
|
|
359
|
+
if (removed === undefined) {
|
|
359
360
|
sendJson(response, 404, { error: 'repository not found' })
|
|
360
361
|
return
|
|
361
362
|
}
|
|
363
|
+
// Unwatch before unlink: the provider's directory watchers hold
|
|
364
|
+
// handles on the material tree, and removing a watched tree on
|
|
365
|
+
// Windows fails with EPERM (the state is already saved by then,
|
|
366
|
+
// which is why the repo still disappears despite the error).
|
|
362
367
|
await config.remountProvider()
|
|
368
|
+
if (removed.materialDir !== undefined) removeTree(removed.materialDir)
|
|
363
369
|
sendJson(response, 200, { ok: true })
|
|
364
370
|
} catch (error) {
|
|
365
371
|
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
package/src/state.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
5
|
import { addSkillRoot, findRootByUrl, loadState, materialDirFor, newEntryId, removeSkillRoot, saveState } from './state.ts'
|
|
6
|
+
import { removeTree } from './rmtree.ts'
|
|
6
7
|
|
|
7
8
|
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-state-'))
|
|
8
9
|
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
@@ -23,8 +24,8 @@ describe('state round-trip', () => {
|
|
|
23
24
|
expect(findRootByUrl('a/b', home)?.id).toBe(entry.id)
|
|
24
25
|
expect(findRootByUrl('other/repo', home)).toBeUndefined()
|
|
25
26
|
|
|
26
|
-
expect(removeSkillRoot(entry.id, home)).
|
|
27
|
-
expect(removeSkillRoot(entry.id, home)).
|
|
27
|
+
expect(removeSkillRoot(entry.id, home)).toMatchObject({ id: entry.id })
|
|
28
|
+
expect(removeSkillRoot(entry.id, home)).toBeUndefined()
|
|
28
29
|
expect(loadState(home).skillRoots).toHaveLength(0)
|
|
29
30
|
})
|
|
30
31
|
|
|
@@ -55,7 +56,11 @@ describe('removeSkillRoot with link material', () => {
|
|
|
55
56
|
symlinkSync(source, join(material, 'skill'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
56
57
|
addSkillRoot({ id, kind: 'local', label: 'keep', path: source, roots: [material], materialDir: material }, home)
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// Deregistration only drops the state row; the caller deletes the
|
|
60
|
+
// material itself once the provider stopped watching it.
|
|
61
|
+
expect(removeSkillRoot(id, home)).toMatchObject({ id })
|
|
62
|
+
expect(existsSync(material)).toBe(true)
|
|
63
|
+
removeTree(material)
|
|
59
64
|
expect(existsSync(material)).toBe(false)
|
|
60
65
|
expect(existsSync(join(source, 'SKILL.md'))).toBe(true)
|
|
61
66
|
// State file persisted without the entry.
|
package/src/state.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* off this file, so it stays small, JSON, and hand-recoverable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { randomBytes } from 'node:crypto'
|
|
@@ -90,17 +90,21 @@ export function addSkillRoot(entry: Omit<SkillRootEntry, 'addedAt'>, dshHome?: s
|
|
|
90
90
|
return stored
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Deregister one entry by id and persist immediately. The material dir is
|
|
95
|
+
* deliberately NOT deleted here: the skill provider still watches it, and
|
|
96
|
+
* removing a watched tree on Windows fails with EPERM even though the
|
|
97
|
+
* state is already saved. The caller unmounts (remountProvider) first,
|
|
98
|
+
* then runs removeTree over the returned entry's materialDir. Returns the
|
|
99
|
+
* removed entry, or undefined when the id is unknown.
|
|
100
|
+
*/
|
|
101
|
+
export function removeSkillRoot(id: string, dshHome?: string): SkillRootEntry | undefined {
|
|
95
102
|
const state = loadState(dshHome)
|
|
96
103
|
const at = state.skillRoots.findIndex(entry => entry.id === id)
|
|
97
|
-
if (at === -1) return
|
|
104
|
+
if (at === -1) return undefined
|
|
98
105
|
const [removed] = state.skillRoots.splice(at, 1)
|
|
99
106
|
saveState(state, dshHome)
|
|
100
|
-
|
|
101
|
-
rmSync(removed.materialDir, { recursive: true, force: true, maxRetries: 2 })
|
|
102
|
-
}
|
|
103
|
-
return true
|
|
107
|
+
return removed
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/** Whether a git URL is already registered (market “installed” state). */
|