diff --git a/src/commands/init.ts b/src/commands/init.ts index 7c9ffad..d18cd9a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,9 +15,12 @@ import { getPlatformInstallers, } from '../installers/registry.js'; import { + type V3UnsafeAdapterPath, V3_ADAPTER_VERSION, assertV3AdapterInstallable, + inspectUnsafeV3AdapterPaths, inspectV3Adapter, + replaceUnsafeV3AdapterSymlinks, } from '../installers/v3-adapter.js'; import { detectTeamStatus } from '../system/detect-team.js'; import { detectSystemDeps } from '../system/detect.js'; @@ -753,6 +756,14 @@ async function initializeV3( return EXIT_INIT_FAILED; } try { + if (!existingV3) { + const unsafeExit = await resolveUnsafeInitAdapterPaths( + rootDir, + options, + selectedPlatforms, + ); + if (unsafeExit !== null) return unsafeExit; + } if ( existingV3 && selectedPlatforms.some((platform) => !registeredAdapters.has(platform)) @@ -808,6 +819,48 @@ async function initializeV3( } } +/** + * Interactive greenfield init: when a fixed adapter target is a symlink, + * offer the user a clean exit or replace the link with a regular file that + * preserves the resolved content, then let installation continue. + */ +async function resolveUnsafeInitAdapterPaths( + rootDir: string, + options: InitOptions, + selectedPlatforms: PlatformName[], +): Promise { + const prompter = + options.prompter ?? (options.interactive ? createTerminalPrompter() : null); + if (!prompter) return null; + const locale = detectInitLocale(options.lang) ?? 'en'; + const found: V3UnsafeAdapterPath[] = []; + const seen = new Set(); + for (const platform of selectedPlatforms) { + for (const entry of await inspectUnsafeV3AdapterPaths(rootDir, platform)) { + if (seen.has(entry.target)) continue; + seen.add(entry.target); + found.push(entry); + } + } + const fixable = found.filter( + (entry) => entry.kind === 'symlink' && entry.finalTarget, + ); + if (fixable.length === 0) return null; + + const choice = await prompter.resolveUnsafeAdapterPaths({ + locale, + paths: found.map(({ relative, resolvedTo }) => ({ relative, resolvedTo })), + }); + if (choice === 'exit') { + console.log( + locale === 'zh-CN' ? '已取消初始化。' : 'Initialization cancelled.', + ); + return EXIT_USER_CANCEL; + } + await replaceUnsafeV3AdapterSymlinks(fixable); + return null; +} + function printV3InitError(error: unknown): void { const message = error instanceof Error ? error.message : 'mancode initialization failed'; diff --git a/src/installers/v3-adapter.ts b/src/installers/v3-adapter.ts index a00b411..0323e7e 100644 --- a/src/installers/v3-adapter.ts +++ b/src/installers/v3-adapter.ts @@ -2349,10 +2349,10 @@ async function readAdapterTarget( return readFile(filePath, 'utf8'); } -async function assertPlatformAdapterPathsSafe( +function fixedAdapterTargetPaths( root: string, platform: PlatformName, -): Promise { +): string[] { const targets = new Set([ path.join(root, targetFor(platform)), ...V3_MODE_NAMES.map((mode) => v3ModeEntryPath(root, platform, mode)), @@ -2365,28 +2365,133 @@ async function assertPlatformAdapterPathsSafe( targets.add(retired.filePath); } } - for (const target of targets) { + return [...targets]; +} + +async function assertPlatformAdapterPathsSafe( + root: string, + platform: PlatformName, +): Promise { + for (const target of fixedAdapterTargetPaths(root, platform)) { await assertAdapterPathSafe(root, target); } } +/** One unsafe fixed adapter path, reported without writing anything. */ +export interface V3UnsafeAdapterPath { + /** Absolute path of the offending entry. */ + target: string; + /** Path relative to the project root. */ + relative: string; + kind: 'symlink' | 'not-directory' | 'outside-root' | 'root-symlink'; + /** True when the entry is the final fixed target (a file), false for parents. */ + finalTarget: boolean; + /** For symlinks: the resolved absolute path, or null when unresolvable. */ + resolvedTo: string | null; +} + +/** + * Reports every unsafe fixed adapter path for a platform without writing + * anything, so interactive flows can offer a remediation before installing. + */ +export async function inspectUnsafeV3AdapterPaths( + projectRoot: string, + platform: PlatformName, +): Promise { + const root = path.resolve(projectRoot); + const found: V3UnsafeAdapterPath[] = []; + const seen = new Set(); + for (const target of fixedAdapterTargetPaths(root, platform)) { + if (seen.has(target)) continue; + seen.add(target); + const unsafe = await findUnsafeAdapterPathEntry(root, target); + if (unsafe !== null) found.push(unsafe); + } + return found; +} + +/** + * Materializes fixable final-target symlinks as regular files that copy the + * resolved content, so a confirmed init can continue without losing what the + * link used to expose. Escaping parents and broken links are left untouched. + */ +export async function replaceUnsafeV3AdapterSymlinks( + entries: readonly V3UnsafeAdapterPath[], +): Promise { + for (const entry of entries) { + if ( + entry.kind !== 'symlink' || + !entry.finalTarget || + entry.resolvedTo === null + ) { + continue; + } + const resolvedEntry = await lstat(entry.resolvedTo).catch(() => null); + if (resolvedEntry === null || !resolvedEntry.isFile()) continue; + const content = await readFile(entry.resolvedTo); + await rm(entry.target, { force: true }); + await writeFile(entry.target, content); + } +} + /** Rejects a symlink or non-directory in any fixed adapter path segment. */ async function assertAdapterPathSafe( root: string, target: string, ): Promise { - const relative = path.relative(root, target); - if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + const unsafe = await findUnsafeAdapterPathEntry(root, target); + if (unsafe === null) return; + if (unsafe.kind === 'outside-root') { throw new Error( `MANCODE_ARTIFACT_PATH_UNSAFE: adapter target must stay inside the project root: ${target}`, ); } - const rootEntry = await lstat(root); - if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) { + if (unsafe.kind === 'root-symlink') { throw new Error( `MANCODE_ARTIFACT_PATH_UNSAFE: project root must be a real directory, not a symbolic link: ${root}`, ); } + if (unsafe.kind === 'not-directory') { + throw new Error( + `MANCODE_ARTIFACT_PATH_UNSAFE: ${unsafe.relative} cannot be used because ${path.basename(unsafe.target)} is not a directory`, + ); + } + const detail = unsafe.resolvedTo + ? ` (resolves to ${unsafe.resolvedTo})` + : ' (broken link)'; + const replacement = unsafe.finalTarget + ? 'a regular file' + : 'a real directory'; + throw new Error( + `MANCODE_ARTIFACT_PATH_UNSAFE: ${unsafe.relative} is a symbolic link${detail}; mancode never writes through links. Replace it with ${replacement} before initializing the adapter.`, + ); +} + +/** Finds the first unsafe entry in one fixed adapter path, or null when safe. */ +async function findUnsafeAdapterPathEntry( + root: string, + target: string, +): Promise { + const relative = path.relative(root, target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return { + target, + relative, + kind: 'outside-root', + finalTarget: false, + resolvedTo: null, + }; + } + const rootEntry = await lstat(root); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) { + return { + target: root, + relative, + kind: 'root-symlink', + finalTarget: false, + resolvedTo: null, + }; + } const segments = relative.split(path.sep); let current = root; for (let index = 0; index < segments.length; index += 1) { @@ -2394,31 +2499,36 @@ async function assertAdapterPathSafe( try { const entry = await lstat(current); if (entry.isSymbolicLink()) { - const detail = await describeAdapterSymlink(current); - const replacement = - index === segments.length - 1 ? 'a regular file' : 'a real directory'; - throw new Error( - `MANCODE_ARTIFACT_PATH_UNSAFE: ${relative} is a symbolic link${detail}; mancode never writes through links. Replace it with ${replacement} before initializing the adapter.`, - ); + return { + target: current, + relative, + kind: 'symlink', + finalTarget: index === segments.length - 1, + resolvedTo: await resolveAdapterSymlink(current), + }; } if (index < segments.length - 1 && !entry.isDirectory()) { - throw new Error( - `MANCODE_ARTIFACT_PATH_UNSAFE: ${relative} cannot be used because ${segments[index]} is not a directory`, - ); + return { + target: current, + relative, + kind: 'not-directory', + finalTarget: false, + resolvedTo: null, + }; } } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') return; + if (isNodeError(error) && error.code === 'ENOENT') return null; throw error; } } + return null; } -async function describeAdapterSymlink(linkPath: string): Promise { +async function resolveAdapterSymlink(linkPath: string): Promise { try { - const resolved = await realpath(linkPath); - return ` (resolves to ${resolved})`; + return await realpath(linkPath); } catch { - return ' (broken link)'; + return null; } } diff --git a/src/system/init-onboarding.ts b/src/system/init-onboarding.ts index 354ccb9..6237601 100644 --- a/src/system/init-onboarding.ts +++ b/src/system/init-onboarding.ts @@ -20,6 +20,10 @@ export interface InitPrompter { locale: InitLocale; detected: PlatformName[]; }): Promise; + resolveUnsafeAdapterPaths(context: { + locale: InitLocale; + paths: readonly { relative: string; resolvedTo: string | null }[]; + }): Promise<'replace' | 'exit'>; } const ALL_PLATFORMS = Object.keys(PLATFORM_INSTALLERS) as PlatformName[]; @@ -262,5 +266,35 @@ export function createTerminalPrompter(): InitPrompter { rl.close(); } }, + async resolveUnsafeAdapterPaths({ locale, paths }) { + const rl = createInterface({ input: stdin, output: stdout }); + try { + console.log( + locale === 'zh-CN' + ? '\n检测到适配器目标路径是符号链接(mancode 不会写入链接):' + : '\nAdapter target paths are symbolic links (mancode never writes through links):', + ); + for (const item of paths) { + const detail = item.resolvedTo ? ` -> ${item.resolvedTo}` : ''; + console.log(` ${item.relative}${detail}`); + } + console.log(locale === 'zh-CN' ? '1. 退出' : '1. Exit'); + console.log( + locale === 'zh-CN' + ? '2. 将符号链接替换为普通文件(保留原内容)并继续初始化' + : '2. Replace the symbolic link(s) with regular file(s) (content preserved) and continue', + ); + const answer = ( + await rl.question( + locale === 'zh-CN' ? '选择 [1/2]: ' : 'Choose [1/2]: ', + ) + ) + .trim() + .toLowerCase(); + return answer === '2' ? 'replace' : 'exit'; + } finally { + rl.close(); + } + }, }; } diff --git a/tests/init-onboarding.test.ts b/tests/init-onboarding.test.ts index d1b60eb..fb95544 100644 --- a/tests/init-onboarding.test.ts +++ b/tests/init-onboarding.test.ts @@ -43,6 +43,7 @@ const PLATFORM_HINT_ENV_VARS = [ 'CURSOR_TRACE_ID', 'COPILOT_AGENT', 'GITHUB_COPILOT', + 'DSH_SHELL', ] as const; describe('init onboarding', () => { diff --git a/tests/v3-adapter-contracts.test.ts b/tests/v3-adapter-contracts.test.ts index b8d8b68..eba1e7f 100644 --- a/tests/v3-adapter-contracts.test.ts +++ b/tests/v3-adapter-contracts.test.ts @@ -1,4 +1,11 @@ -import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { + lstat, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -19,9 +26,11 @@ import type { PlatformName } from '../src/installers/registry.js'; import { V3_ADAPTER_PLATFORMS, V3_MODE_NAMES, + inspectUnsafeV3AdapterPaths, inspectV3Adapter, installV3Adapter, removeV3Adapter, + replaceUnsafeV3AdapterSymlinks, stageV3Adapter, v3ModeEntryPath, } from '../src/installers/v3-adapter.js'; @@ -665,6 +674,74 @@ describe('V3 adapter bootstrap integration', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'reports an in-repo symlinked fixed target through the inspection API', + async () => { + await init(root, { v3: true, platform: 'codex' }); + await symlink('AGENTS.md', path.join(root, 'CLAUDE.md')); + + const found = await inspectUnsafeV3AdapterPaths(root, 'claude-code'); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ + relative: 'CLAUDE.md', + kind: 'symlink', + finalTarget: true, + }); + expect(found[0]?.resolvedTo?.endsWith('AGENTS.md')).toBe(true); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'materializes a symlinked fixed target as a regular file with preserved content', + async () => { + await init(root, { v3: true, platform: 'codex' }); + await writeFile( + path.join(root, 'AGENTS.md'), + '# shared agent instructions\n', + ); + await symlink('AGENTS.md', path.join(root, 'CLAUDE.md')); + + const found = await inspectUnsafeV3AdapterPaths(root, 'claude-code'); + await replaceUnsafeV3AdapterSymlinks(found); + + const entry = await lstat(path.join(root, 'CLAUDE.md')); + expect(entry.isSymbolicLink()).toBe(false); + await expect( + readFile(path.join(root, 'CLAUDE.md'), 'utf8'), + ).resolves.toBe('# shared agent instructions\n'); + + const status = await installV3Adapter(root, 'claude-code'); + expect(status.ready).toBe(true); + const content = await readFile(path.join(root, 'CLAUDE.md'), 'utf8'); + expect(content).toContain('# shared agent instructions'); + expect(content).toContain('mancode:continuity:claude:start'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'leaves an escaping symlinked parent unreplaced and still rejects install', + async () => { + await init(root, { v3: true }); + const outside = `${root}-outside`; + await mkdir(outside, { recursive: true }); + try { + await symlink(outside, path.join(root, '.agents')); + + const found = await inspectUnsafeV3AdapterPaths(root, 'codex'); + expect(found.some((entry) => entry.kind === 'symlink')).toBe(true); + await replaceUnsafeV3AdapterSymlinks(found); + await expect(installV3Adapter(root, 'codex')).rejects.toThrow( + 'MANCODE_ARTIFACT_PATH_UNSAFE', + ); + await expect( + readFile(path.join(outside, 'AGENTS.md')), + ).rejects.toThrow(); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }, + ); + it('retires legacy managed entrypoints when repairing an active V3 adapter', async () => { await init(root, { v3: true }); const legacyCodexAlias = path.join( diff --git a/tests/v3-init-command.test.ts b/tests/v3-init-command.test.ts index 07a293f..fb62d4c 100644 --- a/tests/v3-init-command.test.ts +++ b/tests/v3-init-command.test.ts @@ -1,4 +1,11 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + lstat, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import os, { tmpdir } from 'node:os'; import path from 'node:path'; import { @@ -16,6 +23,7 @@ import { EXIT_INIT_FAILED, EXIT_NOT_A_PROJECT_DIR, EXIT_OK, + EXIT_USER_CANCEL, init, resolveInitAuthority, } from '../src/commands/init.js'; @@ -30,6 +38,7 @@ const PLATFORM_HINT_ENV_VARS = [ 'CURSOR_TRACE_ID', 'COPILOT_AGENT', 'GITHUB_COPILOT', + 'DSH_SHELL', ] as const; describe('journaled V3 init command', () => { @@ -98,6 +107,7 @@ describe('journaled V3 init command', () => { return true; }, selectPlatforms: async () => ['cursor'], + resolveUnsafeAdapterPaths: async () => 'exit', }, }); @@ -281,6 +291,7 @@ describe('journaled V3 init command', () => { platformPrompted = true; return ['codex']; }, + resolveUnsafeAdapterPaths: async () => 'exit', }, }), ).toBe(EXIT_INIT_FAILED); @@ -323,4 +334,67 @@ describe('journaled V3 init command', () => { readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), ).rejects.toThrow(); }); + + it.skipIf(process.platform === 'win32')( + 'replaces a symlinked adapter target with a regular file when confirmed', + async () => { + await mkdir(path.join(root, '.git')); + await writeFile( + path.join(root, 'AGENTS.md'), + '# shared agent instructions\n', + ); + await symlink('AGENTS.md', path.join(root, 'CLAUDE.md')); + let promptedPaths: string[] = []; + + const result = await init(root, { + fromCli: true, + interactive: true, + prompter: { + confirmGenericProject: async () => true, + selectPlatforms: async () => ['claude-code'], + resolveUnsafeAdapterPaths: async (context) => { + promptedPaths = context.paths.map((item) => item.relative); + return 'replace'; + }, + }, + }); + + expect(result).toBe(EXIT_OK); + expect(promptedPaths).toContain('CLAUDE.md'); + const entry = await lstat(path.join(root, 'CLAUDE.md')); + expect(entry.isSymbolicLink()).toBe(false); + const content = await readFile(path.join(root, 'CLAUDE.md'), 'utf8'); + expect(content).toContain('# shared agent instructions'); + expect(content).toContain('mancode:continuity:claude:start'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'lets the user exit cleanly instead of touching the symlink', + async () => { + await mkdir(path.join(root, '.git')); + await writeFile( + path.join(root, 'AGENTS.md'), + '# shared agent instructions\n', + ); + await symlink('AGENTS.md', path.join(root, 'CLAUDE.md')); + + const result = await init(root, { + fromCli: true, + interactive: true, + prompter: { + confirmGenericProject: async () => true, + selectPlatforms: async () => ['claude-code'], + resolveUnsafeAdapterPaths: async () => 'exit', + }, + }); + + expect(result).toBe(EXIT_USER_CANCEL); + const entry = await lstat(path.join(root, 'CLAUDE.md')); + expect(entry.isSymbolicLink()).toBe(true); + await expect( + readFile(path.join(root, '.mancode', 'schema.json'), 'utf8'), + ).rejects.toThrow(); + }, + ); });