mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Fix broken import paths and add deletion robustness improvements
Import paths using `..//'` were invalid and broken. Replace with explicit module names (`skill-discovery-sources`, `skill-install-filesystem`, etc.) to clarify dependencies. - Bind WSL filesystem methods to preserve `this` context - Keep recovery journal when rollback rename fails, so startup can retry - Skip symlink-based tests on Windows where they cannot run - Only treat ENOENT/ENOTDIR as empty directories; propagate other errors - Fix cross-platform path parent calculation to handle drive roots - Replace shared constant with localized string for user-facing message - Use `runProcess` for WSL integration test instead of bare `execFile`
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
|
||||
import { skillDirectoryMaxDepth } from '../../../shared/skill-discovery-depth'
|
||||
import type { SkillPathSemantics } from '../../../shared/skill-path-containment'
|
||||
import type { SkillScanRoot } from '..//'
|
||||
import type { SkillInstallFilesystem, SkillPathInspection } from '..//'
|
||||
import { SKILL_FILE_NAME } from '..//'
|
||||
import type { SkillScanRoot } from '../skill-discovery-sources'
|
||||
import type { SkillInstallFilesystem, SkillPathInspection } from '../skill-install-filesystem'
|
||||
import { SKILL_FILE_NAME } from '../skill-root-file-walk'
|
||||
import { isSkillStagingEntryName } from './staging-names'
|
||||
|
||||
/** One directory in a discovery root that holds a `SKILL.md`, with everything
|
||||
@@ -32,7 +32,11 @@ export function requireEnumerableFilesystem(filesystem: SkillInstallFilesystem):
|
||||
if (!filesystem.listEntries || !filesystem.inspectPaths) {
|
||||
throw new SkillDeleteEnumerationUnsupportedError()
|
||||
}
|
||||
return { listEntries: filesystem.listEntries, inspectPaths: filesystem.inspectPaths }
|
||||
// Bound, not detached: the WSL filesystem's `listEntries` reaches `this.runOutput`.
|
||||
return {
|
||||
listEntries: filesystem.listEntries.bind(filesystem),
|
||||
inspectPaths: filesystem.inspectPaths.bind(filesystem)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
skillPathsEqual,
|
||||
type SkillPathSemantics
|
||||
} from '../../../shared/skill-path-containment'
|
||||
import type { SkillScanRoot } from '..//'
|
||||
import type { SkillScanRoot } from '../skill-discovery-sources'
|
||||
import type { SkillPlacementCandidate } from './enumeration'
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,10 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillDeleteRequest } from '../../../shared/skill-delete-contract'
|
||||
import { buildSkillDeletePlan } from './plan'
|
||||
import { nativeSkillInstallFilesystem } from '..//'
|
||||
import { nativeSkillInstallFilesystem } from '../skill-install-filesystem'
|
||||
|
||||
/** Windows rejects `symlink` with EPERM without elevation or Developer Mode. */
|
||||
const WINDOWS = process.platform === 'win32'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -56,86 +59,104 @@ function plan(home: string, deleteRequest: SkillDeleteRequest) {
|
||||
}
|
||||
|
||||
describe('buildSkillDeletePlan placement enumeration', () => {
|
||||
it('finds the canonical directory and every alias that resolves to it', async () => {
|
||||
const { home } = await fixture()
|
||||
const canonicalDirectory = join(home, '.agents', 'skills', 'demo')
|
||||
const file = await writeSkill(canonicalDirectory, 'demo')
|
||||
// An alias-dir: a symlinked directory in another agent's root.
|
||||
await mkdir(join(home, '.claude', 'skills'), { recursive: true })
|
||||
await symlink(canonicalDirectory, join(home, '.claude', 'skills', 'demo'), 'dir')
|
||||
// An alias-file: a real directory whose SKILL.md is a symlink.
|
||||
const aliasFileDirectory = join(home, '.codex', 'skills', 'demo')
|
||||
await mkdir(aliasFileDirectory, { recursive: true })
|
||||
await symlink(file, join(aliasFileDirectory, 'SKILL.md'))
|
||||
it.skipIf(WINDOWS)(
|
||||
'finds the canonical directory and every alias that resolves to it',
|
||||
async () => {
|
||||
const { home } = await fixture()
|
||||
const canonicalDirectory = join(home, '.agents', 'skills', 'demo')
|
||||
const file = await writeSkill(canonicalDirectory, 'demo')
|
||||
// An alias-dir: a symlinked directory in another agent's root.
|
||||
await mkdir(join(home, '.claude', 'skills'), { recursive: true })
|
||||
await symlink(canonicalDirectory, join(home, '.claude', 'skills', 'demo'), 'dir')
|
||||
// An alias-file: a real directory whose SKILL.md is a symlink.
|
||||
const aliasFileDirectory = join(home, '.codex', 'skills', 'demo')
|
||||
await mkdir(aliasFileDirectory, { recursive: true })
|
||||
await symlink(file, join(aliasFileDirectory, 'SKILL.md'))
|
||||
|
||||
const resolved = await plan(home, await request(file))
|
||||
const entry = resolved.plan.skills[0]
|
||||
expect(entry.blocked).toBeUndefined()
|
||||
expect(new Set(entry.placements.map((placement) => placement.kind))).toEqual(
|
||||
new Set(['canonical', 'alias-dir', 'alias-file'])
|
||||
)
|
||||
expect(entry.placements.map((placement) => placement.path)).toContain(canonicalDirectory)
|
||||
})
|
||||
const resolved = await plan(home, await request(file))
|
||||
const entry = resolved.plan.skills[0]
|
||||
expect(entry.blocked).toBeUndefined()
|
||||
expect(new Set(entry.placements.map((placement) => placement.kind))).toEqual(
|
||||
new Set(['canonical', 'alias-dir', 'alias-file'])
|
||||
)
|
||||
expect(entry.placements.map((placement) => placement.path)).toContain(canonicalDirectory)
|
||||
}
|
||||
)
|
||||
|
||||
it('classifies a directory whose SKILL.md is a symlink as alias-file, not canonical', async () => {
|
||||
const { home } = await fixture()
|
||||
const canonicalDirectory = join(home, '.agents', 'skills', 'demo')
|
||||
const file = await writeSkill(canonicalDirectory, 'demo')
|
||||
const aliasDirectory = join(home, '.claude', 'skills', 'demo')
|
||||
await mkdir(aliasDirectory, { recursive: true })
|
||||
await symlink(file, join(aliasDirectory, 'SKILL.md'))
|
||||
it.skipIf(WINDOWS)(
|
||||
'classifies a directory whose SKILL.md is a symlink as alias-file, not canonical',
|
||||
async () => {
|
||||
const { home } = await fixture()
|
||||
const canonicalDirectory = join(home, '.agents', 'skills', 'demo')
|
||||
const file = await writeSkill(canonicalDirectory, 'demo')
|
||||
const aliasDirectory = join(home, '.claude', 'skills', 'demo')
|
||||
await mkdir(aliasDirectory, { recursive: true })
|
||||
await symlink(file, join(aliasDirectory, 'SKILL.md'))
|
||||
|
||||
const resolved = await plan(home, await request(file))
|
||||
const alias = resolved.plan.skills[0].placements.find(
|
||||
(placement) => placement.path === aliasDirectory
|
||||
)
|
||||
expect(alias?.kind).toBe('alias-file')
|
||||
})
|
||||
const resolved = await plan(home, await request(file))
|
||||
const alias = resolved.plan.skills[0].placements.find(
|
||||
(placement) => placement.path === aliasDirectory
|
||||
)
|
||||
expect(alias?.kind).toBe('alias-file')
|
||||
}
|
||||
)
|
||||
|
||||
it('removes a home-root link to content outside every root, and only the link', async () => {
|
||||
const { home } = await fixture()
|
||||
const outside = join(home, 'elsewhere', 'demo')
|
||||
await writeSkill(outside, 'demo')
|
||||
await mkdir(join(home, '.claude', 'skills'), { recursive: true })
|
||||
await symlink(outside, join(home, '.claude', 'skills', 'demo'), 'dir')
|
||||
it.skipIf(WINDOWS)(
|
||||
'removes a home-root link to content outside every root, and only the link',
|
||||
async () => {
|
||||
const { home } = await fixture()
|
||||
const outside = join(home, 'elsewhere', 'demo')
|
||||
await writeSkill(outside, 'demo')
|
||||
await mkdir(join(home, '.claude', 'skills'), { recursive: true })
|
||||
await symlink(outside, join(home, '.claude', 'skills', 'demo'), 'dir')
|
||||
|
||||
const resolved = await plan(
|
||||
home,
|
||||
await request(join(home, '.claude', 'skills', 'demo', 'SKILL.md'))
|
||||
)
|
||||
expect(resolved.plan.skills[0].blocked).toBeUndefined()
|
||||
expect(resolved.plan.skills[0].placements).toEqual([
|
||||
expect.objectContaining({ path: join(home, '.claude', 'skills', 'demo'), kind: 'alias-dir' })
|
||||
])
|
||||
})
|
||||
const resolved = await plan(
|
||||
home,
|
||||
await request(join(home, '.claude', 'skills', 'demo', 'SKILL.md'))
|
||||
)
|
||||
expect(resolved.plan.skills[0].blocked).toBeUndefined()
|
||||
expect(resolved.plan.skills[0].placements).toEqual([
|
||||
expect.objectContaining({
|
||||
path: join(home, '.claude', 'skills', 'demo'),
|
||||
kind: 'alias-dir'
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('deletes the links of a repo skill whose content lives outside every root', async () => {
|
||||
// The shape that bit a real user: `<repo>/.agents/skills/<name>` is a link
|
||||
// to a tool-managed directory elsewhere on disk. The links are Orca's to
|
||||
// remove; the content behind them is not.
|
||||
const { home } = await fixture()
|
||||
const repo = join(home, 'projects', 'app')
|
||||
const managedElsewhere = join(home, '.local', 'share', 'devex', 'skills', 'demo')
|
||||
await writeSkill(managedElsewhere, 'demo')
|
||||
await mkdir(join(repo, '.agents', 'skills'), { recursive: true })
|
||||
await symlink(managedElsewhere, join(repo, '.agents', 'skills', 'demo'), 'dir')
|
||||
it.skipIf(WINDOWS)(
|
||||
'deletes the links of a repo skill whose content lives outside every root',
|
||||
async () => {
|
||||
// The shape that bit a real user: `<repo>/.agents/skills/<name>` is a link
|
||||
// to a tool-managed directory elsewhere on disk. The links are Orca's to
|
||||
// remove; the content behind them is not.
|
||||
const { home } = await fixture()
|
||||
const repo = join(home, 'projects', 'app')
|
||||
const managedElsewhere = join(home, '.local', 'share', 'devex', 'skills', 'demo')
|
||||
await writeSkill(managedElsewhere, 'demo')
|
||||
await mkdir(join(repo, '.agents', 'skills'), { recursive: true })
|
||||
await symlink(managedElsewhere, join(repo, '.agents', 'skills', 'demo'), 'dir')
|
||||
|
||||
const linked = join(repo, '.agents', 'skills', 'demo', 'SKILL.md')
|
||||
const resolved = await buildSkillDeletePlan({
|
||||
request: await request(linked),
|
||||
target: { kind: 'native-host', cwd: undefined },
|
||||
repos: [{ id: 'repo-1', name: 'app', path: repo } as never],
|
||||
filesystem: nativeSkillInstallFilesystem,
|
||||
homeDir: home
|
||||
})
|
||||
const entry = resolved.plan.skills[0]
|
||||
expect(entry.blocked).toBeUndefined()
|
||||
expect(entry.placements).toEqual([
|
||||
expect.objectContaining({ path: join(repo, '.agents', 'skills', 'demo'), kind: 'alias-dir' })
|
||||
])
|
||||
// Nothing outside the roots is ever offered for removal.
|
||||
expect(entry.placements.some((placement) => placement.path.includes('devex'))).toBe(false)
|
||||
})
|
||||
const linked = join(repo, '.agents', 'skills', 'demo', 'SKILL.md')
|
||||
const resolved = await buildSkillDeletePlan({
|
||||
request: await request(linked),
|
||||
target: { kind: 'native-host', cwd: undefined },
|
||||
repos: [{ id: 'repo-1', name: 'app', path: repo } as never],
|
||||
filesystem: nativeSkillInstallFilesystem,
|
||||
homeDir: home
|
||||
})
|
||||
const entry = resolved.plan.skills[0]
|
||||
expect(entry.blocked).toBeUndefined()
|
||||
expect(entry.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
path: join(repo, '.agents', 'skills', 'demo'),
|
||||
kind: 'alias-dir'
|
||||
})
|
||||
])
|
||||
// Nothing outside the roots is ever offered for removal.
|
||||
expect(entry.placements.some((placement) => placement.path.includes('devex'))).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('still refuses when the content is outside and no link sits in a root', async () => {
|
||||
const { home } = await fixture()
|
||||
@@ -146,19 +167,22 @@ describe('buildSkillDeletePlan placement enumeration', () => {
|
||||
expect(resolved.plan.skills[0].placements).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a bundled skill even when the row claimed a home source', async () => {
|
||||
const { home } = await fixture()
|
||||
const bundled = join(home, '.claude', 'skills', '.system', 'demo')
|
||||
const file = await writeSkill(bundled, 'demo')
|
||||
// Symlinked into a plain home root, which is what makes the row read `home`.
|
||||
await mkdir(join(home, '.agents', 'skills'), { recursive: true })
|
||||
await symlink(bundled, join(home, '.agents', 'skills', 'demo'), 'dir')
|
||||
it.skipIf(WINDOWS)(
|
||||
'refuses a bundled skill even when the row claimed a home source',
|
||||
async () => {
|
||||
const { home } = await fixture()
|
||||
const bundled = join(home, '.claude', 'skills', '.system', 'demo')
|
||||
const file = await writeSkill(bundled, 'demo')
|
||||
// Symlinked into a plain home root, which is what makes the row read `home`.
|
||||
await mkdir(join(home, '.agents', 'skills'), { recursive: true })
|
||||
await symlink(bundled, join(home, '.agents', 'skills', 'demo'), 'dir')
|
||||
|
||||
const resolved = await plan(home, await request(file))
|
||||
expect(resolved.plan.skills[0].blocked).toBe('bundled')
|
||||
})
|
||||
const resolved = await plan(home, await request(file))
|
||||
expect(resolved.plan.skills[0].blocked).toBe('bundled')
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a plugin-cache skill symlinked into a plain home root', async () => {
|
||||
it.skipIf(WINDOWS)('refuses a plugin-cache skill symlinked into a plain home root', async () => {
|
||||
const { home } = await fixture()
|
||||
const cached = join(home, '.codex', 'plugins', 'cache', 'pack', 'demo')
|
||||
const file = await writeSkill(cached, 'demo')
|
||||
|
||||
@@ -21,10 +21,10 @@ import {
|
||||
type SkillDeleteGuardContext
|
||||
} from './guards'
|
||||
import { buildSkillDeleteRootSet } from './roots'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '..//'
|
||||
import type { SkillInstallFilesystem } from '..//'
|
||||
import type { SkillProviderRootOverrides } from '..//'
|
||||
import type { SkillScanRoot } from '..//'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '../skill-discovery-target'
|
||||
import type { SkillInstallFilesystem } from '../skill-install-filesystem'
|
||||
import type { SkillProviderRootOverrides } from '../skill-provider-destinations'
|
||||
import type { SkillScanRoot } from '../skill-discovery-sources'
|
||||
|
||||
/** Overlapping roots can reach one directory twice. Staging the same path twice
|
||||
* would fail the second rename and roll the whole skill back. */
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
writeSkillDeleteJournal,
|
||||
type SkillDeleteJournalV1
|
||||
} from './recovery'
|
||||
import { writeSkillStateFile } from '..//'
|
||||
import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from '../skill-install-filesystem'
|
||||
import { writeSkillStateFile } from '../skill-install-provenance'
|
||||
import { skillDeleteStagedName } from './staging-names'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -147,6 +151,30 @@ describe('recoverSkillDeleteTransaction', () => {
|
||||
expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the journal when a rollback rename fails, so startup can retry it', async () => {
|
||||
const { stateDirectory, root } = await fixture()
|
||||
const canonicalPath = join(root, 'demo')
|
||||
const entry = journal(root, canonicalPath, { phase: 'staging' })
|
||||
await mkdir(entry.moves[0].stagedPath, { recursive: true })
|
||||
await writeSkillDeleteJournal(stateDirectory, entry)
|
||||
|
||||
const filesystem: SkillInstallFilesystem = {
|
||||
...nativeSkillInstallFilesystem,
|
||||
rename: () => Promise.reject(new Error('EPERM'))
|
||||
}
|
||||
await recoverSkillDeleteTransaction(stateDirectory, canonicalPath, filesystem)
|
||||
|
||||
// Journal retained: the staged path is still parked, and dropping the record
|
||||
// would strand it with nothing left to retry from.
|
||||
expect(await exists(entry.moves[0].stagedPath)).toBe(true)
|
||||
expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(true)
|
||||
|
||||
// Retry on a working filesystem completes and only then clears the journal.
|
||||
await recoverSkillDeleteTransaction(stateDirectory, canonicalPath)
|
||||
expect(await exists(canonicalPath)).toBe(true)
|
||||
expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(false)
|
||||
})
|
||||
|
||||
it('restores canonical before alias-file, so no alias dangles mid-recovery', async () => {
|
||||
const { home, stateDirectory, root } = await fixture()
|
||||
const canonicalPath = join(root, 'demo')
|
||||
|
||||
@@ -6,8 +6,15 @@ import {
|
||||
type SkillDeletePlacementKind
|
||||
} from '../../../shared/skill-delete-contract'
|
||||
import { nativeSkillPathSemantics, skillPathInside } from '../../../shared/skill-path-containment'
|
||||
import { nativeSkillInstallFilesystem, type SkillInstallFilesystem } from '..//'
|
||||
import { removeSkillInstallReceipt, skillInstallStateKey, writeSkillStateFile } from '..//'
|
||||
import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from '../skill-install-filesystem'
|
||||
import {
|
||||
removeSkillInstallReceipt,
|
||||
skillInstallStateKey,
|
||||
writeSkillStateFile
|
||||
} from '../skill-install-provenance'
|
||||
import { isSkillDeleteStagedName } from './staging-names'
|
||||
|
||||
const JOURNAL_MAX_BYTES = 4 * 1024 * 1024
|
||||
@@ -158,9 +165,18 @@ export async function recoverSkillDeleteTransaction(
|
||||
// Roll back in reverse of staging order, so a restored alias never points at
|
||||
// a canonical directory that does not exist yet. Indexed rather than
|
||||
// `toReversed()`: this module reaches the Node 18 relay bundle.
|
||||
let restoredEvery = true
|
||||
for (let index = staged.length - 1; index >= 0; index -= 1) {
|
||||
const move = staged[index]
|
||||
await filesystem.rename(move.stagedPath, move.sourcePath).catch(() => undefined)
|
||||
// Keep going past a failure so the other moves still come back, but keep
|
||||
// the journal: dropping it here strands the staged path with no record
|
||||
// that startup could retry from. Restores are idempotent.
|
||||
await filesystem.rename(move.stagedPath, move.sourcePath).catch(() => {
|
||||
restoredEvery = false
|
||||
})
|
||||
}
|
||||
if (!restoredEvery) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await rm(skillDeleteJournalPath(stateDirectory, canonicalPath), { force: true })
|
||||
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
SkillDeleteResult
|
||||
} from '../../../shared/skill-delete-contract'
|
||||
import { deleteSkills, previewSkillDeletion, type SkillDeleteServiceInput } from './service'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '..//'
|
||||
import { nativeSkillInstallFilesystem } from '..//'
|
||||
import type { SkillProviderRootOverrides } from '..//'
|
||||
import { WslSkillInstallFilesystem } from '..//'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '../skill-discovery-target'
|
||||
import { nativeSkillInstallFilesystem } from '../skill-install-filesystem'
|
||||
import type { SkillProviderRootOverrides } from '../skill-provider-destinations'
|
||||
import { WslSkillInstallFilesystem } from '../skill-wsl-install-filesystem'
|
||||
|
||||
/** Assembly shared by the local IPC channel and the runtime RPC method, so both
|
||||
* transports resolve the same host, roots, and filesystem. */
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
type SkillPathSemantics
|
||||
} from '../../../shared/skill-path-containment'
|
||||
import { toWindowsWslPath } from '../../../shared/wsl-paths'
|
||||
import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources'
|
||||
import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl'
|
||||
import { buildSkillDiscoverySources, type SkillScanRoot } from '..//'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '..//'
|
||||
import type { SkillProviderRootOverrides } from '..//'
|
||||
import { discoverClaudePluginSkillSources } from '../claude-plugin-skill-sources'
|
||||
import { discoverClaudePluginSkillSourcesInWsl } from '../claude-plugin-skill-sources-wsl'
|
||||
import { buildSkillDiscoverySources, type SkillScanRoot } from '../skill-discovery-sources'
|
||||
import type { ResolvedSkillDiscoveryTarget } from '../skill-discovery-target'
|
||||
import type { SkillProviderRootOverrides } from '../skill-provider-destinations'
|
||||
|
||||
/**
|
||||
* The delete host owns everything about where skills may live, so it rebuilds
|
||||
|
||||
@@ -4,9 +4,12 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillDeleteRequest } from '../../../shared/skill-delete-contract'
|
||||
import { deleteSkills } from './service'
|
||||
import { acquireSkillInstallLock, skillInstallLockPath } from '..//'
|
||||
import { nativeSkillInstallFilesystem, type SkillInstallFilesystem } from '..//'
|
||||
import { readSkillInstallReceipt, writeSkillInstallReceipt } from '..//'
|
||||
import { acquireSkillInstallLock, skillInstallLockPath } from '../skill-install-lock'
|
||||
import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from '../skill-install-filesystem'
|
||||
import { readSkillInstallReceipt, writeSkillInstallReceipt } from '../skill-install-provenance'
|
||||
import { skillDeleteJournalPath } from './recovery'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
removeStagedSkillDeleteMoves,
|
||||
stageSkillDeleteMoves
|
||||
} from './staging'
|
||||
import { clearSkillDiscoveryCaches, type ResolvedSkillDiscoveryTarget } from '..//'
|
||||
import { acquireSkillInstallLock, skillInstallLockPath } from '..//'
|
||||
import { removeSkillInstallReceipt } from '..//'
|
||||
import type { SkillInstallFilesystem } from '..//'
|
||||
import type { SkillProviderRootOverrides } from '..//'
|
||||
import { recordSkillDeleteOperation } from '..//'
|
||||
import {
|
||||
clearSkillDiscoveryCaches,
|
||||
type ResolvedSkillDiscoveryTarget
|
||||
} from '../skill-discovery-target'
|
||||
import { acquireSkillInstallLock, skillInstallLockPath } from '../skill-install-lock'
|
||||
import { removeSkillInstallReceipt } from '../skill-install-provenance'
|
||||
import type { SkillInstallFilesystem } from '../skill-install-filesystem'
|
||||
import type { SkillProviderRootOverrides } from '../skill-provider-destinations'
|
||||
import { recordSkillDeleteOperation } from '../skill-operation-observability'
|
||||
|
||||
export type SkillDeleteServiceInput = {
|
||||
request: SkillDeleteRequest
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { buildWslSkillDiscoveryCommand } from '..//'
|
||||
import { findSkillFiles } from '..//'
|
||||
import { buildWslSkillDiscoveryCommand } from '../skill-discovery-wsl'
|
||||
import { findSkillFiles } from '../skill-root-file-walk'
|
||||
import { isSkillStagingEntryName, skillDeleteStagedName, SKILL_STAGING_GLOB } from './staging-names'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -3,8 +3,8 @@ import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
|
||||
import type { SkillDeletePlacement } from '../../../shared/skill-delete-contract'
|
||||
import { nativeSkillPathSemantics } from '../../../shared/skill-path-containment'
|
||||
import type { SkillDeleteJournalMove } from './recovery'
|
||||
import type { SkillInstallFilesystem } from '..//'
|
||||
import { SKILL_FILE_NAME } from '..//'
|
||||
import type { SkillInstallFilesystem } from '../skill-install-filesystem'
|
||||
import { SKILL_FILE_NAME } from '../skill-root-file-walk'
|
||||
import { skillDeleteStagedName } from './staging-names'
|
||||
|
||||
function api(): typeof pathPosix {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SkillDirectoryEntry, SkillFilesystemEntryKind, SkillPathInspection } from '..//'
|
||||
import type {
|
||||
SkillDirectoryEntry,
|
||||
SkillFilesystemEntryKind,
|
||||
SkillPathInspection
|
||||
} from '../skill-install-filesystem'
|
||||
|
||||
/**
|
||||
* Guest-side enumeration for path-based skill deletion.
|
||||
@@ -98,10 +102,12 @@ export function parseWslListEntriesOutput(
|
||||
}
|
||||
const name = fields[index++]
|
||||
const kind = fields[index++]
|
||||
if (name === undefined || !isEntryKind(kind)) {
|
||||
// An `E` before any `D` would silently vanish, and a listing short of its
|
||||
// real contents reads to the planner as an empty directory it may remove.
|
||||
if (name === undefined || !isEntryKind(kind) || current === undefined) {
|
||||
throw new WslEnumerationProtocolError()
|
||||
}
|
||||
current?.push({ name, kind })
|
||||
current.push({ name, kind })
|
||||
}
|
||||
return listings
|
||||
}
|
||||
|
||||
@@ -71,10 +71,20 @@ export const nativeSkillInstallFilesystem: SkillInstallFilesystem = {
|
||||
new Map(
|
||||
await runSkillCandidateTasks(
|
||||
directories.map((directory) => async (): Promise<[string, SkillDirectoryEntry[]]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => null)
|
||||
// Only a confirmed absence may read as an empty directory: the delete
|
||||
// planner treats an empty listing as "nothing left here" and removes
|
||||
// the parent, so a swallowed EACCES/EIO would delete unenumerated files.
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||
(error: NodeJS.ErrnoException) => {
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
return [
|
||||
directory,
|
||||
(entries ?? []).map((entry) => ({ name: entry.name, kind: direntKind(entry) }))
|
||||
entries.map((entry) => ({ name: entry.name, kind: direntKind(entry) }))
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { resolveWslExecutablePath } from '../wsl/wsl-executable-path'
|
||||
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DISTRO = process.env.ORCA_REAL_WSL_SKILL_DISTRO ?? 'Ubuntu-24.04'
|
||||
const RUN_REAL_WSL = process.platform === 'win32' && process.env.ORCA_REAL_WSL_SKILL_TEST === '1'
|
||||
|
||||
async function runWsl(...args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync('wsl.exe', ['-d', DISTRO, '--exec', ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true
|
||||
const result = await runProcess({
|
||||
program: resolveWslExecutablePath(),
|
||||
args: ['-d', DISTRO, '--exec', ...args],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
return stdout.trim()
|
||||
// `runProcess` reports a non-zero exit as data; this harness wants it fatal.
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`wsl ${args.join(' ')} exited ${result.code}: ${result.stderr.trim()}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
function uncPath(guestPath: string): string {
|
||||
|
||||
@@ -86,9 +86,18 @@ export function skillDeletePlacementSummary(plan: SkillDeletePlan): string | nul
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not `node:path`: the renderer may be showing a Windows host's paths from macOS,
|
||||
* so `dirname` would pick the wrong separator. Both separators are honoured, and
|
||||
* a root keeps its trailing one — `/SKILL.md` is `/`, `C:\SKILL.md` is `C:\`.
|
||||
*/
|
||||
function parentDirectory(path: string): string {
|
||||
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return index > 0 ? path.slice(0, index) : path
|
||||
if (index < 0) {
|
||||
return path
|
||||
}
|
||||
const isDriveRoot = index === 2 && path[1] === ':'
|
||||
return index === 0 || isDriveRoot ? path.slice(0, index + 1) : path.slice(0, index)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
SkillDeleteRequest,
|
||||
SkillDeleteResult
|
||||
} from '../../../../shared/skill-delete-contract'
|
||||
import { SKILL_DELETE_UPDATE_REQUIRED_MESSAGE } from '../../../../shared/skill-install-capability'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import {
|
||||
deleteSkillsOnRuntimeTarget,
|
||||
@@ -173,8 +172,13 @@ function blockedOnlyResult(plan: SkillDeletePlan): SkillDeleteResult {
|
||||
}
|
||||
|
||||
function unsupportedReason(runtimeTarget: RuntimeClientTarget | null): string {
|
||||
// The shared constant is the wire-side error text; what the user reads here
|
||||
// goes through the catalog like every other string in this panel.
|
||||
return runtimeTarget
|
||||
? SKILL_DELETE_UPDATE_REQUIRED_MESSAGE
|
||||
? translate(
|
||||
'auto.components.skills.SkillDelete.hostUpdateRequired',
|
||||
'Update Orca on the selected machine to delete skills.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillDelete.hostUnresolved',
|
||||
'Still finding the machine that owns these skills.'
|
||||
|
||||
@@ -4670,6 +4670,7 @@
|
||||
"confirmPermanent": "This cannot be undone.",
|
||||
"failed": "Could not delete skills",
|
||||
"hostUnresolved": "Still finding the machine that owns these skills.",
|
||||
"hostUpdateRequired": "Update Orca on the selected machine to delete skills.",
|
||||
"nothingOne": "That skill cannot be deleted from here.",
|
||||
"nothingOther": "None of the {{count}} selected skills can be deleted from here.",
|
||||
"placementSummaryParts": "Removes {{parts}} across {{roots}}.",
|
||||
|
||||
Reference in New Issue
Block a user