Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<number | null> {
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<string>();
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';
Expand Down
152 changes: 131 additions & 21 deletions src/installers/v3-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2349,10 +2349,10 @@ async function readAdapterTarget(
return readFile(filePath, 'utf8');
}

async function assertPlatformAdapterPathsSafe(
function fixedAdapterTargetPaths(
root: string,
platform: PlatformName,
): Promise<void> {
): string[] {
const targets = new Set<string>([
path.join(root, targetFor(platform)),
...V3_MODE_NAMES.map((mode) => v3ModeEntryPath(root, platform, mode)),
Expand All @@ -2365,60 +2365,170 @@ async function assertPlatformAdapterPathsSafe(
targets.add(retired.filePath);
}
}
for (const target of targets) {
return [...targets];
}

async function assertPlatformAdapterPathsSafe(
root: string,
platform: PlatformName,
): Promise<void> {
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<V3UnsafeAdapterPath[]> {
const root = path.resolve(projectRoot);
const found: V3UnsafeAdapterPath[] = [];
const seen = new Set<string>();
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<void> {
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<void> {
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<V3UnsafeAdapterPath | null> {
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) {
current = path.join(current, segments[index] ?? '');
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<string> {
async function resolveAdapterSymlink(linkPath: string): Promise<string | null> {
try {
const resolved = await realpath(linkPath);
return ` (resolves to ${resolved})`;
return await realpath(linkPath);
} catch {
return ' (broken link)';
return null;
}
}

Expand Down
34 changes: 34 additions & 0 deletions src/system/init-onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export interface InitPrompter {
locale: InitLocale;
detected: PlatformName[];
}): Promise<PlatformName[] | null>;
resolveUnsafeAdapterPaths(context: {
locale: InitLocale;
paths: readonly { relative: string; resolvedTo: string | null }[];
}): Promise<'replace' | 'exit'>;
}

const ALL_PLATFORMS = Object.keys(PLATFORM_INSTALLERS) as PlatformName[];
Expand Down Expand Up @@ -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();
}
},
};
}
1 change: 1 addition & 0 deletions tests/init-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const PLATFORM_HINT_ENV_VARS = [
'CURSOR_TRACE_ID',
'COPILOT_AGENT',
'GITHUB_COPILOT',
'DSH_SHELL',
] as const;

describe('init onboarding', () => {
Expand Down
Loading
Loading