mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat: install marketplace skills safely
This commit is contained in:
parent
d24f0f4bda
commit
cc3d16a3a5
3
bun.lock
3
bun.lock
@ -31,6 +31,7 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
@ -852,6 +853,8 @@
|
|||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.3", "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||||
|
|
||||||
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||||
|
|
||||||
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -34,6 +34,7 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
@ -5522,6 +5523,12 @@
|
|||||||
"node": "^12.20 || >= 14.13"
|
"node": "^12.20 || >= 14.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/figures": {
|
"node_modules/figures": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz",
|
||||||
|
|||||||
@ -64,6 +64,7 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"env-paths": "^4.0.0",
|
"env-paths": "^4.0.0",
|
||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
|
|||||||
143
src/server/__tests__/skill-market-install.test.ts
Normal file
143
src/server/__tests__/skill-market-install.test.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||||
|
import { zipSync } from 'fflate'
|
||||||
|
import * as fs from 'node:fs/promises'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { installUserSkillFromZipBytes } from '../services/skillMarket/installer.js'
|
||||||
|
|
||||||
|
let tmpHome: string
|
||||||
|
let originalClaudeConfigDir: string | undefined
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
function zip(entries: Record<string, string>): Buffer {
|
||||||
|
return Buffer.from(zipSync(Object.fromEntries(
|
||||||
|
Object.entries(entries).map(([name, content]) => [name, encoder.encode(content)]),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetPath(skillName: string): string {
|
||||||
|
return path.join(tmpHome, '.claude', 'skills', skillName)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('skill market user installer', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-market-install-'))
|
||||||
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalClaudeConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||||
|
}
|
||||||
|
await fs.rm(tmpHome, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installs a complete skill directory into user skills', async () => {
|
||||||
|
const result = await installUserSkillFromZipBytes({
|
||||||
|
skillName: 'skill-vetter',
|
||||||
|
zipBytes: zip({
|
||||||
|
'skill-vetter/SKILL.md': '---\ndescription: Safe\n---\n# Skill',
|
||||||
|
'skill-vetter/scripts/check.sh': 'echo ok',
|
||||||
|
'skill-vetter/assets/icon.txt': 'icon',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
installed: true,
|
||||||
|
skillName: 'skill-vetter',
|
||||||
|
targetPath: targetPath('skill-vetter'),
|
||||||
|
})
|
||||||
|
await expect(fs.readFile(path.join(targetPath('skill-vetter'), 'SKILL.md'), 'utf-8')).resolves.toContain('# Skill')
|
||||||
|
await expect(fs.readFile(path.join(targetPath('skill-vetter'), 'scripts', 'check.sh'), 'utf-8')).resolves.toBe('echo ok')
|
||||||
|
await expect(fs.readFile(path.join(targetPath('skill-vetter'), 'assets', 'icon.txt'), 'utf-8')).resolves.toBe('icon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installs a root-level SKILL.md package structure', async () => {
|
||||||
|
await installUserSkillFromZipBytes({
|
||||||
|
skillName: 'root-skill',
|
||||||
|
zipBytes: zip({
|
||||||
|
'SKILL.md': '---\ndescription: Root\n---\n# Root Skill',
|
||||||
|
'scripts/check.sh': 'echo root',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(fs.readFile(path.join(targetPath('root-skill'), 'SKILL.md'), 'utf-8')).resolves.toContain('Root Skill')
|
||||||
|
await expect(fs.readFile(path.join(targetPath('root-skill'), 'scripts', 'check.sh'), 'utf-8')).resolves.toBe('echo root')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unsafe skill names', async () => {
|
||||||
|
const unsafeNames = ['', '../escape', '/absolute', 'Skill', 'bad/name', 'bad\\name']
|
||||||
|
|
||||||
|
for (const skillName of unsafeNames) {
|
||||||
|
await expect(installUserSkillFromZipBytes({
|
||||||
|
skillName,
|
||||||
|
zipBytes: zip({ 'SKILL.md': 'bad' }),
|
||||||
|
})).rejects.toThrow('Invalid skill name')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects path traversal and absolute zip entries before writing the target', async () => {
|
||||||
|
const cases = [
|
||||||
|
{ entry: '../bad-skill/SKILL.md', message: 'Unsafe file path' },
|
||||||
|
{ entry: '/bad-skill/SKILL.md', message: 'Unsafe file path' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
await expect(installUserSkillFromZipBytes({
|
||||||
|
skillName: 'bad-skill',
|
||||||
|
zipBytes: zip({ [testCase.entry]: 'bad' }),
|
||||||
|
})).rejects.toThrow(testCase.message)
|
||||||
|
await expect(fs.stat(targetPath('bad-skill'))).rejects.toThrow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects packages missing SKILL.md', async () => {
|
||||||
|
await expect(installUserSkillFromZipBytes({
|
||||||
|
skillName: 'missing-entry',
|
||||||
|
zipBytes: zip({ 'missing-entry/scripts/check.sh': 'echo nope' }),
|
||||||
|
})).rejects.toThrow('SKILL.md')
|
||||||
|
|
||||||
|
await expect(fs.stat(targetPath('missing-entry'))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not overwrite an existing non-empty skill directory', async () => {
|
||||||
|
await fs.mkdir(targetPath('skill-vetter'), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(targetPath('skill-vetter'), 'SKILL.md'), 'existing', 'utf-8')
|
||||||
|
|
||||||
|
await expect(installUserSkillFromZipBytes({
|
||||||
|
skillName: 'skill-vetter',
|
||||||
|
zipBytes: zip({ 'skill-vetter/SKILL.md': 'new' }),
|
||||||
|
})).rejects.toThrow('already exists')
|
||||||
|
|
||||||
|
await expect(fs.readFile(path.join(targetPath('skill-vetter'), 'SKILL.md'), 'utf-8')).resolves.toBe('existing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not overwrite an existing empty skill directory', async () => {
|
||||||
|
await fs.mkdir(targetPath('empty-skill'), { recursive: true })
|
||||||
|
|
||||||
|
await expect(installUserSkillFromZipBytes({
|
||||||
|
skillName: 'empty-skill',
|
||||||
|
zipBytes: zip({ 'empty-skill/SKILL.md': 'new' }),
|
||||||
|
})).rejects.toThrow('already exists')
|
||||||
|
|
||||||
|
await expect(fs.readdir(targetPath('empty-skill'))).resolves.toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips a single different top-level directory while preserving the requested skill name', async () => {
|
||||||
|
await installUserSkillFromZipBytes({
|
||||||
|
skillName: 'renamed-skill',
|
||||||
|
zipBytes: zip({
|
||||||
|
'upstream-name/SKILL.md': '---\ndescription: Renamed\n---\n# Renamed',
|
||||||
|
'upstream-name/README.md': 'docs',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(fs.readFile(path.join(targetPath('renamed-skill'), 'SKILL.md'), 'utf-8')).resolves.toContain('Renamed')
|
||||||
|
await expect(fs.readFile(path.join(targetPath('renamed-skill'), 'README.md'), 'utf-8')).resolves.toBe('docs')
|
||||||
|
await expect(fs.stat(path.join(targetPath('renamed-skill'), 'upstream-name'))).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
211
src/server/services/skillMarket/installer.ts
Normal file
211
src/server/services/skillMarket/installer.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import * as fs from 'node:fs/promises'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { unzipFile } from '../../../utils/dxt/zip.js'
|
||||||
|
import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js'
|
||||||
|
import type { SkillMarketInstallResult } from './types.js'
|
||||||
|
|
||||||
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,80}$/
|
||||||
|
|
||||||
|
type NormalizedEntries = Map<string, Uint8Array>
|
||||||
|
|
||||||
|
export async function installUserSkillFromZipBytes(input: {
|
||||||
|
skillName: string
|
||||||
|
zipBytes: Buffer
|
||||||
|
}): Promise<SkillMarketInstallResult> {
|
||||||
|
validateSkillName(input.skillName)
|
||||||
|
|
||||||
|
const userSkillsRoot = path.join(getClaudeConfigHomeDir(), 'skills')
|
||||||
|
const targetPath = path.join(userSkillsRoot, input.skillName)
|
||||||
|
ensurePathInside(path.resolve(userSkillsRoot), path.resolve(targetPath), 'Install target escapes user skills directory')
|
||||||
|
|
||||||
|
if (await exists(targetPath)) {
|
||||||
|
throw new Error(`Skill "${input.skillName}" already exists at ${targetPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipEntries = await unzipFile(input.zipBytes)
|
||||||
|
const entries = normalizeSkillEntries(input.skillName, zipEntries)
|
||||||
|
if (!entries.has('SKILL.md')) {
|
||||||
|
throw new Error('Package does not contain SKILL.md')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagingRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-skill-install-'))
|
||||||
|
const stagingSkillDir = path.join(stagingRoot, input.skillName)
|
||||||
|
let targetCreated = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(stagingSkillDir)
|
||||||
|
await writeEntries(stagingSkillDir, entries)
|
||||||
|
|
||||||
|
await fs.mkdir(userSkillsRoot, { recursive: true })
|
||||||
|
await createTargetDirectory(targetPath, input.skillName)
|
||||||
|
targetCreated = true
|
||||||
|
await writeEntries(targetPath, entries)
|
||||||
|
|
||||||
|
return { installed: true, skillName: input.skillName, targetPath }
|
||||||
|
} catch (error) {
|
||||||
|
if (targetCreated) {
|
||||||
|
await fs.rm(targetPath, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await fs.rm(stagingRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSkillName(skillName: string): void {
|
||||||
|
if (!SKILL_NAME_PATTERN.test(skillName)) {
|
||||||
|
throw new Error(`Invalid skill name: ${skillName}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkillEntries(
|
||||||
|
skillName: string,
|
||||||
|
entries: Record<string, Uint8Array>,
|
||||||
|
): NormalizedEntries {
|
||||||
|
const validatedEntries = Object.entries(entries)
|
||||||
|
.map(([entryPath, bytes]) => ({
|
||||||
|
path: normalizeArchiveEntryPath(entryPath),
|
||||||
|
bytes,
|
||||||
|
}))
|
||||||
|
.filter((entry): entry is { path: string; bytes: Uint8Array } => entry.path !== null)
|
||||||
|
|
||||||
|
const packageRoot = findPackageRoot(skillName, validatedEntries.map((entry) => entry.path))
|
||||||
|
const normalized = new Map<string, Uint8Array>()
|
||||||
|
|
||||||
|
for (const entry of validatedEntries) {
|
||||||
|
const relativePath = packageRoot ? stripPackageRoot(entry.path, packageRoot) : entry.path
|
||||||
|
validateRelativeInstallPath(relativePath)
|
||||||
|
if (normalized.has(relativePath)) {
|
||||||
|
throw new Error(`Duplicate file path detected: "${relativePath}"`)
|
||||||
|
}
|
||||||
|
normalized.set(relativePath, entry.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArchiveEntryPath(entryPath: string): string | null {
|
||||||
|
if (!entryPath || entryPath.includes('\0')) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-zA-Z]:/.test(entryPath)) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slashed = entryPath.replace(/\\/g, '/')
|
||||||
|
if (path.posix.isAbsolute(slashed)) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = slashed.split('/')
|
||||||
|
if (segments.some((segment) => segment === '..')) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.posix.normalize(slashed)
|
||||||
|
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized === '..' || path.posix.isAbsolute(normalized)) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slashed.endsWith('/')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.replace(/^(\.\/)+/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPackageRoot(skillName: string, paths: string[]): string | null {
|
||||||
|
if (paths.includes('SKILL.md')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevelNames = new Set(paths.map((entryPath) => entryPath.split('/')[0]!).filter(Boolean))
|
||||||
|
if (topLevelNames.size !== 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [topLevelName] = topLevelNames
|
||||||
|
if (!topLevelName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topLevelName === skillName || paths.includes(`${topLevelName}/SKILL.md`)) {
|
||||||
|
return topLevelName
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPackageRoot(entryPath: string, packageRoot: string): string {
|
||||||
|
if (!entryPath.startsWith(`${packageRoot}/`)) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${entryPath}"`)
|
||||||
|
}
|
||||||
|
return entryPath.slice(packageRoot.length + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRelativeInstallPath(relativePath: string): void {
|
||||||
|
if (!relativePath || relativePath.includes('\0') || /^[a-zA-Z]:/.test(relativePath)) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${relativePath}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.posix.normalize(relativePath.replace(/\\/g, '/'))
|
||||||
|
if (
|
||||||
|
normalized !== relativePath
|
||||||
|
|| normalized === '.'
|
||||||
|
|| normalized.startsWith('../')
|
||||||
|
|| normalized === '..'
|
||||||
|
|| path.posix.isAbsolute(normalized)
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsafe file path detected: "${relativePath}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEntries(rootDir: string, entries: NormalizedEntries): Promise<void> {
|
||||||
|
const resolvedRoot = path.resolve(rootDir)
|
||||||
|
|
||||||
|
for (const [relativePath, bytes] of entries) {
|
||||||
|
const destination = path.resolve(rootDir, relativePath)
|
||||||
|
ensurePathInside(resolvedRoot, destination, `Unsafe destination path: ${relativePath}`)
|
||||||
|
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
await fs.writeFile(destination, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePathInside(root: string, target: string, message: string): void {
|
||||||
|
const relative = path.relative(root, target)
|
||||||
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTargetDirectory(targetPath: string, skillName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(targetPath)
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, 'EEXIST')) {
|
||||||
|
throw new Error(`Skill "${skillName}" already exists at ${targetPath}`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.lstat(filePath)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, 'ENOENT')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return typeof error === 'object'
|
||||||
|
&& error !== null
|
||||||
|
&& 'code' in error
|
||||||
|
&& (error as { code?: unknown }).code === code
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user