diff --git a/Cargo.toml b/Cargo.toml index 7ee82c02..fa47a494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "2026.10324.11958" +version = "2026.10325.12228" edition = "2024" license = "AGPL-3.0-only" authors = ["TrueNine"] diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 5db38fa3..1a3858ff 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index 343ca74a..a1696233 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 5944bace..af300737 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 9953739b..379b8e62 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 3e5ad568..8bd19bf8 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index a0da80c8..70b33619 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10324.11958", + "version": "2026.10325.12228", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/src/cleanup/delete-targets.ts b/cli/src/cleanup/delete-targets.ts new file mode 100644 index 00000000..4ed5c39e --- /dev/null +++ b/cli/src/cleanup/delete-targets.ts @@ -0,0 +1,71 @@ +import * as path from 'node:path' +import {resolveAbsolutePath} from '../ProtectedDeletionGuard' + +export interface CompactedDeletionTargets { + readonly files: string[] + readonly dirs: string[] +} + +function stripTrailingSeparator(rawPath: string): string { + const {root} = path.parse(rawPath) + if (rawPath === root) return rawPath + return rawPath.endsWith(path.sep) ? rawPath.slice(0, -1) : rawPath +} + +export function isSameOrChildDeletionPath(candidate: string, parent: string): boolean { + const normalizedCandidate = stripTrailingSeparator(candidate) + const normalizedParent = stripTrailingSeparator(parent) + if (normalizedCandidate === normalizedParent) return true + return normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`) +} + +export function compactDeletionTargets( + files: readonly string[], + dirs: readonly string[] +): CompactedDeletionTargets { + const filesByKey = new Map() + const dirsByKey = new Map() + + for (const filePath of files) { + const resolvedPath = resolveAbsolutePath(filePath) + filesByKey.set(resolvedPath, resolvedPath) + } + + for (const dirPath of dirs) { + const resolvedPath = resolveAbsolutePath(dirPath) + dirsByKey.set(resolvedPath, resolvedPath) + } + + const compactedDirs = new Map() + const sortedDirEntries = [...dirsByKey.entries()].sort((a, b) => a[0].length - b[0].length) + + for (const [dirKey, dirPath] of sortedDirEntries) { + let coveredByParent = false + for (const existingParentKey of compactedDirs.keys()) { + if (isSameOrChildDeletionPath(dirKey, existingParentKey)) { + coveredByParent = true + break + } + } + + if (!coveredByParent) compactedDirs.set(dirKey, dirPath) + } + + const compactedFiles: string[] = [] + for (const [fileKey, filePath] of filesByKey) { + let coveredByDir = false + for (const dirKey of compactedDirs.keys()) { + if (isSameOrChildDeletionPath(fileKey, dirKey)) { + coveredByDir = true + break + } + } + + if (!coveredByDir) compactedFiles.push(filePath) + } + + compactedFiles.sort((a, b) => a.localeCompare(b)) + const compactedDirPaths = [...compactedDirs.values()].sort((a, b) => a.localeCompare(b)) + + return {files: compactedFiles, dirs: compactedDirPaths} +} diff --git a/cli/src/commands/CleanupUtils.test.ts b/cli/src/commands/CleanupUtils.test.ts index ac0a4978..127b0389 100644 --- a/cli/src/commands/CleanupUtils.test.ts +++ b/cli/src/commands/CleanupUtils.test.ts @@ -23,6 +23,22 @@ function createMockLogger(): ILogger { } as ILogger } +function createRecordingLogger(): ILogger & {debugMessages: unknown[]} { + const debugMessages: unknown[] = [] + + return { + debugMessages, + trace: () => {}, + debug: message => { + debugMessages.push(message) + }, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {} + } as ILogger & {debugMessages: unknown[]} +} + function createCleanContext( overrides?: Partial, pluginOptionsOverrides?: Parameters[0] @@ -630,4 +646,47 @@ describe('performCleanup', () => { fs.rmSync(tempDir, {recursive: true, force: true}) } }) + + it('logs aggregated cleanup execution summaries instead of per-path success logs', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-perform-cleanup-logging-')) + const outputFile = path.join(tempDir, 'project-a', 'AGENTS.md') + const outputDir = path.join(tempDir, '.codex', 'prompts') + const stalePrompt = path.join(outputDir, 'demo.md') + const logger = createRecordingLogger() + + fs.mkdirSync(path.dirname(outputFile), {recursive: true}) + fs.mkdirSync(outputDir, {recursive: true}) + fs.writeFileSync(outputFile, '# agent', 'utf8') + fs.writeFileSync(stalePrompt, '# prompt', 'utf8') + + try { + const ctx = createCleanContext({ + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: tempDir, + getDirectoryName: () => path.basename(tempDir), + getAbsolutePath: () => tempDir + }, + projects: [] + } + }) + const plugin = createMockOutputPlugin('MockOutputPlugin', [outputFile], { + delete: [{kind: 'directory', path: outputDir}] + }) + + await performCleanup([plugin], ctx, logger) + + expect(logger.debugMessages).toEqual(expect.arrayContaining([ + 'cleanup plan built', + 'cleanup delete execution started', + 'cleanup delete execution complete' + ])) + expect(logger.debugMessages).not.toContainEqual(expect.objectContaining({path: outputFile})) + expect(logger.debugMessages).not.toContainEqual(expect.objectContaining({path: outputDir})) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/commands/CleanupUtils.ts b/cli/src/commands/CleanupUtils.ts index 662f1485..7119baed 100644 --- a/cli/src/commands/CleanupUtils.ts +++ b/cli/src/commands/CleanupUtils.ts @@ -1,3 +1,4 @@ +import type {DeletionError} from '../core/desk-paths' import type {ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputFileDeclaration, OutputPlugin, PluginOptions} from '../plugins/plugin-core' import type {ProtectedPathRule, ProtectionMode, ProtectionRuleMatcher} from '../ProtectedDeletionGuard' import * as fs from 'node:fs' @@ -8,7 +9,8 @@ import { buildFileOperationDiagnostic, diagnosticLines } from '@/diagnostics' -import {deleteDirectories as deskDeleteDirectories, deleteFiles as deskDeleteFiles} from '../plugins/desk-paths' +import {compactDeletionTargets} from '../cleanup/delete-targets' +import {deleteTargets as deskDeleteTargets} from '../core/desk-paths' import { collectAllPluginOutputs } from '../plugins/plugin-core' @@ -84,19 +86,6 @@ function normalizeGlobPattern(pattern: string): string { return resolveAbsolutePath(pattern).replaceAll('\\', '/') } -function stripTrailingSeparator(rawPath: string): string { - const {root} = path.parse(rawPath) - if (rawPath === root) return rawPath - return rawPath.endsWith(path.sep) ? rawPath.slice(0, -1) : rawPath -} - -function isSameOrChildPath(candidate: string, parent: string): boolean { - const normalizedCandidate = stripTrailingSeparator(candidate) - const normalizedParent = stripTrailingSeparator(parent) - if (normalizedCandidate === normalizedParent) return true - return normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`) -} - function expandCleanupGlob( pattern: string, ignoreGlobs: readonly string[] @@ -148,41 +137,6 @@ async function collectPluginCleanupSnapshot( return {plugin, outputs, cleanup} } -function compactDeletionTargets( - filesByKey: Map, - dirsByKey: Map -): {files: string[], dirs: string[]} { - const compactedDirs = new Map() - const sortedDirEntries = [...dirsByKey.entries()].sort((a, b) => a[0].length - b[0].length) - - for (const [dirKey, dirPath] of sortedDirEntries) { - let coveredByParent = false - for (const existingParentKey of compactedDirs.keys()) { - if (isSameOrChildPath(dirKey, existingParentKey)) { - coveredByParent = true - break - } - } - if (!coveredByParent) compactedDirs.set(dirKey, dirPath) - } - - const compactedFiles: string[] = [] - for (const [fileKey, filePath] of filesByKey) { - let coveredByDir = false - for (const dirKey of compactedDirs.keys()) { - if (isSameOrChildPath(fileKey, dirKey)) { - coveredByDir = true - break - } - } - if (!coveredByDir) compactedFiles.push(filePath) - } - - compactedFiles.sort((a, b) => a.localeCompare(b)) - const compactedDirPaths = [...compactedDirs.values()].sort((a, b) => a.localeCompare(b)) - return {files: compactedFiles, dirs: compactedDirPaths} -} - function buildCleanupProtectionConflictMessage(conflicts: readonly CleanupProtectionConflict[]): string { const pathList = conflicts.map(conflict => conflict.outputPath).join(', ') return `Cleanup protection conflict: ${conflicts.length} output path(s) are also protected: ${pathList}` @@ -402,8 +356,8 @@ export async function collectDeletionTargets( const dirPartition = partitionDeletionTargets([...deleteDirs], guard) const compactedTargets = compactDeletionTargets( - new Map(filePartition.safePaths.map(filePath => [filePath, filePath])), - new Map(dirPartition.safePaths.map(dirPath => [dirPath, dirPath])) + filePartition.safePaths, + dirPartition.safePaths ) return { @@ -415,66 +369,58 @@ export async function collectDeletionTargets( } } -/** - * Delete files with error handling. - * Logs warnings for failed deletions and continues with remaining files. - * Uses deletePathSync from @truenine/desk-paths for cross-platform safe deletion. - */ -export async function deleteFiles(files: string[], logger: ILogger): Promise<{deleted: number, errors: CleanupError[]}> { - const resolved = files.map(f => path.isAbsolute(f) ? f : path.resolve(f)) - const result = await deskDeleteFiles(resolved) - - for (const f of resolved) { - if (!result.errors.some(e => e.path === f)) logger.debug({action: 'delete', type: 'file', path: f}) - } - const errors: CleanupError[] = result.errors.map(e => { - const errorMessage = e.error instanceof Error ? e.error.message : String(e.error) +function buildCleanupErrors( + logger: ILogger, + errors: readonly DeletionError[], + type: 'file' | 'directory' +): CleanupError[] { + return errors.map(currentError => { + const errorMessage = currentError.error instanceof Error ? currentError.error.message : String(currentError.error) logger.warn(buildFileOperationDiagnostic({ - code: 'CLEANUP_FILE_DELETE_FAILED', - title: 'Cleanup could not delete a file', + code: type === 'file' ? 'CLEANUP_FILE_DELETE_FAILED' : 'CLEANUP_DIRECTORY_DELETE_FAILED', + title: type === 'file' ? 'Cleanup could not delete a file' : 'Cleanup could not delete a directory', operation: 'delete', - targetKind: 'file', - path: e.path, + targetKind: type, + path: currentError.path, error: errorMessage, details: { phase: 'cleanup' } })) - return {path: e.path, type: 'file' as const, error: e.error} - }) - return {deleted: result.deleted, errors} + return {path: currentError.path, type, error: currentError.error} + }) } -/** - * Delete directories with error handling. - * Sorts by length descending to handle nested dirs properly. - * Logs warnings for failed deletions and continues with remaining directories. - */ -export async function deleteDirectories(dirs: string[], logger: ILogger): Promise<{deleted: number, errors: CleanupError[]}> { - const resolved = dirs.map(d => path.isAbsolute(d) ? d : path.resolve(d)) - const result = await deskDeleteDirectories(resolved) +async function executeCleanupTargets( + targets: CleanupTargetCollections, + logger: ILogger +): Promise<{deletedFiles: number, deletedDirs: number, errors: CleanupError[]}> { + logger.debug('cleanup delete execution started', { + filesToDelete: targets.filesToDelete.length, + dirsToDelete: targets.dirsToDelete.length + }) - for (const d of resolved) { - if (!result.errors.some(e => e.path === d)) logger.debug({action: 'delete', type: 'directory', path: d}) - } - const errors: CleanupError[] = result.errors.map(e => { - const errorMessage = e.error instanceof Error ? e.error.message : String(e.error) - logger.warn(buildFileOperationDiagnostic({ - code: 'CLEANUP_DIRECTORY_DELETE_FAILED', - title: 'Cleanup could not delete a directory', - operation: 'delete', - targetKind: 'directory', - path: e.path, - error: errorMessage, - details: { - phase: 'cleanup' - } - })) - return {path: e.path, type: 'directory' as const, error: e.error} + const result = await deskDeleteTargets({ + files: targets.filesToDelete, + dirs: targets.dirsToDelete }) - return {deleted: result.deleted, errors} + const fileErrors = buildCleanupErrors(logger, result.fileErrors, 'file') + const dirErrors = buildCleanupErrors(logger, result.dirErrors, 'directory') + const allErrors = [...fileErrors, ...dirErrors] + + logger.debug('cleanup delete execution complete', { + deletedFiles: result.deletedFiles.length, + deletedDirs: result.deletedDirs.length, + errors: allErrors.length + }) + + return { + deletedFiles: result.deletedFiles.length, + deletedDirs: result.deletedDirs.length, + errors: allErrors + } } function logCleanupPlanDiagnostics( @@ -550,15 +496,12 @@ export async function performCleanup( } } - const [fileResult, dirResult] = await Promise.all([ - deleteFiles(cleanupTargets.filesToDelete, logger), - deleteDirectories(cleanupTargets.dirsToDelete, logger) - ]) + const executionResult = await executeCleanupTargets(cleanupTargets, logger) return { - deletedFiles: fileResult.deleted, - deletedDirs: dirResult.deleted, - errors: [...fileResult.errors, ...dirResult.errors], + deletedFiles: executionResult.deletedFiles, + deletedDirs: executionResult.deletedDirs, + errors: executionResult.errors, violations: [], conflicts: [] } diff --git a/cli/src/core/desk-paths-fallback.ts b/cli/src/core/desk-paths-fallback.ts new file mode 100644 index 00000000..8396cd7e --- /dev/null +++ b/cli/src/core/desk-paths-fallback.ts @@ -0,0 +1,250 @@ +import type {Buffer} from 'node:buffer' +import type {LoggerDiagnosticInput} from '../plugins/plugin-core' +import * as fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import {buildFileOperationDiagnostic} from '@/diagnostics' +import {resolveRuntimeEnvironment, resolveUserPath} from '@/runtime-environment' + +type PlatformFixedDir = 'win32' | 'darwin' | 'linux' + +function getLinuxDataDir(homeDir: string): string { + const xdgDataHome = process.env['XDG_DATA_HOME'] + if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return resolveUserPath(xdgDataHome) + return path.join(homeDir, '.local', 'share') +} + +export function getPlatformFixedDir(): string { + const runtimeEnvironment = resolveRuntimeEnvironment() + const platform = (runtimeEnvironment.isWsl ? 'win32' : runtimeEnvironment.platform) as PlatformFixedDir + const homeDir = runtimeEnvironment.effectiveHomeDir + + if (platform === 'win32') return resolveUserPath(process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local')) + if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support') + if (platform === 'linux') return getLinuxDataDir(homeDir) + + throw new Error(`Unsupported platform: ${process.platform}`) +} + +export function ensureDir(dir: string): void { + fs.mkdirSync(dir, {recursive: true}) +} + +export function existsSync(p: string): boolean { + return fs.existsSync(p) +} + +export function deletePathSync(p: string): void { + if (!fs.existsSync(p)) return + + const stat = fs.lstatSync(p) + if (stat.isSymbolicLink()) { + if (process.platform === 'win32') fs.rmSync(p, {recursive: true, force: true}) + else fs.unlinkSync(p) + } + else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) + else fs.unlinkSync(p) +} + +export function writeFileSync(filePath: string, data: string | Buffer, encoding: BufferEncoding = 'utf8'): void { + const parentDir = path.dirname(filePath) + ensureDir(parentDir) + if (typeof data === 'string') fs.writeFileSync(filePath, data, encoding) + else fs.writeFileSync(filePath, data) +} + +export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { + try { + return fs.readFileSync(filePath, encoding) + } + catch (error) { + const msg = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to read file "${filePath}": ${msg}`) + } +} + +export interface DeletionError { + readonly path: string + readonly error: unknown +} + +export interface DeletionResult { + readonly deleted: number + readonly deletedPaths: readonly string[] + readonly errors: readonly DeletionError[] +} + +export interface DeleteTargetsResult { + readonly deletedFiles: readonly string[] + readonly deletedDirs: readonly string[] + readonly fileErrors: readonly DeletionError[] + readonly dirErrors: readonly DeletionError[] +} + +const DELETE_CONCURRENCY = 32 + +async function deletePath(p: string): Promise { + try { + const stat = await fs.promises.lstat(p) + if (stat.isSymbolicLink()) { + await (process.platform === 'win32' ? fs.promises.rm(p, {recursive: true, force: true}) : fs.promises.unlink(p)) + return true + } + + if (stat.isDirectory()) { + await fs.promises.rm(p, {recursive: true, force: true}) + return true + } + + await fs.promises.unlink(p) + return true + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} + +async function mapWithConcurrencyLimit( + items: readonly T[], + concurrency: number, + worker: (item: T) => Promise +): Promise { + if (items.length === 0) return [] + + const results: TResult[] = [] + let nextIndex = 0 + + const runWorker = async (): Promise => { + while (true) { + const currentIndex = nextIndex + if (currentIndex >= items.length) return + + nextIndex += 1 + results[currentIndex] = await worker(items[currentIndex] as T) + } + } + + const workerCount = Math.min(concurrency, items.length) + const workers: Promise[] = [] + for (let index = 0; index < workerCount; index += 1) { + workers.push(runWorker()) + } + await Promise.all(workers) + + return results +} + +async function deletePaths( + paths: readonly string[], + options?: {readonly sortByDepthDescending?: boolean} +): Promise { + const sortedPaths = options?.sortByDepthDescending === true + ? [...paths].sort((a, b) => b.length - a.length || b.localeCompare(a)) + : [...paths] + + const results = await mapWithConcurrencyLimit(sortedPaths, DELETE_CONCURRENCY, async currentPath => { + try { + const deleted = await deletePath(currentPath) + return {path: currentPath, deleted} + } + catch (error) { + return {path: currentPath, error} + } + }) + + const deletedPaths: string[] = [] + const errors: DeletionError[] = [] + + for (const result of results) { + if ('error' in result) { + errors.push({path: result.path, error: result.error}) + continue + } + + if (result.deleted) deletedPaths.push(result.path) + } + + return { + deleted: deletedPaths.length, + deletedPaths, + errors + } +} + +export async function deleteFiles(files: readonly string[]): Promise { + return deletePaths(files) +} + +export async function deleteDirectories(dirs: readonly string[]): Promise { + return deletePaths(dirs, {sortByDepthDescending: true}) +} + +export async function deleteTargets(targets: { + readonly files?: readonly string[] + readonly dirs?: readonly string[] +}): Promise { + const [fileResult, dirResult] = await Promise.all([ + deleteFiles(targets.files ?? []), + deleteDirectories(targets.dirs ?? []) + ]) + + return { + deletedFiles: fileResult.deletedPaths, + deletedDirs: dirResult.deletedPaths, + fileErrors: fileResult.errors, + dirErrors: dirResult.errors + } +} + +export interface WriteLogger { + readonly trace: (data: object) => void + readonly error: (diagnostic: LoggerDiagnosticInput) => void +} + +export interface SafeWriteOptions { + readonly fullPath: string + readonly content: string | Buffer + readonly type: string + readonly relativePath: string + readonly dryRun: boolean + readonly logger: WriteLogger +} + +export interface SafeWriteResult { + readonly path: string + readonly success: boolean + readonly skipped?: boolean + readonly error?: Error +} + +export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { + const {fullPath, content, type, relativePath, dryRun, logger} = options + + if (dryRun) { + logger.trace({action: 'dryRun', type, path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + writeFileSync(fullPath, content) + logger.trace({action: 'write', type, path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + logger.error(buildFileOperationDiagnostic({ + code: 'OUTPUT_FILE_WRITE_FAILED', + title: `Failed to write ${type} output`, + operation: 'write', + targetKind: `${type} output file`, + path: fullPath, + error: errMsg, + details: { + relativePath, + type + } + })) + return {path: relativePath, success: false, error: error as Error} + } +} diff --git a/cli/src/core/desk-paths.ts b/cli/src/core/desk-paths.ts new file mode 100644 index 00000000..e289760b --- /dev/null +++ b/cli/src/core/desk-paths.ts @@ -0,0 +1,204 @@ +import type {Buffer} from 'node:buffer' +import type { + DeleteTargetsResult, + DeletionResult, + SafeWriteOptions, + SafeWriteResult +} from './desk-paths-fallback' +import {createRequire} from 'node:module' +import process from 'node:process' +import {buildFileOperationDiagnostic} from '@/diagnostics' +import * as fallback from './desk-paths-fallback' + +export type { + DeleteTargetsResult, + DeletionError, + DeletionResult, + SafeWriteOptions, + SafeWriteResult, + WriteLogger +} from './desk-paths-fallback' + +interface NativeDeskPathsBinding { + readonly getPlatformFixedDir?: () => string + readonly ensureDir?: (dir: string) => void + readonly existsSync?: (targetPath: string) => boolean + readonly deletePathSync?: (targetPath: string) => void + readonly writeFileSync?: (filePath: string, data: string | Buffer, encoding?: BufferEncoding) => void + readonly readFileSync?: (filePath: string, encoding?: BufferEncoding) => string + readonly deleteFiles?: (files: readonly string[]) => DeletionResult | Promise + readonly deleteDirectories?: (dirs: readonly string[]) => DeletionResult | Promise + readonly deleteTargets?: (targets: {readonly files?: readonly string[], readonly dirs?: readonly string[]}) => DeleteTargetsResult | Promise +} + +type NativeDeletionResult = DeletionResult & { + readonly deleted_paths?: readonly string[] +} + +type NativeDeleteTargetsResult = DeleteTargetsResult & { + readonly deleted_files?: readonly string[] + readonly deleted_dirs?: readonly string[] + readonly file_errors?: readonly import('./desk-paths-fallback').DeletionError[] + readonly dir_errors?: readonly import('./desk-paths-fallback').DeletionError[] +} + +function shouldSkipNativeBinding(): boolean { + return process.env['NODE_ENV'] === 'test' + || process.env['VITEST'] != null + || process.env['VITEST_WORKER_ID'] != null +} + +function tryLoadNativeBinding(): NativeDeskPathsBinding | undefined { + if (shouldSkipNativeBinding()) return void 0 + + const suffixMap: Readonly> = { + 'win32-x64': 'win32-x64-msvc', + 'linux-x64': 'linux-x64-gnu', + 'linux-arm64': 'linux-arm64-gnu', + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64' + } + const suffix = suffixMap[`${process.platform}-${process.arch}`] + if (suffix == null) return void 0 + + try { + const _require = createRequire(import.meta.url) + const packageName = `@truenine/memory-sync-cli-${suffix}` + const binaryFile = `napi-memory-sync-cli.${suffix}.node` + const candidates = [ + packageName, + `${packageName}/${binaryFile}`, + `./${binaryFile}` + ] + + for (const specifier of candidates) { + try { + const loaded = _require(specifier) as unknown + const possibleBindings = [ + loaded, + (loaded as {default?: unknown})?.default, + (loaded as {config?: unknown})?.config, + (loaded as {default?: {config?: unknown}})?.default?.config + ] + + for (const candidate of possibleBindings) { + if (candidate != null && typeof candidate === 'object') return candidate as NativeDeskPathsBinding + } + } + catch {} + } + } + catch { + } + + return void 0 +} + +const nativeBinding = tryLoadNativeBinding() + +function normalizeDeletionResult(result: NativeDeletionResult): DeletionResult { + return { + deleted: result.deleted, + deletedPaths: result.deletedPaths ?? result.deleted_paths ?? [], + errors: result.errors ?? [] + } +} + +function normalizeDeleteTargetsResult(result: NativeDeleteTargetsResult): DeleteTargetsResult { + return { + deletedFiles: result.deletedFiles ?? result.deleted_files ?? [], + deletedDirs: result.deletedDirs ?? result.deleted_dirs ?? [], + fileErrors: result.fileErrors ?? result.file_errors ?? [], + dirErrors: result.dirErrors ?? result.dir_errors ?? [] + } +} + +export function getPlatformFixedDir(): string { + return fallback.getPlatformFixedDir() +} + +export function ensureDir(dir: string): void { + if (nativeBinding?.ensureDir != null) { + nativeBinding.ensureDir(dir) + return + } + fallback.ensureDir(dir) +} + +export function existsSync(targetPath: string): boolean { + return nativeBinding?.existsSync?.(targetPath) ?? fallback.existsSync(targetPath) +} + +export function deletePathSync(targetPath: string): void { + if (nativeBinding?.deletePathSync != null) { + nativeBinding.deletePathSync(targetPath) + return + } + fallback.deletePathSync(targetPath) +} + +export function writeFileSync(filePath: string, data: string | Buffer, encoding: BufferEncoding = 'utf8'): void { + if (nativeBinding?.writeFileSync != null) { + nativeBinding.writeFileSync(filePath, data, encoding) + return + } + fallback.writeFileSync(filePath, data, encoding) +} + +export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { + return nativeBinding?.readFileSync?.(filePath, encoding) ?? fallback.readFileSync(filePath, encoding) +} + +export async function deleteFiles(files: readonly string[]): Promise { + if (nativeBinding?.deleteFiles != null) return normalizeDeletionResult(await Promise.resolve(nativeBinding.deleteFiles(files) as NativeDeletionResult)) + return fallback.deleteFiles(files) +} + +export async function deleteDirectories(dirs: readonly string[]): Promise { + if (nativeBinding?.deleteDirectories != null) return normalizeDeletionResult(await Promise.resolve(nativeBinding.deleteDirectories(dirs) as NativeDeletionResult)) + return fallback.deleteDirectories(dirs) +} + +export async function deleteTargets(targets: { + readonly files?: readonly string[] + readonly dirs?: readonly string[] +}): Promise { + if (nativeBinding?.deleteTargets != null) { + return normalizeDeleteTargetsResult(await Promise.resolve(nativeBinding.deleteTargets({ + files: targets.files ?? [], + dirs: targets.dirs ?? [] + }) as NativeDeleteTargetsResult)) + } + return fallback.deleteTargets(targets) +} + +export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { + const {fullPath, content, type, relativePath, dryRun, logger} = options + + if (dryRun) { + logger.trace({action: 'dryRun', type, path: fullPath}) + return {path: relativePath, success: true, skipped: false} + } + + try { + writeFileSync(fullPath, content) + logger.trace({action: 'write', type, path: fullPath}) + return {path: relativePath, success: true} + } + catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + logger.error(buildFileOperationDiagnostic({ + code: 'OUTPUT_FILE_WRITE_FAILED', + title: `Failed to write ${type} output`, + operation: 'write', + targetKind: `${type} output file`, + path: fullPath, + error: errMsg, + details: { + relativePath, + type + } + })) + return {path: relativePath, success: false, error: error as Error} + } +} diff --git a/cli/src/core/desk_paths.rs b/cli/src/core/desk_paths.rs new file mode 100644 index 00000000..34c9f530 --- /dev/null +++ b/cli/src/core/desk_paths.rs @@ -0,0 +1,515 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::core::config; + +const WINDOWS_DRIVE_PREFIX_LEN: usize = 2; + +/// Errors emitted by the desk-paths helpers. +#[derive(Debug, Error)] +pub enum DeskPathsError { + #[error("{0}")] + Io(#[from] io::Error), + #[error("unsupported platform: {0}")] + UnsupportedPlatform(String), +} + +pub type DeskPathsResult = Result; + +/// Platform shim that mirrors the values used by the legacy TS module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Win32, + Linux, + Darwin, +} + +impl Platform { + fn from_runtime(ctx: &config::RuntimeEnvironmentContext) -> Self { + if ctx.is_wsl { + return Platform::Win32; + } + match env::consts::OS { + "macos" => Platform::Darwin, + "windows" => Platform::Win32, + _ => Platform::Linux, + } + } + + fn is_windows(self) -> bool { + matches!(self, Platform::Win32) + } +} + +pub fn get_platform_fixed_dir() -> DeskPathsResult { + let ctx = config::resolve_runtime_environment(); + let platform = Platform::from_runtime(&ctx); + let target = match platform { + Platform::Win32 => get_windows_fixed_dir(&ctx), + Platform::Darwin => get_home_dir(&ctx) + .join("Library") + .join("Application Support"), + Platform::Linux => get_linux_data_dir(&ctx), + }; + Ok(target.to_string_lossy().into_owned()) +} + +fn get_windows_fixed_dir(ctx: &config::RuntimeEnvironmentContext) -> PathBuf { + let default = get_home_dir(ctx).join("AppData").join("Local"); + let candidate = + env::var("LOCALAPPDATA").unwrap_or_else(|_| default.to_string_lossy().into_owned()); + PathBuf::from(resolve_user_path(&candidate, ctx)) +} + +fn get_linux_data_dir(ctx: &config::RuntimeEnvironmentContext) -> PathBuf { + if let Ok(xdg_data_home) = env::var("XDG_DATA_HOME") { + if !xdg_data_home.trim().is_empty() { + return PathBuf::from(resolve_user_path(&xdg_data_home, ctx)); + } + } + get_home_dir(ctx).join(".local").join("share") +} + +fn get_home_dir(ctx: &config::RuntimeEnvironmentContext) -> PathBuf { + ctx.effective_home_dir + .as_ref() + .cloned() + .or_else(|| ctx.native_home_dir.clone()) + .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))) +} + +fn resolve_user_path(raw_path: &str, ctx: &config::RuntimeEnvironmentContext) -> String { + let platform = Platform::from_runtime(ctx); + let home_dir = get_home_dir(ctx); + let expanded = expand_home_directory(raw_path, &home_dir); + if ctx.is_wsl { + if let Some(converted) = convert_windows_path_to_wsl(&expanded) { + return normalize_posix_like_path(&converted, true); + } + return normalize_posix_like_path(&expanded, true); + } + if platform.is_windows() { + normalize_windows_path(&expanded) + } else { + normalize_posix_like_path(&expanded, false) + } +} + +fn expand_home_directory(raw_path: &str, home_dir: &Path) -> String { + if raw_path == "~" { + return normalize_posix_like_path(&home_dir.to_string_lossy(), false); + } + if raw_path.starts_with("~/") || raw_path.starts_with("~\\") { + let suffix = &raw_path[2..]; + let normalized = suffix.replace('\\', "/"); + let mut joined = PathBuf::from(home_dir); + for component in normalized.split('/') { + if component.is_empty() || component == "." { + continue; + } + if component == ".." { + joined.pop(); + } else { + joined.push(component); + } + } + return normalize_posix_like_path(&joined.to_string_lossy(), false); + } + raw_path.to_string() +} + +fn normalize_posix_like_path(raw_path: &str, preserve_slashes: bool) -> String { + let replaced = raw_path.replace('\\', "/"); + let is_absolute = replaced.starts_with('/'); + let mut components = Vec::new(); + for segment in replaced.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + components.pop(); + continue; + } + components.push(segment); + } + let mut normalized = String::new(); + if is_absolute { + normalized.push('/'); + } + normalized.push_str(&components.join("/")); + if normalized.is_empty() { + if is_absolute { + normalized.push('/'); + } else if preserve_slashes { + normalized.push('.'); + } + } + normalized +} + +fn normalize_windows_path(raw_path: &str) -> String { + let replaced = raw_path.replace('/', "\\"); + let mut components = Vec::new(); + let mut rest = replaced.as_str(); + let mut prefix = String::new(); + if rest.len() >= WINDOWS_DRIVE_PREFIX_LEN && rest.as_bytes()[1] == b':' { + prefix = rest[..WINDOWS_DRIVE_PREFIX_LEN].to_ascii_uppercase(); + rest = &rest[WINDOWS_DRIVE_PREFIX_LEN..]; + } + for segment in rest.split('\\') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + components.pop(); + continue; + } + components.push(segment); + } + let mut normalized = prefix.clone(); + if !normalized.is_empty() && !components.is_empty() { + normalized.push('\\'); + } + normalized.push_str(&components.join("\\")); + if normalized.is_empty() { + normalized.push('.'); + } + normalized +} + +fn convert_windows_path_to_wsl(raw_path: &str) -> Option { + let bytes = raw_path.as_bytes(); + if bytes.len() < WINDOWS_DRIVE_PREFIX_LEN + 1 || bytes[1] != b':' { + return None; + } + let drive_letter = (bytes[0] as char).to_ascii_lowercase(); + if !drive_letter.is_ascii_alphabetic() { + return None; + } + let mut rest = &raw_path[WINDOWS_DRIVE_PREFIX_LEN..]; + if rest.starts_with('\\') || rest.starts_with('/') { + rest = &rest[1..]; + } + let normalized = rest.replace('\\', "/"); + let prefix = format!("/mnt/{}", drive_letter); + if normalized.is_empty() { + return Some(prefix); + } + Some(format!("{}/{}", prefix, normalized)) +} + +pub fn ensure_dir>(dir: P) -> io::Result<()> { + fs::create_dir_all(dir) +} + +pub fn exists_sync>(path: P) -> bool { + path.as_ref().exists() +} + +pub fn delete_path_sync>(path: P) -> io::Result<()> { + delete_path(path).map(|_| ()) +} + +fn delete_path(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + + if metadata.file_type().is_symlink() { + #[cfg(windows)] + { + return fs::metadata(path) + .map(|resolved| resolved.is_dir()) + .unwrap_or(false) + .then(|| fs::remove_dir(path).or_else(|_| fs::remove_file(path))) + .unwrap_or_else(|| fs::remove_file(path).or_else(|_| fs::remove_dir(path))) + .map(|_| true); + } + #[cfg(not(windows))] + { + return fs::remove_file(path).map(|_| true); + } + } + + if metadata.is_dir() { + fs::remove_dir_all(path).map(|_| true) + } else { + fs::remove_file(path).map(|_| true) + } +} + +pub fn write_file_sync>(path: P, content: &[u8]) -> io::Result<()> { + if let Some(parent) = path.as_ref().parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content) +} + +pub fn read_file_sync>(path: P) -> io::Result { + fs::read_to_string(&path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "Failed to read file \"{}\": {}", + path.as_ref().display(), + err + ), + ) + }) +} + +pub struct DeletionError { + pub path: String, + pub error: String, +} + +pub struct DeletionResult { + pub deleted: usize, + pub deleted_paths: Vec, + pub errors: Vec, +} + +pub struct DeleteTargetsResult { + pub deleted_files: Vec, + pub deleted_dirs: Vec, + pub file_errors: Vec, + pub dir_errors: Vec, +} + +pub fn delete_files(paths: &[String]) -> DeletionResult { + let mut result = DeletionResult { + deleted: 0, + deleted_paths: Vec::new(), + errors: Vec::new(), + }; + for path in paths { + match delete_path(Path::new(path)) { + Ok(true) => { + result.deleted += 1; + result.deleted_paths.push(path.clone()); + } + Ok(false) => {} + Err(err) => result.errors.push(DeletionError { + path: path.clone(), + error: err.to_string(), + }), + } + } + result +} + +pub fn delete_directories(paths: &[String]) -> DeletionResult { + let mut sorted_paths = paths.to_vec(); + sorted_paths.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| b.cmp(a))); + + let mut result = DeletionResult { + deleted: 0, + deleted_paths: Vec::new(), + errors: Vec::new(), + }; + for path in &sorted_paths { + match delete_path(Path::new(path)) { + Ok(true) => { + result.deleted += 1; + result.deleted_paths.push(path.clone()); + } + Ok(false) => {} + Err(err) => result.errors.push(DeletionError { + path: path.clone(), + error: err.to_string(), + }), + } + } + result +} + +pub fn delete_targets(files: &[String], dirs: &[String]) -> DeleteTargetsResult { + let file_result = delete_files(files); + let dir_result = delete_directories(dirs); + DeleteTargetsResult { + deleted_files: file_result.deleted_paths, + deleted_dirs: dir_result.deleted_paths, + file_errors: file_result.errors, + dir_errors: dir_result.errors, + } +} + +#[cfg(feature = "napi")] +mod napi_binding { + use napi::bindgen_prelude::*; + use napi_derive::napi; + + use super::DeletionError; + + #[napi] + pub fn get_platform_fixed_dir() -> napi::Result { + super::get_platform_fixed_dir().map_err(|err| napi::Error::from_reason(err.to_string())) + } + + #[napi] + pub fn ensure_dir(path: String) -> napi::Result<()> { + super::ensure_dir(path).map_err(|err| napi::Error::from_reason(err.to_string())) + } + + #[napi] + pub fn exists_sync(path: String) -> bool { + super::exists_sync(path) + } + + #[napi] + pub fn delete_path_sync(path: String) -> napi::Result<()> { + super::delete_path_sync(path).map_err(|err| napi::Error::from_reason(err.to_string())) + } + + #[napi] + pub fn write_file_sync( + path: String, + data: Either, + encoding: Option, + ) -> napi::Result<()> { + if let Some(value) = encoding.as_deref() { + let normalized = value.to_ascii_lowercase(); + if normalized != "utf8" && normalized != "utf-8" { + return Err(napi::Error::from_reason(format!( + "unsupported encoding: {}", + value + ))); + } + } + + let bytes = match data { + Either::A(text) => text.into_bytes(), + Either::B(buffer) => buffer.to_vec(), + }; + super::write_file_sync(path, &bytes) + .map_err(|err| napi::Error::from_reason(err.to_string())) + } + + #[napi] + pub fn read_file_sync(path: String, encoding: Option) -> napi::Result { + if let Some(value) = encoding.as_deref() { + let normalized = value.to_ascii_lowercase(); + if normalized != "utf8" && normalized != "utf-8" { + return Err(napi::Error::from_reason(format!( + "unsupported encoding: {}", + value + ))); + } + } + super::read_file_sync(path).map_err(|err| napi::Error::from_reason(err.to_string())) + } + + #[allow(non_snake_case)] + #[napi(object)] + pub struct NapiDeletionError { + pub path: String, + pub error: String, + } + + #[allow(non_snake_case)] + #[napi(object)] + pub struct NapiDeletionResult { + pub deleted: u32, + pub deletedPaths: Vec, + pub errors: Vec, + } + + #[allow(non_snake_case)] + #[napi(object)] + pub struct NapiDeleteTargetsResult { + pub deletedFiles: Vec, + pub deletedDirs: Vec, + pub fileErrors: Vec, + pub dirErrors: Vec, + } + + fn to_napi_error(err: DeletionError) -> NapiDeletionError { + NapiDeletionError { + path: err.path, + error: err.error, + } + } + + #[napi] + pub fn delete_files(paths: Vec) -> NapiDeletionResult { + let result = super::delete_files(&paths); + NapiDeletionResult { + deleted: result.deleted as u32, + deletedPaths: result.deleted_paths, + errors: result.errors.into_iter().map(to_napi_error).collect(), + } + } + + #[napi] + pub fn delete_directories(paths: Vec) -> NapiDeletionResult { + let result = super::delete_directories(&paths); + NapiDeletionResult { + deleted: result.deleted as u32, + deletedPaths: result.deleted_paths, + errors: result.errors.into_iter().map(to_napi_error).collect(), + } + } + + #[napi(object)] + pub struct DeleteTargetsInput { + pub files: Option>, + pub dirs: Option>, + } + + #[napi] + pub fn delete_targets(paths: DeleteTargetsInput) -> NapiDeleteTargetsResult { + let files = paths.files.unwrap_or_default(); + let dirs = paths.dirs.unwrap_or_default(); + let result = super::delete_targets(&files, &dirs); + NapiDeleteTargetsResult { + deletedFiles: result.deleted_files, + deletedDirs: result.deleted_dirs, + fileErrors: result.file_errors.into_iter().map(to_napi_error).collect(), + dirErrors: result.dir_errors.into_iter().map(to_napi_error).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn delete_targets_batch() { + let dir = tempdir().unwrap(); + let files_dir = dir.path().join("files"); + let dirs_dir = dir.path().join("dirs"); + fs::create_dir_all(&files_dir).unwrap(); + fs::create_dir_all(&dirs_dir.join("nested")).unwrap(); + let file = files_dir.join("artifact.txt"); + fs::write(&file, b"data").unwrap(); + let leaf = dirs_dir.join("nested").join("inner.txt"); + fs::write(&leaf, b"payload").unwrap(); + + let result = delete_targets( + &[file.to_string_lossy().into_owned()], + &[dirs_dir.to_string_lossy().into_owned()], + ); + + assert_eq!( + result.deleted_files, + vec![file.to_string_lossy().into_owned()] + ); + assert!( + result + .deleted_dirs + .contains(&dirs_dir.to_string_lossy().into_owned()) + ); + assert!(result.file_errors.is_empty()); + assert!(result.dir_errors.is_empty()); + } +} diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 7706c47f..7e6db79e 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -1,3 +1,4 @@ pub mod config; +pub mod desk_paths; pub mod input_plugins; pub mod plugin_shared; diff --git a/cli/src/inputs/AbstractInputCapability.ts b/cli/src/inputs/AbstractInputCapability.ts index fc44100c..244400da 100644 --- a/cli/src/inputs/AbstractInputCapability.ts +++ b/cli/src/inputs/AbstractInputCapability.ts @@ -79,10 +79,10 @@ export abstract class AbstractInputCapability implements InputCapability { if (result.success) { this.log.trace({action: 'inputEffect', name: effect.name, status: 'success', description: result.description}) if (result.modifiedFiles != null && result.modifiedFiles.length > 0) { - this.log.debug({action: 'inputEffect', name: effect.name, modifiedFiles: result.modifiedFiles}) + this.log.debug({action: 'inputEffect', name: effect.name, modifiedFileCount: result.modifiedFiles.length}) } if (result.deletedFiles != null && result.deletedFiles.length > 0) { - this.log.debug({action: 'inputEffect', name: effect.name, deletedFiles: result.deletedFiles}) + this.log.debug({action: 'inputEffect', name: effect.name, deletedFileCount: result.deletedFiles.length}) } } else { const error = result.error ?? new Error(`Input effect failed: ${effect.name}`) diff --git a/cli/src/inputs/effect-orphan-cleanup.test.ts b/cli/src/inputs/effect-orphan-cleanup.test.ts index 9cc3f34a..5345dd4b 100644 --- a/cli/src/inputs/effect-orphan-cleanup.test.ts +++ b/cli/src/inputs/effect-orphan-cleanup.test.ts @@ -137,4 +137,26 @@ describe('orphan file cleanup effect', () => { fs.rmSync(tempWorkspace, {recursive: true, force: true}) } }) + + it('collapses nested orphan directories to the highest removable subtree root', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-orphan-cleanup-collapse-test-')) + const distDir = path.join(tempWorkspace, 'aindex', 'dist', 'commands', 'legacy', 'deep') + const orphanFile = path.join(distDir, 'demo.txt') + + try { + fs.mkdirSync(distDir, {recursive: true}) + fs.writeFileSync(orphanFile, 'Compiled prompt', 'utf8') + + const plugin = new OrphanFileCleanupEffectInputCapability() + const [result] = await plugin.executeEffects(createContext(tempWorkspace)) + + expect(result?.success).toBe(true) + expect(result?.deletedFiles).toEqual([]) + expect(result?.deletedDirs).toEqual([path.join(tempWorkspace, 'aindex', 'dist', 'commands')]) + expect(fs.existsSync(path.join(tempWorkspace, 'aindex', 'dist', 'commands'))).toBe(false) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/inputs/effect-orphan-cleanup.ts b/cli/src/inputs/effect-orphan-cleanup.ts index 1c47b0cf..1e311ae9 100644 --- a/cli/src/inputs/effect-orphan-cleanup.ts +++ b/cli/src/inputs/effect-orphan-cleanup.ts @@ -1,5 +1,7 @@ import type {InputCapabilityContext, InputCollectedContext, InputEffectContext, InputEffectResult} from '../plugins/plugin-core' import {buildFileOperationDiagnostic} from '@/diagnostics' +import {compactDeletionTargets} from '../cleanup/delete-targets' +import {deleteTargets} from '../core/desk-paths' import {AbstractInputCapability, SourcePromptFileExtensions} from '../plugins/plugin-core' import { collectConfiguredAindexInputRules, @@ -63,7 +65,8 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil const distSubDirPath = ctx.path.join(distDir, subDir) if (!ctx.fs.existsSync(distSubDirPath)) continue if (!ctx.fs.statSync(distSubDirPath).isDirectory()) continue - this.collectDirectoryPlan(ctx, distSubDirPath, subDir, srcPaths[subDir], filesToDelete, dirsToDelete, errors) + const subDirWillBeEmpty = this.collectDirectoryPlan(ctx, distSubDirPath, subDir, srcPaths[subDir], filesToDelete, dirsToDelete, errors) + if (subDirWillBeEmpty) dirsToDelete.push(distSubDirPath) } return {filesToDelete, dirsToDelete, errors} @@ -96,6 +99,7 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil const guard = this.buildProtectedDeletionGuard(ctx) const filePartition = partitionDeletionTargets(plan.filesToDelete, guard) const dirPartition = partitionDeletionTargets(plan.dirsToDelete, guard) + const compactedPlan = compactDeletionTargets(filePartition.safePaths, dirPartition.safePaths) const violations = [...filePartition.violations, ...dirPartition.violations].sort((a, b) => a.targetPath.localeCompare(b.targetPath)) if (violations.length > 0) { @@ -111,60 +115,61 @@ export class OrphanFileCleanupEffectInputCapability extends AbstractInputCapabil if (dryRun) { return { success: true, - description: `Would delete ${filePartition.safePaths.length} files and ${dirPartition.safePaths.length} directories`, - deletedFiles: [...filePartition.safePaths], - deletedDirs: [...dirPartition.safePaths].sort((a, b) => b.length - a.length) + description: `Would delete ${compactedPlan.files.length} files and ${compactedPlan.dirs.length} directories`, + deletedFiles: [...compactedPlan.files], + deletedDirs: [...compactedPlan.dirs] } } - const deletedFiles: string[] = [] - const deletedDirs: string[] = [] const deleteErrors: {path: string, error: Error}[] = [...plan.errors] + logger.debug('orphan cleanup delete execution started', { + filesToDelete: compactedPlan.files.length, + dirsToDelete: compactedPlan.dirs.length + }) - for (const filePath of filePartition.safePaths) { - try { - fs.unlinkSync(filePath) - deletedFiles.push(filePath) - logger.debug({action: 'orphan-cleanup', deleted: filePath}) - } - catch (error) { - deleteErrors.push({path: filePath, error: error as Error}) - logger.warn(buildFileOperationDiagnostic({ - code: 'ORPHAN_CLEANUP_FILE_DELETE_FAILED', - title: 'Orphan cleanup could not delete a file', - operation: 'delete', - targetKind: 'orphan file', - path: filePath, - error - })) - } + const result = await deleteTargets({ + files: compactedPlan.files, + dirs: compactedPlan.dirs + }) + + for (const fileError of result.fileErrors) { + const normalizedError = fileError.error instanceof Error ? fileError.error : new Error(String(fileError.error)) + deleteErrors.push({path: fileError.path, error: normalizedError}) + logger.warn(buildFileOperationDiagnostic({ + code: 'ORPHAN_CLEANUP_FILE_DELETE_FAILED', + title: 'Orphan cleanup could not delete a file', + operation: 'delete', + targetKind: 'orphan file', + path: fileError.path, + error: normalizedError + })) } - for (const dirPath of [...dirPartition.safePaths].sort((a, b) => b.length - a.length)) { - try { - fs.rmdirSync(dirPath) - deletedDirs.push(dirPath) - logger.debug({action: 'orphan-cleanup', deletedDir: dirPath}) - } - catch (error) { - deleteErrors.push({path: dirPath, error: error as Error}) - logger.warn(buildFileOperationDiagnostic({ - code: 'ORPHAN_CLEANUP_DIRECTORY_DELETE_FAILED', - title: 'Orphan cleanup could not delete a directory', - operation: 'delete', - targetKind: 'orphan directory', - path: dirPath, - error - })) - } + for (const dirError of result.dirErrors) { + const normalizedError = dirError.error instanceof Error ? dirError.error : new Error(String(dirError.error)) + deleteErrors.push({path: dirError.path, error: normalizedError}) + logger.warn(buildFileOperationDiagnostic({ + code: 'ORPHAN_CLEANUP_DIRECTORY_DELETE_FAILED', + title: 'Orphan cleanup could not delete a directory', + operation: 'delete', + targetKind: 'orphan directory', + path: dirError.path, + error: normalizedError + })) } + logger.debug('orphan cleanup delete execution complete', { + deletedFiles: result.deletedFiles.length, + deletedDirs: result.deletedDirs.length, + errors: deleteErrors.length + }) + const hasErrors = deleteErrors.length > 0 return { success: !hasErrors, - description: `Deleted ${deletedFiles.length} files and ${deletedDirs.length} directories`, - deletedFiles, - deletedDirs, + description: `Deleted ${result.deletedFiles.length} files and ${result.deletedDirs.length} directories`, + deletedFiles: [...result.deletedFiles], + deletedDirs: [...result.deletedDirs], ...hasErrors && {error: new Error(`${deleteErrors.length} errors occurred during cleanup`)} } } diff --git a/cli/src/inputs/effect-skill-sync.test.ts b/cli/src/inputs/effect-skill-sync.test.ts index 4cc5a6e7..2bc9ab11 100644 --- a/cli/src/inputs/effect-skill-sync.test.ts +++ b/cli/src/inputs/effect-skill-sync.test.ts @@ -56,9 +56,9 @@ describe('skill dist cleanup effect', () => { path.join(distSkillDir, 'guide.src.mdx'), path.join(distSkillDir, 'notes.md'), path.join(distSkillDir, 'demo.kts'), - path.join(distSkillDir, 'mcp.json'), - path.join(nestedLegacyDir, 'diagram.svg') + path.join(distSkillDir, 'mcp.json') ])) + expect(result?.deletedDirs).toContain(nestedLegacyDir) } finally { fs.rmSync(tempWorkspace, {recursive: true, force: true}) @@ -90,4 +90,26 @@ describe('skill dist cleanup effect', () => { fs.rmSync(tempWorkspace, {recursive: true, force: true}) } }) + + it('collapses nested removable skill dist directories to the highest safe root', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-skill-dist-cleanup-collapse-test-')) + const distSkillDir = path.join(tempWorkspace, 'aindex', 'dist', 'skills', 'demo') + const nestedLegacyDir = path.join(distSkillDir, 'legacy', 'deep') + + try { + fs.mkdirSync(nestedLegacyDir, {recursive: true}) + fs.writeFileSync(path.join(nestedLegacyDir, 'diagram.svg'), '', 'utf8') + + const plugin = new SkillDistCleanupEffectInputCapability() + const [result] = await plugin.executeEffects(createContext(tempWorkspace)) + + expect(result?.success).toBe(true) + expect(result?.deletedFiles).toEqual([]) + expect(result?.deletedDirs).toEqual([path.join(tempWorkspace, 'aindex', 'dist', 'skills')]) + expect(fs.existsSync(path.join(tempWorkspace, 'aindex', 'dist', 'skills'))).toBe(false) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/inputs/effect-skill-sync.ts b/cli/src/inputs/effect-skill-sync.ts index bab39bb4..61c2d89b 100644 --- a/cli/src/inputs/effect-skill-sync.ts +++ b/cli/src/inputs/effect-skill-sync.ts @@ -1,5 +1,7 @@ import type {InputCapabilityContext, InputCollectedContext, InputEffectContext, InputEffectResult} from '../plugins/plugin-core' import {buildFileOperationDiagnostic} from '@/diagnostics' +import {compactDeletionTargets} from '../cleanup/delete-targets' +import {deleteTargets} from '../core/desk-paths' import {AbstractInputCapability, hasSourcePromptExtension} from '../plugins/plugin-core' export interface SkillDistCleanupEffectResult extends InputEffectResult { @@ -35,64 +37,66 @@ export class SkillDistCleanupEffectInputCapability extends AbstractInputCapabili } const plan = this.buildCleanupPlan(ctx, distSkillsDir) + const compactedPlan = compactDeletionTargets(plan.filesToDelete, plan.dirsToDelete) if (dryRun) { return { success: true, - description: `Would delete ${plan.filesToDelete.length} files and ${plan.dirsToDelete.length} directories`, - deletedFiles: [...plan.filesToDelete], - deletedDirs: [...plan.dirsToDelete].sort((a, b) => b.length - a.length) + description: `Would delete ${compactedPlan.files.length} files and ${compactedPlan.dirs.length} directories`, + deletedFiles: [...compactedPlan.files], + deletedDirs: [...compactedPlan.dirs] } } - const deletedFiles: string[] = [] - const deletedDirs: string[] = [] const deleteErrors: {path: string, error: Error}[] = [...plan.errors] - - for (const filePath of plan.filesToDelete) { - try { - fs.unlinkSync(filePath) - deletedFiles.push(filePath) - logger.debug({action: 'skill-dist-cleanup', deleted: filePath}) - } - catch (error) { - deleteErrors.push({path: filePath, error: error as Error}) - logger.warn(buildFileOperationDiagnostic({ - code: 'SKILL_DIST_CLEANUP_FILE_DELETE_FAILED', - title: 'Skill dist cleanup could not delete a file', - operation: 'delete', - targetKind: 'skill dist file', - path: filePath, - error - })) - } + logger.debug('skill dist cleanup delete execution started', { + filesToDelete: compactedPlan.files.length, + dirsToDelete: compactedPlan.dirs.length + }) + + const result = await deleteTargets({ + files: compactedPlan.files, + dirs: compactedPlan.dirs + }) + + for (const fileError of result.fileErrors) { + const normalizedError = fileError.error instanceof Error ? fileError.error : new Error(String(fileError.error)) + deleteErrors.push({path: fileError.path, error: normalizedError}) + logger.warn(buildFileOperationDiagnostic({ + code: 'SKILL_DIST_CLEANUP_FILE_DELETE_FAILED', + title: 'Skill dist cleanup could not delete a file', + operation: 'delete', + targetKind: 'skill dist file', + path: fileError.path, + error: normalizedError + })) } - for (const dirPath of [...plan.dirsToDelete].sort((a, b) => b.length - a.length)) { - try { - fs.rmdirSync(dirPath) - deletedDirs.push(dirPath) - logger.debug({action: 'skill-dist-cleanup', deletedDir: dirPath}) - } - catch (error) { - deleteErrors.push({path: dirPath, error: error as Error}) - logger.warn(buildFileOperationDiagnostic({ - code: 'SKILL_DIST_CLEANUP_DIRECTORY_DELETE_FAILED', - title: 'Skill dist cleanup could not delete a directory', - operation: 'delete', - targetKind: 'skill dist directory', - path: dirPath, - error - })) - } + for (const dirError of result.dirErrors) { + const normalizedError = dirError.error instanceof Error ? dirError.error : new Error(String(dirError.error)) + deleteErrors.push({path: dirError.path, error: normalizedError}) + logger.warn(buildFileOperationDiagnostic({ + code: 'SKILL_DIST_CLEANUP_DIRECTORY_DELETE_FAILED', + title: 'Skill dist cleanup could not delete a directory', + operation: 'delete', + targetKind: 'skill dist directory', + path: dirError.path, + error: normalizedError + })) } + logger.debug('skill dist cleanup delete execution complete', { + deletedFiles: result.deletedFiles.length, + deletedDirs: result.deletedDirs.length, + errors: deleteErrors.length + }) + const hasErrors = deleteErrors.length > 0 return { success: !hasErrors, - description: `Deleted ${deletedFiles.length} files and ${deletedDirs.length} directories`, - deletedFiles, - deletedDirs, + description: `Deleted ${result.deletedFiles.length} files and ${result.deletedDirs.length} directories`, + deletedFiles: [...result.deletedFiles], + deletedDirs: [...result.deletedDirs], ...hasErrors && {error: new Error(`${deleteErrors.length} errors occurred during cleanup`)} } } @@ -102,7 +106,8 @@ export class SkillDistCleanupEffectInputCapability extends AbstractInputCapabili const dirsToDelete: string[] = [] const errors: {path: string, error: Error}[] = [] - this.collectCleanupPlan(ctx, distSkillsDir, filesToDelete, dirsToDelete, errors) + const rootWillBeEmpty = this.collectCleanupPlan(ctx, distSkillsDir, filesToDelete, dirsToDelete, errors) + if (rootWillBeEmpty) dirsToDelete.push(distSkillsDir) return {filesToDelete, dirsToDelete, errors} } diff --git a/cli/src/pipeline/OutputRuntimeTargets.ts b/cli/src/pipeline/OutputRuntimeTargets.ts index 10423fc6..0f9aa71b 100644 --- a/cli/src/pipeline/OutputRuntimeTargets.ts +++ b/cli/src/pipeline/OutputRuntimeTargets.ts @@ -2,8 +2,8 @@ import type {ILogger, OutputRuntimeTargets} from '@/plugins/plugin-core' import * as fs from 'node:fs' import * as path from 'node:path' +import {getPlatformFixedDir} from '@/core/desk-paths' import {buildFileOperationDiagnostic} from '@/diagnostics' -import {getPlatformFixedDir} from '@/plugins/desk-paths' const JETBRAINS_VENDOR_DIR = 'JetBrains' const JETBRAINS_AIA_DIR = 'aia' diff --git a/cli/src/plugins/desk-paths.test.ts b/cli/src/plugins/desk-paths.test.ts index 8dc16f43..17786de9 100644 --- a/cli/src/plugins/desk-paths.test.ts +++ b/cli/src/plugins/desk-paths.test.ts @@ -1,7 +1,9 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' import * as path from 'node:path' import {afterEach, describe, expect, it, vi} from 'vitest' -import {getPlatformFixedDir} from './desk-paths' +import {deleteFiles, deleteTargets, getPlatformFixedDir} from '../core/desk-paths' const {resolveRuntimeEnvironmentMock, resolveUserPathMock} = vi.hoisted(() => ({ resolveRuntimeEnvironmentMock: vi.fn(), @@ -22,6 +24,7 @@ const originalLocalAppData = process.env['LOCALAPPDATA'] describe('desk paths', () => { afterEach(() => { + vi.restoreAllMocks() vi.clearAllMocks() if (originalXdgDataHome == null) delete process.env['XDG_DATA_HOME'] @@ -63,4 +66,68 @@ describe('desk paths', () => { expect(getPlatformFixedDir()).toBe('/mnt/c/Users/alpha/AppData/Local') expect(resolveUserPathMock).toHaveBeenCalledWith('C:\\Users\\alpha\\AppData\\Local') }) + + it('deletes mixed file and directory targets in one batch', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-desk-paths-delete-targets-')) + const outputFile = path.join(tempDir, 'output.txt') + const outputDir = path.join(tempDir, 'nested') + const nestedFile = path.join(outputDir, 'artifact.txt') + + try { + fs.mkdirSync(outputDir, {recursive: true}) + fs.writeFileSync(outputFile, 'file', 'utf8') + fs.writeFileSync(nestedFile, 'nested', 'utf8') + + const result = await deleteTargets({ + files: [outputFile], + dirs: [outputDir] + }) + + expect(result.deletedFiles).toEqual([outputFile]) + expect(result.deletedDirs).toEqual([outputDir]) + expect(result.fileErrors).toEqual([]) + expect(result.dirErrors).toEqual([]) + expect(fs.existsSync(outputFile)).toBe(false) + expect(fs.existsSync(outputDir)).toBe(false) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('caps delete file concurrency to the configured worker limit', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-desk-paths-concurrency-')) + const files = Array.from({length: 40}, (_, index) => path.join(tempDir, `artifact-${index}.txt`)) + let active = 0 + let maxActive = 0 + const originalLstat = fs.promises.lstat.bind(fs.promises) + + try { + fs.mkdirSync(tempDir, {recursive: true}) + for (const filePath of files) fs.writeFileSync(filePath, 'artifact', 'utf8') + + vi.spyOn(fs.promises, 'lstat').mockImplementation(async filePath => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise(resolve => setTimeout(resolve, 20)) + + try { + return await originalLstat(filePath) + } + finally { + active -= 1 + } + }) + + const result = await deleteFiles(files) + + expect(result.deleted).toBe(files.length) + expect(result.errors).toEqual([]) + expect(maxActive).toBeLessThanOrEqual(32) + expect(maxActive).toBeGreaterThan(1) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) }) diff --git a/cli/src/plugins/desk-paths.ts b/cli/src/plugins/desk-paths.ts index 84f98fe1..add7c1dd 100644 --- a/cli/src/plugins/desk-paths.ts +++ b/cli/src/plugins/desk-paths.ts @@ -1,391 +1 @@ -import type {Buffer} from 'node:buffer' -import type {LoggerDiagnosticInput} from './plugin-core' -import * as fs from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import {buildFileOperationDiagnostic} from '@/diagnostics' -import {resolveRuntimeEnvironment, resolveUserPath} from '@/runtime-environment' - -/** - * Represents a fixed set of platform directory identifiers. - * - * `PlatformFixedDir` is a type that specifies platform-specific values. - * These values correspond to common operating system platforms and are - * used to identify directory structures or configurations unique to those systems. - * - * Valid values include: - * - 'win32': Represents the Windows operating system. - * - 'darwin': Represents the macOS operating system. - * - 'linux': Represents the Linux operating system. - * - * This type is typically used in contexts where platform-dependent logic - * or directory configurations are required. - */ -type PlatformFixedDir = 'win32' | 'darwin' | 'linux' - -/** - * Determines the Linux data directory based on the XDG_DATA_HOME environment - * variable or defaults to a directory under the user's home directory. - * - * @param {string} homeDir - The home directory path of the current user. - * @return {string} The resolved path to the Linux data directory. - */ -function getLinuxDataDir(homeDir: string): string { - const xdgDataHome = process.env['XDG_DATA_HOME'] - if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return resolveUserPath(xdgDataHome) - return path.join(homeDir, '.local', 'share') -} - -/** - * Determines and returns the platform-specific directory for storing application data. - * The directory path is resolved based on the underlying operating system. - * - * @return {string} The resolved directory path specific to the current platform. - * @throws {Error} If the platform is unsupported. - */ -export function getPlatformFixedDir(): string { - const runtimeEnvironment = resolveRuntimeEnvironment() - const platform = (runtimeEnvironment.isWsl ? 'win32' : runtimeEnvironment.platform) as PlatformFixedDir - const homeDir = runtimeEnvironment.effectiveHomeDir - - if (platform === 'win32') return resolveUserPath(process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local')) - if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support') - if (platform === 'linux') return getLinuxDataDir(homeDir) - - throw new Error(`Unsupported platform: ${process.platform}`) -} - -/** - * Check if a path is a symbolic link (or junction on Windows). - * - * @param p - The path to check - * @returns true if the path is a symbolic link, false otherwise - */ -export function isSymlink(p: string): boolean { - try { - return fs.lstatSync(p).isSymbolicLink() - } - catch { - return false - } -} - -/** - * Get file stats without following symlinks. - * - * @param p - The path to get stats for - * @returns The fs.Stats object - */ -export function lstatSync(p: string): fs.Stats { - return fs.lstatSync(p) -} - -/** - * Ensure a directory exists, creating it recursively if needed. - * Idempotent: calling multiple times has the same effect as calling once. - * - * @param dir - The directory path to ensure exists - */ -export function ensureDir(dir: string): void { - fs.mkdirSync(dir, {recursive: true}) -} - -/** @internal */ -function ensureDirectory(dir: string): void { - ensureDir(dir) -} - -/** - * Create a symbolic link with cross-platform support. - * - * On Windows: - * - Uses 'junction' for directories (no admin privileges required) - * - Uses 'file' symlink for files (may require admin or developer mode) - * - * On Unix/macOS: - * - Uses standard symbolic links for both files and directories - * - * @param targetPath - The path the symlink should point to (must be absolute on Windows for junction) - * @param symlinkPath - The path where the symlink will be created - * @param type - Type of symlink: 'file' or 'dir' (default: 'dir') - */ -export function createSymlink(targetPath: string, symlinkPath: string, type: 'file' | 'dir' = 'dir'): void { - const parentDir = path.dirname(symlinkPath) - ensureDirectory(parentDir) - - if (fs.existsSync(symlinkPath)) { // Remove existing symlink or directory - const stat = fs.lstatSync(symlinkPath) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync - else fs.unlinkSync(symlinkPath) - } else if (stat.isDirectory()) fs.rmSync(symlinkPath, {recursive: true}) - else fs.unlinkSync(symlinkPath) - } - - if (process.platform === 'win32' && type === 'dir') fs.symlinkSync(targetPath, symlinkPath, 'junction') // On Windows, use junction for directories (no admin needed) - else fs.symlinkSync(targetPath, symlinkPath, type) -} - -/** - * Remove a symbolic link (or junction on Windows) if it exists. - * - * @param symlinkPath - The path of the symlink to remove - */ -export function removeSymlink(symlinkPath: string): void { - if (!fs.existsSync(symlinkPath)) return - - const stat = fs.lstatSync(symlinkPath) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(symlinkPath, {recursive: true, force: true}) // Windows junction needs rmSync - else fs.unlinkSync(symlinkPath) - } -} - -/** - * Read the target of a symbolic link. - * - * @param symlinkPath - The path of the symlink - * @returns The target path, or null if not a symlink or an error occurred - */ -export function readSymlinkTarget(symlinkPath: string): string | null { - try { - if (!isSymlink(symlinkPath)) return null - return fs.readlinkSync(symlinkPath) - } - catch { - return null - } -} - -/** - * Check if a path exists (file, directory, or symlink). - * - * @param p - The path to check - * @returns true if the path exists - */ -export function existsSync(p: string): boolean { - return fs.existsSync(p) -} - -/** - * Delete a file, directory, or symlink/junction safely. - * Handles Windows junctions properly by using rmSync. - * - * @param p - The path to delete - */ -export function deletePathSync(p: string): void { - if (!fs.existsSync(p)) return - - const stat = fs.lstatSync(p) - if (stat.isSymbolicLink()) { - if (process.platform === 'win32') fs.rmSync(p, {recursive: true, force: true}) // Windows junction - else fs.unlinkSync(p) - } else if (stat.isDirectory()) fs.rmSync(p, {recursive: true, force: true}) - else fs.unlinkSync(p) -} // File Operations - Read, Write, Ensure - -/** - * Write a string or Buffer to a file, auto-creating parent directories. - * - * @param filePath - Absolute path to the file - * @param data - Content to write (string or Buffer) - * @param encoding - Encoding for string data (default: 'utf8') - */ -export function writeFileSync(filePath: string, data: string | Buffer, encoding: BufferEncoding = 'utf8'): void { - const parentDir = path.dirname(filePath) - ensureDir(parentDir) - if (typeof data === 'string') fs.writeFileSync(filePath, data, encoding) - else fs.writeFileSync(filePath, data) -} - -/** - * Read a file as a string. Throws with the path included in the error message on failure. - * - * @param filePath - Absolute path to the file - * @param encoding - Encoding (default: 'utf8') - * @returns The file content as a string - * @throws Error with path context if the file cannot be read - */ -export function readFileSync(filePath: string, encoding: BufferEncoding = 'utf8'): string { - try { - return fs.readFileSync(filePath, encoding) - } - catch (error) { - const msg = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to read file "${filePath}": ${msg}`) - } -} // Batch Deletion - Delete files and directories with error collection - -/** - * Error encountered during a batch deletion operation. - */ -export interface DeletionError { - readonly path: string - readonly error: unknown -} - -/** - * Result of a batch deletion operation. - */ -export interface DeletionResult { - readonly deleted: number - readonly errors: readonly DeletionError[] -} - -async function deletePath(p: string): Promise { - try { - const stat = await fs.promises.lstat(p) - if (stat.isSymbolicLink()) { - await (process.platform === 'win32' ? fs.promises.rm(p, {recursive: true, force: true}) : fs.promises.unlink(p)) - return true - } - - if (stat.isDirectory()) { - await fs.promises.rm(p, {recursive: true, force: true}) - return true - } - - await fs.promises.unlink(p) - return true - } - catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - throw error - } -} - -/** - * Delete multiple files. Skips non-existent files. Collects errors without throwing. - * - * @param files - Array of absolute file paths to delete - * @returns DeletionResult with count and errors - */ -export async function deleteFiles(files: readonly string[]): Promise { - const results = await Promise.all(files.map(async file => { - try { - const deleted = await deletePath(file) - return {path: file, deleted} - } - catch (error) { - return {path: file, error} - } - })) - - const errors: DeletionError[] = [] - let deleted = 0 - - for (const result of results) { - if ('error' in result) { - errors.push({path: result.path, error: result.error}) - continue - } - - if (result.deleted) deleted++ - } - - return {deleted, errors} -} - -/** - * Delete multiple directories. Sorts by depth descending so nested dirs are removed first. - * Skips non-existent directories. Collects errors without throwing. - * - * @param dirs - Array of absolute directory paths to delete - * @returns DeletionResult with count and errors - */ -export async function deleteDirectories(dirs: readonly string[]): Promise { - const sorted = [...dirs].sort((a, b) => b.length - a.length) - const results = await Promise.all(sorted.map(async dir => { - try { - const deleted = await deletePath(dir) - return {path: dir, deleted} - } - catch (error) { - return {path: dir, error} - } - })) - - const errors: DeletionError[] = [] - let deleted = 0 - - for (const result of results) { - if ('error' in result) { - errors.push({path: result.path, error: result.error}) - continue - } - - if (result.deleted) deleted++ - } - - return {deleted, errors} -} // Safe Write - Dry-run aware file writing with error handling - -/** - * Logger interface for safe write operations. - */ -export interface WriteLogger { - readonly trace: (data: object) => void - readonly error: (diagnostic: LoggerDiagnosticInput) => void -} - -/** - * Options for writeFileSafe. - */ -export interface SafeWriteOptions { - readonly fullPath: string - readonly content: string | Buffer - readonly type: string - /** 相对路径字符串 (相对于输出目标目录) */ - readonly relativePath: string - readonly dryRun: boolean - readonly logger: WriteLogger -} - -/** - * Result of a safe write operation. - */ -export interface SafeWriteResult { - /** 相对路径字符串 (相对于输出目标目录) */ - readonly path: string - readonly success: boolean - readonly skipped?: boolean - readonly error?: Error -} - -/** - * Write a file with dry-run support and error handling. - * Auto-creates parent directories. Returns a result object instead of throwing. - * - * @param options - Write options including path, content, dry-run flag, and logger - * @returns SafeWriteResult indicating success or failure - */ -export function writeFileSafe(options: SafeWriteOptions): SafeWriteResult { - const {fullPath, content, type, relativePath, dryRun, logger} = options - - if (dryRun) { - logger.trace({action: 'dryRun', type, path: fullPath}) - return {path: relativePath, success: true, skipped: false} - } - - try { - writeFileSync(fullPath, content) - logger.trace({action: 'write', type, path: fullPath}) - return {path: relativePath, success: true} - } - catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - logger.error(buildFileOperationDiagnostic({ - code: 'OUTPUT_FILE_WRITE_FAILED', - title: `Failed to write ${type} output`, - operation: 'write', - targetKind: `${type} output file`, - path: fullPath, - error: errMsg, - details: { - relativePath, - type - } - })) - return {path: relativePath, success: false, error: error as Error} - } -} +export * from '../core/desk-paths' diff --git a/cli/src/plugins/plugin-core/plugin.ts b/cli/src/plugins/plugin-core/plugin.ts index d4def91e..9bed4e00 100644 --- a/cli/src/plugins/plugin-core/plugin.ts +++ b/cli/src/plugins/plugin-core/plugin.ts @@ -278,8 +278,6 @@ export interface OutputFileDeclaration { readonly source: unknown /** Optional existing-file policy */ readonly ifExists?: 'overwrite' | 'skip' | 'error' - /** Optional symlink target for declarative link creation */ - readonly symlinkTarget?: string /** Optional label for logging */ readonly label?: string } @@ -448,13 +446,6 @@ export async function executeDeclarativeWriteOutputs( if (declaration.ifExists === 'error' && fs.existsSync(declaration.path)) throw new Error(`Refusing to overwrite existing file: ${declaration.path}`) - if (declaration.symlinkTarget != null) { - if (fs.existsSync(declaration.path)) fs.rmSync(declaration.path, {force: true, recursive: false}) - fs.symlinkSync(declaration.symlinkTarget, declaration.path, 'file') - fileResults.push({path: declaration.path, success: true}) - continue - } - const content = await plugin.convertContent(declaration, ctx) isNodeBufferLike(content) ? fs.writeFileSync(declaration.path, content) diff --git a/cli/tmp/plugin-pipeline-frontmatter/frontmatter.txt b/cli/tmp/plugin-pipeline-frontmatter/frontmatter.txt deleted file mode 100644 index 02e4a84d..00000000 --- a/cli/tmp/plugin-pipeline-frontmatter/frontmatter.txt +++ /dev/null @@ -1 +0,0 @@ -false \ No newline at end of file diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 2dd219c8..65a26540 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -17,8 +17,8 @@ "@/*": [ "./src/*" ], - "@truenine/desk-paths": ["./src/plugins/desk-paths.ts"], - "@truenine/desk-paths/*": ["./src/plugins/desk-paths/*"], + "@truenine/desk-paths": ["./src/core/desk-paths.ts"], + "@truenine/desk-paths/*": ["./src/core/desk-paths/*"], "@truenine/plugin-output-shared": ["./src/plugins/plugin-output-shared/index.ts"], "@truenine/plugin-output-shared/*": ["./src/plugins/plugin-output-shared/*"], "@truenine/plugin-input-shared": ["./src/plugins/plugin-input-shared/index.ts"], diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 5f24aab9..29bca6dc 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -6,7 +6,7 @@ const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) as {version: stri const kiroGlobalPowersRegistry = '{"version":"1.0.0","powers":{},"repoSources":{}}' const pluginAliases: Record = { - '@truenine/desk-paths': resolve('src/plugins/desk-paths.ts'), + '@truenine/desk-paths': resolve('src/core/desk-paths.ts'), '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), '@truenine/plugin-input-shared': resolve('src/plugins/plugin-input-shared/index.ts'), '@truenine/plugin-agentskills-compact': resolve('src/plugins/plugin-agentskills-compact.ts'), diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 2f0ccae7..0dfa24d3 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -13,7 +13,7 @@ const workspacePackageAliases: Record = { } const pluginAliases: Record = { - '@truenine/desk-paths': resolve('src/plugins/desk-paths.ts'), + '@truenine/desk-paths': resolve('src/core/desk-paths.ts'), '@truenine/plugin-output-shared': resolve('src/plugins/plugin-output-shared/index.ts'), '@truenine/plugin-output-shared/utils': resolve('src/plugins/plugin-output-shared/utils/index.ts'), '@truenine/plugin-output-shared/registry': resolve('src/plugins/plugin-output-shared/registry/index.ts'), diff --git a/doc/app/docs/layout.tsx b/doc/app/docs/layout.tsx index 172a0dd5..786d3d7a 100644 --- a/doc/app/docs/layout.tsx +++ b/doc/app/docs/layout.tsx @@ -57,6 +57,7 @@ export default async function DocsLayout({children}: {readonly children: ReactNo link: siteConfig.issueUrl, labels: 'documentation' }} + darkMode={false} sidebar={{ autoCollapse: false, defaultMenuCollapseLevel: 99, @@ -77,6 +78,7 @@ export default async function DocsLayout({children}: {readonly children: ReactNo attribute: 'class', defaultTheme: 'dark', disableTransitionOnChange: true, + forcedTheme: 'dark', storageKey: 'memory-sync-docs-theme' }} > diff --git a/doc/app/globals.css b/doc/app/globals.css index 8c9f7876..9a3776fb 100644 --- a/doc/app/globals.css +++ b/doc/app/globals.css @@ -1,34 +1,7 @@ -:root { - --nextra-content-width: 1380px; - --page-bg: #ffffff; - --page-fg: #111111; - --page-fg-soft: #5f6670; - --page-fg-muted: #7b818b; - --surface: rgba(255, 255, 255, 0.9); - --surface-strong: #ffffff; - --surface-muted: #f6f7f8; - --surface-subtle: #f3f4f6; - --surface-elevated: #f8f9fb; - --surface-overlay: rgba(255, 255, 255, 0.94); - --surface-border: rgba(17, 17, 17, 0.08); - --surface-border-strong: rgba(17, 17, 17, 0.14); - --surface-separator: rgba(17, 17, 17, 0.06); - --surface-highlight: rgba(17, 17, 17, 0.035); - --surface-highlight-strong: rgba(17, 17, 17, 0.055); - --inline-code-bg: rgba(17, 17, 17, 0.035); - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); - --shadow-md: 0 12px 40px rgba(15, 23, 42, 0.06); - --hero-glow: radial-gradient(circle at top, rgba(0, 0, 0, 0.06), transparent 58%); - --page-gradient: - radial-gradient(circle at top, rgba(0, 0, 0, 0.04), transparent 30%), - linear-gradient(180deg, #ffffff 0%, #fbfbfc 100%); - --button-primary-bg: #111111; - --button-primary-fg: #ffffff; - --button-secondary-bg: rgba(255, 255, 255, 0.82); - --button-secondary-fg: #111111; -} - +:root, html.dark { + color-scheme: dark; + --nextra-content-width: 1380px; --page-bg: #0b0c10; --page-fg: #fafafa; --page-fg-soft: #b8bec7; @@ -57,6 +30,37 @@ html.dark { --button-secondary-fg: #fafafa; } +html.light { + color-scheme: light; + --nextra-content-width: 1380px; + --page-bg: #ffffff; + --page-fg: #111111; + --page-fg-soft: #5f6670; + --page-fg-muted: #7b818b; + --surface: rgba(255, 255, 255, 0.9); + --surface-strong: #ffffff; + --surface-muted: #f6f7f8; + --surface-subtle: #f3f4f6; + --surface-elevated: #f8f9fb; + --surface-overlay: rgba(255, 255, 255, 0.94); + --surface-border: rgba(17, 17, 17, 0.08); + --surface-border-strong: rgba(17, 17, 17, 0.14); + --surface-separator: rgba(17, 17, 17, 0.06); + --surface-highlight: rgba(17, 17, 17, 0.035); + --surface-highlight-strong: rgba(17, 17, 17, 0.055); + --inline-code-bg: rgba(17, 17, 17, 0.035); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); + --shadow-md: 0 12px 40px rgba(15, 23, 42, 0.06); + --hero-glow: radial-gradient(circle at top, rgba(0, 0, 0, 0.06), transparent 58%); + --page-gradient: + radial-gradient(circle at top, rgba(0, 0, 0, 0.04), transparent 30%), + linear-gradient(180deg, #ffffff 0%, #fbfbfc 100%); + --button-primary-bg: #111111; + --button-primary-fg: #ffffff; + --button-secondary-bg: rgba(255, 255, 255, 0.82); + --button-secondary-fg: #111111; +} + *, *::before, *::after { @@ -818,6 +822,197 @@ samp { padding: 0 18px 18px; } +.nextra-body-typesetting-article .docs-widget { + margin: 1.5rem 0; +} + +.nextra-body-typesetting-article .docs-widget-header { + margin-bottom: 0.9rem; +} + +.nextra-body-typesetting-article .docs-widget-header h3 { + margin: 0; + font-size: 1rem; +} + +.nextra-body-typesetting-article .docs-widget-header p { + margin: 0.45rem 0 0; + color: var(--page-fg-soft); +} + +.nextra-body-typesetting-article .docs-table-shell { + overflow-x: auto; + border: 1px solid var(--surface-border); + border-radius: 20px; + background: color-mix(in srgb, var(--surface-strong) 92%, transparent); + box-shadow: var(--shadow-sm); +} + +.nextra-body-typesetting-article .docs-widget-table { + width: 100%; + min-width: 720px; + margin: 0; + border: 0; + border-collapse: separate; + border-spacing: 0; + background: transparent; +} + +.nextra-body-typesetting-article .docs-widget-table thead { + background: color-mix(in srgb, var(--surface-subtle) 88%, transparent); +} + +.nextra-body-typesetting-article .docs-widget-table :where(th, td) { + vertical-align: top; + border-right: 1px solid var(--surface-border); + border-bottom: 1px solid var(--surface-border); +} + +.nextra-body-typesetting-article .docs-widget-table :where(th, td):last-child { + border-right: 0; +} + +.nextra-body-typesetting-article .docs-widget-table tbody tr:last-child td { + border-bottom: 0; +} + +.nextra-body-typesetting-article .docs-cell-heading { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.nextra-body-typesetting-article .docs-muted { + color: var(--page-fg-muted); +} + +.nextra-body-typesetting-article .docs-table-list, +.nextra-body-typesetting-article .docs-platform-card__highlights { + margin: 0; + padding-left: 1.1rem; +} + +.nextra-body-typesetting-article .docs-table-list li, +.nextra-body-typesetting-article .docs-platform-card__highlights li { + margin: 0.15rem 0; + color: var(--page-fg-soft); +} + +.nextra-body-typesetting-article .docs-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 0.7rem; + border: 1px solid var(--surface-border); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.nextra-body-typesetting-article .docs-badge--stable, +.nextra-body-typesetting-article .docs-badge--full { + border-color: rgba(20, 132, 86, 0.22); + background: rgba(20, 132, 86, 0.1); + color: #116149; +} + +.nextra-body-typesetting-article .docs-badge--partial, +.nextra-body-typesetting-article .docs-badge--beta { + border-color: rgba(180, 83, 9, 0.2); + background: rgba(180, 83, 9, 0.1); + color: #92400e; +} + +.nextra-body-typesetting-article .docs-badge--planned, +.nextra-body-typesetting-article .docs-badge--experimental, +.nextra-body-typesetting-article .docs-badge--info { + border-color: rgba(37, 99, 235, 0.18); + background: rgba(37, 99, 235, 0.08); + color: #1d4ed8; +} + +.nextra-body-typesetting-article .docs-badge--deprecated, +.nextra-body-typesetting-article .docs-badge--unsupported { + border-color: rgba(185, 28, 28, 0.18); + background: rgba(185, 28, 28, 0.08); + color: #b91c1c; +} + +.nextra-body-typesetting-article .docs-platform-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.nextra-body-typesetting-article .docs-platform-card { + position: relative; + overflow: hidden; + padding: 20px; + border: 1px solid var(--surface-border); + border-radius: 22px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 94%, transparent), var(--surface)), + var(--hero-glow); + box-shadow: var(--shadow-sm); +} + +.nextra-body-typesetting-article .docs-platform-card::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--surface-border-strong), transparent); +} + +.nextra-body-typesetting-article .docs-platform-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.nextra-body-typesetting-article .docs-platform-card__family { + display: inline-flex; + margin-bottom: 0.5rem; + color: var(--page-fg-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nextra-body-typesetting-article .docs-platform-card h3 { + margin: 0; + font-size: 1.02rem; +} + +.nextra-body-typesetting-article .docs-platform-card p { + margin: 0.75rem 0 0; +} + +.nextra-body-typesetting-article .docs-command-stack { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.nextra-body-typesetting-article .docs-command-chip { + display: block; + width: fit-content; + max-width: min(100%, 36rem); + padding: 0.45rem 0.6rem; + border: 1px solid var(--surface-border); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-subtle) 90%, transparent); + color: var(--page-fg); + white-space: pre-wrap; + word-break: break-word; +} + html.dark .home-topbar-nav a:hover, html.dark .docs-nav-link:hover, html.dark .docs-navbar-action:hover { @@ -1082,6 +1277,79 @@ html.dark .nextra-body-typesetting-article .mermaid-diagram__fallback { background: color-mix(in srgb, var(--surface-overlay) 96%, transparent) !important; } +html.dark .nextra-body-typesetting-article .docs-table-shell { + border-color: var(--surface-border); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-overlay) 96%, transparent), + color-mix(in srgb, var(--surface) 92%, transparent) + ); + box-shadow: + inset 0 1px 0 var(--surface-highlight), + var(--shadow-sm); +} + +html.dark .nextra-body-typesetting-article .docs-widget-table thead { + background: color-mix(in srgb, var(--surface-highlight-strong) 100%, var(--surface-elevated)); +} + +html.dark .nextra-body-typesetting-article .docs-widget-table :where(th, td) { + border-color: var(--surface-separator); +} + +html.dark .nextra-body-typesetting-article .docs-badge--stable, +html.dark .nextra-body-typesetting-article .docs-badge--full { + border-color: rgba(74, 222, 128, 0.18); + background: rgba(74, 222, 128, 0.1); + color: #86efac; +} + +html.dark .nextra-body-typesetting-article .docs-badge--partial, +html.dark .nextra-body-typesetting-article .docs-badge--beta { + border-color: rgba(251, 191, 36, 0.16); + background: rgba(251, 191, 36, 0.1); + color: #fcd34d; +} + +html.dark .nextra-body-typesetting-article .docs-badge--planned, +html.dark .nextra-body-typesetting-article .docs-badge--experimental, +html.dark .nextra-body-typesetting-article .docs-badge--info { + border-color: rgba(96, 165, 250, 0.16); + background: rgba(96, 165, 250, 0.1); + color: #93c5fd; +} + +html.dark .nextra-body-typesetting-article .docs-badge--deprecated, +html.dark .nextra-body-typesetting-article .docs-badge--unsupported { + border-color: rgba(248, 113, 113, 0.16); + background: rgba(248, 113, 113, 0.1); + color: #fca5a5; +} + +html.dark .nextra-body-typesetting-article .docs-platform-card { + border-color: var(--surface-border); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-overlay) 94%, transparent), + color-mix(in srgb, var(--surface) 92%, transparent) + ); + box-shadow: + inset 0 1px 0 var(--surface-highlight), + var(--shadow-sm); +} + +html.dark .nextra-body-typesetting-article .docs-platform-card::before { + background: linear-gradient(90deg, transparent, var(--surface-separator), transparent); +} + +html.dark .nextra-body-typesetting-article .docs-command-chip { + border-color: var(--surface-border); + background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-elevated)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + .nextra-footer { color: var(--page-fg-muted); } @@ -1150,6 +1418,10 @@ html.dark .nextra-body-typesetting-article .mermaid-diagram__fallback { grid-template-columns: minmax(0, 1fr); } + .nextra-body-typesetting-article .docs-platform-grid { + grid-template-columns: 1fr; + } + .home-hero h1 { max-width: 13ch; font-size: clamp(1.8rem, 10vw, 2.45rem); diff --git a/doc/app/layout.tsx b/doc/app/layout.tsx index 0fb0d53d..021acfb3 100644 --- a/doc/app/layout.tsx +++ b/doc/app/layout.tsx @@ -9,14 +9,16 @@ const docsThemeStorageKey = 'memory-sync-docs-theme' const docsThemeBootstrapScript = ` try { const storageKey = '${docsThemeStorageKey}'; - const storedTheme = window.localStorage.getItem(storageKey); - const normalizedTheme = storedTheme === 'light' ? 'light' : 'dark'; + const root = document.documentElement; + const normalizedTheme = 'dark'; - if (storedTheme !== normalizedTheme) { + if (window.localStorage.getItem(storageKey) !== normalizedTheme) { window.localStorage.setItem(storageKey, normalizedTheme); } - document.documentElement.classList.toggle('dark', normalizedTheme === 'dark'); + root.classList.remove('light', 'dark'); + root.classList.add(normalizedTheme); + root.style.colorScheme = normalizedTheme; } catch {} ` @@ -62,7 +64,7 @@ export const metadata: Metadata = { export default function RootLayout({children}: {readonly children: React.ReactNode}) { return ( - +