import { existsSync, lstatSync, mkdirSync } from 'node:fs';
import { copyFile, cp, mkdir, stat } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
import { getAgentDir } from '../../config.js';
/**
 * Low-level filesystem contract for skill proposals.
 *
 * Threat model: the only "attacker" is our own LLM-driven fork acting through
 * tool calls we authored. We need to reject `..`-style lexical escapes from
 * model-supplied paths and reject symlinks at the immediate source we are
 * about to copy. We do not defend against an attacker who can plant symlinks
 * inside `~/.shortcut/agent/` — at that point the host is already compromised.
 */
const SKILL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
const VERSIONED_SKILL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:_(?:[2-9]|[1-9][0-9]+))?$/;
/** Resolve the proposal/live-skill/backup roots under the configured agent dir. */
export function getSkillProposalRoots(agentDir = getAgentDir()) {
    const resolvedAgentDir = resolve(agentDir);
    return {
        agentDir: resolvedAgentDir,
        proposalsRoot: join(resolvedAgentDir, 'skill-proposals'),
        liveSkillsRoot: join(resolvedAgentDir, 'skills'),
        skillBackupsRoot: join(resolvedAgentDir, 'skill-backups')
    };
}
/** Create the proposal and live-skill directories on disk if they do not already exist. */
export function initializeSkillProposalRoots(roots) {
    mkdirSync(roots.proposalsRoot, { recursive: true });
    mkdirSync(roots.liveSkillsRoot, { recursive: true });
}
/** Throw unless `name` matches the canonical skill-id pattern (lowercase, digits, hyphens). */
export function validateSkillName(name) {
    if (!SKILL_NAME_PATTERN.test(name)) {
        throw new Error(`Invalid skill name: ${name}`);
    }
}
/** Throw unless `name` is a valid skill-id, optionally with a `_<N>` version suffix. */
export function validateLiveSkillName(name) {
    if (!VERSIONED_SKILL_NAME_PATTERN.test(name)) {
        throw new Error(`Invalid live skill name: ${name}`);
    }
}
/** Return the absolute path of the proposal folder for `name` after validating the name. */
export function proposalDir(roots, name) {
    validateSkillName(name);
    return join(roots.proposalsRoot, name);
}
/** Return the absolute path of the live-skill folder for `name` after validating the name. */
export function liveSkillDir(roots, name) {
    validateLiveSkillName(name);
    return join(roots.liveSkillsRoot, name);
}
/** True if `candidate` resolves to `root` or a descendant, with no `..` escape. */
export function isPathInside(root, candidate) {
    const rel = relative(resolve(root), resolve(candidate));
    return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}
/** Return the first root that contains `candidate`, or undefined if none does. */
export function findAllowedRoot(roots, candidate) {
    return roots.find((root) => isPathInside(root, candidate));
}
/** Throw if `candidate` is not inside `root`, using `label` in the error message. */
export function assertInside(root, candidate, label) {
    if (!isPathInside(root, candidate)) {
        throw new Error(`${label} is outside allowed root: ${candidate}`);
    }
}
/** Return the proposal-folder name for a path inside the proposals root, validating the segment. */
export function getProposalNameForPath(roots, candidate) {
    const resolved = resolve(candidate);
    assertInside(roots.proposalsRoot, resolved, 'Proposal path');
    const [name] = relative(roots.proposalsRoot, resolved).split(/[\\/]/);
    if (!name) {
        throw new Error('Path must be inside a proposal folder');
    }
    validateSkillName(name);
    return name;
}
/** Copy a file or directory from a live-skill folder into a proposal, rejecting symlinks at the source. */
export async function copyFromSkillIntoProposal(roots, input) {
    // This is the one operation scoped file tools cannot do faithfully for binary/nested assets.
    validateSkillName(input.targetSkillName);
    validateLiveSkillName(input.sourceSkillName);
    const targetProposalDir = proposalDir(roots, input.targetSkillName);
    const sourceSkillDir = liveSkillDir(roots, input.sourceSkillName);
    assertLiveSourceSkillDirectory(sourceSkillDir);
    const requestedSource = input.sourceRelativePath || '.';
    const source = resolve(sourceSkillDir, requestedSource);
    assertInside(sourceSkillDir, source, 'Copy source');
    if (!existsSync(source)) {
        throw new Error(`Copy source does not exist: ${input.sourceSkillName}/${input.sourceRelativePath}`);
    }
    if (lstatSync(source).isSymbolicLink()) {
        throw new Error(`Copy source contains unsupported symlink: ${relative(sourceSkillDir, source)}`);
    }
    const sourceStats = await stat(source);
    const destinationRelative = input.destinationRelativePath && input.destinationRelativePath.trim().length > 0
        ? input.destinationRelativePath
        : normalize(requestedSource) === '.'
            ? '.'
            : basename(source);
    const destination = resolve(targetProposalDir, destinationRelative);
    assertInside(targetProposalDir, destination, 'Copy destination');
    if (sourceStats.isFile() && destination === targetProposalDir) {
        throw new Error('Copy file destination must name a file inside the proposal folder');
    }
    await mkdir(dirname(destination), { recursive: true });
    if (sourceStats.isDirectory()) {
        await cp(source, destination, { recursive: true, force: true });
    }
    else {
        await copyFile(source, destination);
    }
    return { copiedTo: destination };
}
/** Throw unless the live-skill source path exists, is a directory, and is not a symlink. */
function assertLiveSourceSkillDirectory(sourceSkillDir) {
    let stats;
    try {
        stats = lstatSync(sourceSkillDir);
    }
    catch {
        throw new Error(`Live skill source does not exist: ${sourceSkillDir}`);
    }
    if (stats.isSymbolicLink()) {
        throw new Error(`Live skill source must not be a symlink: ${sourceSkillDir}`);
    }
    if (!stats.isDirectory()) {
        throw new Error(`Live skill source must be a directory: ${sourceSkillDir}`);
    }
}
//# sourceMappingURL=proposal-fs.js.map