← Files ModRetro Chromatic PluginARCHIVED FILE

dist/project-files.js

31.1 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { link, lstat, mkdir, open, readFile, realpath, rename, rm, unlink, } from "node:fs/promises";
import { hostname, userInfo } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { assertSafePlatformPath, isPathWithinRoot, platformPath } from "./platform.js";
import { GameStudioProjectError } from "./project.js";
function isSystemError(error, code) {
    return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function validateProjectPath(candidate, platform) {
    try {
        assertSafePlatformPath(candidate, platform);
    }
    catch (error) {
        throw new GameStudioProjectError("INVALID_RESOURCE_PATH", `Invalid native game project resource path: ${error instanceof Error ? error.message : String(error)}`, candidate);
    }
}
function platformCandidate(candidate, operations, platform) {
    // Simulated Windows fixtures live on the host's real POSIX filesystem. Still
    // honor both Windows separators so their confinement checks remain genuine.
    return platform === "win32" && operations === path.posix
        ? candidate.replace(/\\/g, "/")
        : candidate;
}
function absoluteProjectPath(root, candidate, platform = process.platform) {
    const operations = platformPath(root, platform);
    const normalizedRoot = platformCandidate(root, operations, platform);
    const normalizedCandidate = platformCandidate(candidate, operations, platform);
    return operations.isAbsolute(normalizedCandidate)
        ? operations.resolve(normalizedCandidate)
        : operations.resolve(normalizedRoot, normalizedCandidate);
}
/** Reject lexical traversal before following or creating any filesystem entry. */
export function assertWithinProject(root, candidate, platform = process.platform) {
    validateProjectPath(root, platform);
    validateProjectPath(candidate, platform);
    const operations = platformPath(root, platform);
    const absoluteRoot = operations.resolve(platformCandidate(root, operations, platform));
    const absoluteCandidate = absoluteProjectPath(absoluteRoot, candidate, platform);
    if (!isPathWithinRoot(absoluteRoot, absoluteCandidate, platform)) {
        throw new GameStudioProjectError("PATH_OUTSIDE_PROJECT", `Refusing to access a path outside the native game project: ${absoluteCandidate}`, absoluteCandidate);
    }
}
export function projectRelativePath(root, candidate, platform = process.platform) {
    assertWithinProject(root, candidate, platform);
    const operations = platformPath(root, platform);
    return operations.relative(operations.resolve(platformCandidate(root, operations, platform)), absoluteProjectPath(root, candidate, platform)).split(operations.sep).join("/");
}
async function assertExistingWindowsAncestorsStayInside(absoluteRoot, absoluteCandidate, canonicalRoot, operations, platform) {
    if (platform !== "win32")
        return;
    const relative = operations.relative(absoluteRoot, absoluteCandidate);
    if (relative === "")
        return;
    let ancestor = absoluteRoot;
    for (const component of relative.split(operations.sep)) {
        ancestor = operations.join(ancestor, component);
        try {
            assertWithinProject(canonicalRoot, await realpath(ancestor), platform);
        }
        catch (error) {
            if (isSystemError(error, "ENOENT"))
                return;
            throw error;
        }
    }
}
/** Resolve existing resources or proposed descendants without trusting escaping symlinks. */
export async function resolveProjectPath(root, candidate, options = {}) {
    const platform = options.platform ?? process.platform;
    validateProjectPath(root, platform);
    validateProjectPath(candidate, platform);
    const operations = platformPath(root, platform);
    const absoluteRoot = operations.resolve(platformCandidate(root, operations, platform));
    const absoluteCandidate = absoluteProjectPath(absoluteRoot, candidate, platform);
    assertWithinProject(absoluteRoot, absoluteCandidate, platform);
    let canonicalRoot;
    try {
        canonicalRoot = await realpath(absoluteRoot);
    }
    catch (error) {
        if (isSystemError(error, "ENOENT")) {
            throw new GameStudioProjectError("RESOURCE_NOT_FOUND", `Native game project root does not exist: ${absoluteRoot}`, absoluteRoot);
        }
        throw error;
    }
    // Windows junctions and other reparse points need not advertise themselves
    // as symbolic links. Check every existing component, not just its endpoint.
    await assertExistingWindowsAncestorsStayInside(absoluteRoot, absoluteCandidate, canonicalRoot, operations, platform);
    let existingAncestor = absoluteCandidate;
    let canonicalAncestor;
    while (true) {
        try {
            canonicalAncestor = await realpath(existingAncestor);
            break;
        }
        catch (error) {
            if (!isSystemError(error, "ENOENT"))
                throw error;
            try {
                const entry = await lstat(existingAncestor);
                if (entry.isSymbolicLink()) {
                    throw new GameStudioProjectError("UNSAFE_SYMLINK", `Refusing to access a dangling symbolic link: ${existingAncestor}`, existingAncestor);
                }
            }
            catch (entryError) {
                if (!isSystemError(entryError, "ENOENT"))
                    throw entryError;
            }
            if (options.mustExist) {
                throw new GameStudioProjectError("RESOURCE_NOT_FOUND", `Resource does not exist: ${absoluteCandidate}`, absoluteCandidate);
            }
            existingAncestor = operations.dirname(existingAncestor);
        }
    }
    assertWithinProject(canonicalRoot, canonicalAncestor, platform);
    const resolved = operations.resolve(canonicalAncestor, operations.relative(existingAncestor, absoluteCandidate));
    assertWithinProject(canonicalRoot, resolved, platform);
    if (options.allowRoot === false && operations.relative(canonicalRoot, resolved) === "") {
        throw new GameStudioProjectError("INVALID_RESOURCE_PATH", "A native project resource path must identify a descendant of the project root", absoluteCandidate);
    }
    return resolved;
}
export async function projectPathExists(root, candidate) {
    try {
        await resolveProjectPath(root, candidate, { mustExist: true });
        return true;
    }
    catch (error) {
        if (error instanceof GameStudioProjectError && error.code === "RESOURCE_NOT_FOUND")
            return false;
        throw error;
    }
}
/** Parse and hash one confined resource from exactly the same filesystem read. */
export async function readProjectJsonWithRevision(root, candidate) {
    const resourcePath = await resolveProjectPath(root, candidate, { mustExist: true, allowRoot: false });
    let parsed;
    let content;
    try {
        content = await readFile(resourcePath);
        parsed = JSON.parse(content.toString("utf8"));
    }
    catch (error) {
        throw new GameStudioProjectError("INVALID_RESOURCE", `Could not parse native project resource ${projectRelativePath(await realpath(root), resourcePath)}: ${error instanceof Error ? error.message : String(error)}`, resourcePath);
    }
    if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
        throw new GameStudioProjectError("INVALID_RESOURCE", `Native project resource must contain a JSON object: ${resourcePath}`, resourcePath);
    }
    return {
        resourcePath,
        json: parsed,
        revision: createHash("sha256").update(content).digest("hex"),
    };
}
export async function readProjectJson(root, candidate) {
    return (await readProjectJsonWithRevision(root, candidate)).json;
}
const RESOURCE_LOCK_TIMEOUT_MS = 5_000;
const RESOURCE_LOCK_POLL_MS = 25;
const PROJECT_LOCK_RECORD_MAX_BYTES = 1_024;
const PROJECT_RECOVERY_MAX_DEPTH = 8;
function resourceLockError(resourcePath, detail) {
    return new GameStudioProjectError("RESOURCE_WRITE_LOCKED", `${detail} No unverified lock was removed; retry only after the blocking owner is known to have released it.`, resourcePath);
}
function resourceLockOwner() {
    if (process.platform === "win32") {
        const user = userInfo();
        const identity = createHash("sha256").update(`${user.homedir.toLowerCase()}\0${user.username.toLowerCase()}`).digest("hex");
        // os.tmpdir() honors caller-controlled TEMP; independent plugin processes must choose the same root.
        return { key: identity, temporaryBase: path.join(user.homedir, "AppData", "Local", "Temp") };
    }
    const uid = BigInt(process.geteuid?.() ?? process.getuid?.() ?? userInfo().uid);
    // /tmp is the host-owned shared namespace; on macOS realpath resolves it to /private/tmp.
    return { uid, key: String(uid), temporaryBase: "/tmp" };
}
function privateResourceLockEntry(entry, uid) {
    return uid === undefined || (entry.uid === uid && (entry.mode & 63n) === 0n);
}
async function resourceLockDirectory(resourcePath) {
    try {
        const owner = resourceLockOwner();
        const temporaryBase = await realpath(owner.temporaryBase);
        const directory = path.join(temporaryBase, `codex-gb-studio-resource-locks-v1-${owner.key}`);
        try {
            await mkdir(directory, { mode: 0o700 });
        }
        catch (error) {
            if (!isSystemError(error, "EEXIST"))
                throw error;
        }
        const entry = await lstat(directory, { bigint: true });
        if (!entry.isDirectory() || entry.isSymbolicLink() || !privateResourceLockEntry(entry, owner.uid)
            || await realpath(directory) !== directory) {
            throw new Error("the per-user lock directory is not a private canonical directory");
        }
        return { directory, uid: owner.uid };
    }
    catch (error) {
        throw resourceLockError(resourcePath, `Cannot establish the private project-resource lock directory: ${error instanceof Error ? error.message : String(error)}.`);
    }
}
async function resourceLockFile(lockPath, resourcePath, uid, maximumLinks = 1n) {
    try {
        const entry = await lstat(lockPath, { bigint: true });
        if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink < 1n || entry.nlink > maximumLinks
            || !privateResourceLockEntry(entry, uid)) {
            throw resourceLockError(resourcePath, "The existing project-resource lock is not a private regular file.");
        }
        return entry;
    }
    catch (error) {
        if (isSystemError(error, "ENOENT"))
            return undefined;
        throw error;
    }
}
function sameResourceLock(left, right) {
    return left.dev === right.dev && left.ino === right.ino;
}
function projectLockRecord(scope, token) {
    return { version: 2, scope, token, pid: process.pid, hostname: hostname(), acquiredAt: new Date().toISOString() };
}
function parseRecoverableProjectLockRecord(content, scope) {
    try {
        const value = JSON.parse(content.toString("utf8"));
        if (typeof value !== "object" || value === null || Array.isArray(value))
            return undefined;
        const record = value;
        if (record.version !== 2 || record.scope !== scope || typeof record.token !== "string"
            || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(record.token)
            || typeof record.pid !== "number" || !Number.isSafeInteger(record.pid) || record.pid < 1 || record.pid > 2_147_483_647
            || typeof record.hostname !== "string" || record.hostname.length < 1 || record.hostname !== hostname()
            || typeof record.acquiredAt !== "string" || !Number.isFinite(Date.parse(record.acquiredAt))
            || new Date(record.acquiredAt).toISOString() !== record.acquiredAt)
            return undefined;
        return record;
    }
    catch {
        return undefined;
    }
}
async function readRecoverableProjectLock(lockPath, resourcePath, uid, scope) {
    const maximumLinks = 2n;
    const initial = await resourceLockFile(lockPath, resourcePath, uid, maximumLinks);
    if (!initial || initial.size < 1n || initial.size > BigInt(PROJECT_LOCK_RECORD_MAX_BYTES))
        return undefined;
    let handle;
    try {
        handle = await open(lockPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
    }
    catch (error) {
        if (isSystemError(error, "ENOENT"))
            return undefined;
        throw resourceLockError(resourcePath, `Cannot inspect a project lock owner: ${error instanceof Error ? error.message : String(error)}.`);
    }
    try {
        const opened = await handle.stat({ bigint: true });
        if (!sameResourceLock(initial, opened) || opened.size !== initial.size
            || opened.mtimeNs !== initial.mtimeNs || opened.ctimeNs !== initial.ctimeNs)
            return undefined;
        const content = Buffer.alloc(PROJECT_LOCK_RECORD_MAX_BYTES + 1);
        const { bytesRead } = await handle.read(content, 0, content.length, 0);
        const closed = await handle.stat({ bigint: true });
        if (!sameResourceLock(opened, closed) || closed.size !== BigInt(bytesRead)
            || opened.mtimeNs !== closed.mtimeNs || opened.ctimeNs !== closed.ctimeNs)
            return undefined;
        const record = parseRecoverableProjectLockRecord(content.subarray(0, bytesRead), scope);
        if (!record)
            return undefined;
        const current = await resourceLockFile(lockPath, resourcePath, uid, maximumLinks);
        if (!current || !sameResourceLock(closed, current) || current.size !== closed.size
            || current.mtimeNs !== closed.mtimeNs || current.ctimeNs !== closed.ctimeNs)
            return undefined;
        if (current.nlink === 2n) {
            const staging = await resourceLockFile(projectRecoveryPendingPath(lockPath, record.token), resourcePath, uid, maximumLinks);
            if (!staging || !sameResourceLock(current, staging))
                return undefined;
        }
        return { entry: current, record };
    }
    finally {
        await handle.close();
    }
}
function sameRecoverableProjectLock(left, right) {
    return sameResourceLock(left.entry, right.entry) && left.record.scope === right.record.scope
        && left.record.token === right.record.token && left.record.pid === right.record.pid
        && left.record.hostname === right.record.hostname && left.record.acquiredAt === right.record.acquiredAt;
}
function projectLockOwnerIsDead(record) {
    if (record.hostname !== hostname() || record.pid === process.pid)
        return false;
    try {
        process.kill(record.pid, 0);
        return false;
    }
    catch (error) {
        return isSystemError(error, "ESRCH");
    }
}
function projectRecoveryPath(lockPath, token) {
    const key = createHash("sha256").update(`project-recovery\0${lockPath}\0${token}`).digest("hex");
    return path.join(path.dirname(lockPath), `${key}.recovery`);
}
function projectRecoveryPendingPath(electionPath, token) {
    return `${electionPath}.${token}.pending`;
}
/** Atomically publish already-complete project/election metadata so death cannot strand an empty lock. */
async function publishRecoverableProjectLock(lockPath, resourcePath, scope) {
    const token = randomUUID();
    const pendingPath = projectRecoveryPendingPath(lockPath, token);
    let pending;
    let published = false;
    try {
        pending = await open(pendingPath, "wx", 0o600);
        await pending.writeFile(JSON.stringify(projectLockRecord(scope, token)));
        await pending.sync();
        try {
            await link(pendingPath, lockPath);
            published = true;
        }
        catch (error) {
            if (isSystemError(error, "EEXIST"))
                return undefined;
            throw resourceLockError(resourcePath, `Cannot publish private ${scope} lock metadata: ${error instanceof Error ? error.message : String(error)}.`);
        }
    }
    finally {
        let stagingRemoved = false;
        try {
            try {
                await unlink(pendingPath);
            }
            catch (error) {
                if (!isSystemError(error, "ENOENT"))
                    throw error;
            }
            stagingRemoved = true;
        }
        catch (error) {
            if (published && pending) {
                const held = await pending.stat({ bigint: true });
                const current = await readRecoverableProjectLock(lockPath, resourcePath, resourceLockOwner().uid, scope);
                if (current?.record.token === token && sameResourceLock(held, current.entry)) {
                    try {
                        await unlink(lockPath);
                    }
                    catch (releaseError) {
                        if (!isSystemError(releaseError, "ENOENT"))
                            throw releaseError;
                    }
                }
            }
            throw resourceLockError(resourcePath, `Cannot remove a ${scope} lock staging file: ${error instanceof Error ? error.message : String(error)}.`);
        }
        finally {
            if ((!published || !stagingRemoved) && pending)
                await pending.close();
        }
    }
    return pending ? { token, handle: pending } : undefined;
}
async function publishProjectRecoveryElection(electionPath, resourcePath) {
    const published = await publishRecoverableProjectLock(electionPath, resourcePath, "project-recovery");
    if (!published)
        return undefined;
    await published.handle.close();
    return published.token;
}
async function releaseProjectRecoveryElection(electionPath, resourcePath, uid, token) {
    let handle;
    try {
        handle = await open(electionPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
    }
    catch (error) {
        throw resourceLockError(resourcePath, `Cannot release a project lock recovery election: ${error instanceof Error ? error.message : String(error)}.`);
    }
    await releaseResourceLock(handle, electionPath, resourcePath, uid, token);
}
/** Caller holds the token-specific election, so cooperating successors cannot be mistaken for this owner. */
async function removeVerifiedDeadProjectLock(lockPath, resourcePath, uid, stale) {
    const current = await readRecoverableProjectLock(lockPath, resourcePath, uid, stale.record.scope);
    if (!current || !sameRecoverableProjectLock(stale, current) || !projectLockOwnerIsDead(current.record))
        return false;
    const confirmed = await readRecoverableProjectLock(lockPath, resourcePath, uid, stale.record.scope);
    if (!confirmed || !sameRecoverableProjectLock(current, confirmed) || !projectLockOwnerIsDead(confirmed.record))
        return false;
    try {
        await unlink(lockPath);
    }
    catch (error) {
        if (isSystemError(error, "ENOENT"))
            return false;
        throw resourceLockError(resourcePath, `Cannot remove a verified dead project lock owner: ${error instanceof Error ? error.message : String(error)}.`);
    }
    // Any publisher can die between publishing its hard link and removing the unique staging link.
    const pendingPath = projectRecoveryPendingPath(lockPath, stale.record.token);
    const pending = await resourceLockFile(pendingPath, resourcePath, uid);
    if (pending && sameResourceLock(pending, confirmed.entry)) {
        try {
            await unlink(pendingPath);
        }
        catch (error) {
            if (!isSystemError(error, "ENOENT"))
                throw error;
        }
    }
    return true;
}
async function acquireProjectRecoveryElection(electionPath, resourcePath, uid, depth = 0) {
    const existing = await resourceLockFile(electionPath, resourcePath, uid, 2n);
    const token = existing ? undefined : await publishProjectRecoveryElection(electionPath, resourcePath);
    if (token)
        return token;
    if (depth >= PROJECT_RECOVERY_MAX_DEPTH)
        return undefined;
    const stale = await readRecoverableProjectLock(electionPath, resourcePath, uid, "project-recovery");
    if (!stale || !projectLockOwnerIsDead(stale.record))
        return undefined;
    const nextPath = projectRecoveryPath(electionPath, stale.record.token);
    const nextToken = await acquireProjectRecoveryElection(nextPath, resourcePath, uid, depth + 1);
    if (!nextToken)
        return undefined;
    try {
        await removeVerifiedDeadProjectLock(electionPath, resourcePath, uid, stale);
    }
    finally {
        await releaseProjectRecoveryElection(nextPath, resourcePath, uid, nextToken);
    }
    return publishProjectRecoveryElection(electionPath, resourcePath);
}
async function recoverDeadProjectWriteLock(lockPath, resourcePath, uid) {
    const stale = await readRecoverableProjectLock(lockPath, resourcePath, uid, "project");
    if (!stale || !projectLockOwnerIsDead(stale.record))
        return false;
    const electionPath = projectRecoveryPath(lockPath, stale.record.token);
    const electionToken = await acquireProjectRecoveryElection(electionPath, resourcePath, uid);
    if (!electionToken)
        return false;
    try {
        return await removeVerifiedDeadProjectLock(lockPath, resourcePath, uid, stale);
    }
    finally {
        await releaseProjectRecoveryElection(electionPath, resourcePath, uid, electionToken);
    }
}
async function releaseResourceLock(handle, lockPath, resourcePath, uid, token) {
    let held;
    try {
        held = await handle.stat({ bigint: true });
    }
    finally {
        await handle.close();
    } // Do not depend on the operating system's open-file deletion semantics.
    const current = await resourceLockFile(lockPath, resourcePath, uid);
    if (!current || !sameResourceLock(held, current)) {
        throw resourceLockError(resourcePath, "The project-resource lock changed before this writer could release it.");
    }
    if (token !== undefined) {
        let disk;
        try {
            disk = await open(lockPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
        }
        catch (error) {
            throw resourceLockError(resourcePath, `Cannot reopen the project-resource lock to verify ownership: ${error instanceof Error ? error.message : String(error)}.`);
        }
        try {
            const observed = await disk.stat({ bigint: true });
            if (!sameResourceLock(held, observed) || JSON.parse(await disk.readFile("utf8")).token !== token) {
                throw resourceLockError(resourcePath, "The project-resource lock ownership changed before release.");
            }
        }
        catch (error) {
            if (error instanceof GameStudioProjectError)
                throw error;
            throw resourceLockError(resourcePath, `Cannot verify ownership of the project-resource lock: ${error instanceof Error ? error.message : String(error)}.`);
        }
        finally {
            await disk.close();
        }
        const rechecked = await resourceLockFile(lockPath, resourcePath, uid);
        if (!rechecked || !sameResourceLock(held, rechecked)) {
            throw resourceLockError(resourcePath, "The project-resource lock changed while this writer was releasing it.");
        }
    }
    try {
        await unlink(lockPath);
    }
    catch (error) {
        throw resourceLockError(resourcePath, `Cannot release the verified project-resource lock: ${error instanceof Error ? error.message : String(error)}.`);
    }
}
/**
 * Serialize cooperating plugin writers on one existing canonical owner path in the same host filesystem namespace.
 * The lock lives outside the authored project and never takes over an abandoned/unknown owner. Editors and tools that
 * do not use this helper are not locked; read the fresh owner and recheck its revision just before replacing it.
 */
export async function withProjectResourceWriteLock(root, candidate, operation, options = {}) {
    return withCanonicalProjectWriteLock(root, candidate, "resource", operation, options);
}
/** Gate public mutations; only a positively dead local owner with unchanged private metadata can be recovered. */
export async function withProjectWriteLock(root, operation, options = {}) {
    return withCanonicalProjectWriteLock(root, root, "project", operation, options);
}
async function withCanonicalProjectWriteLock(root, candidate, scope, operation, options) {
    const timeoutMs = options.timeoutMs ?? (scope === "project" ? 30_000 : RESOURCE_LOCK_TIMEOUT_MS);
    if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30_000) {
        throw new GameStudioProjectError("INVALID_INPUT", "A project-resource write lock timeout must be an integer between 0 and 30000 milliseconds", candidate);
    }
    const pathOptions = { mustExist: true, allowRoot: scope === "project" };
    const resourcePath = await resolveProjectPath(root, candidate, pathOptions);
    const entry = await lstat(resourcePath);
    if (scope === "project" ? !entry.isDirectory() : !entry.isFile()) {
        throw new GameStudioProjectError("INVALID_RESOURCE", `A project-${scope} write lock requires an existing ${scope === "project" ? "directory" : "regular file"}`, resourcePath);
    }
    const { directory, uid } = await resourceLockDirectory(resourcePath);
    // Preserve the existing resource key so older participating writers still exclude one another.
    const key = createHash("sha256").update(scope === "project" ? `project-root\0${resourcePath}` : resourcePath).digest("hex");
    const lockPath = path.join(directory, `${key}.lock`);
    const deadline = performance.now() + timeoutMs;
    while (true) {
        let handle;
        let token;
        if (scope === "project") {
            const existing = await resourceLockFile(lockPath, resourcePath, uid, 2n);
            const published = existing ? undefined : await publishRecoverableProjectLock(lockPath, resourcePath, "project");
            handle = published?.handle;
            token = published?.token;
        }
        else {
            try {
                handle = await open(lockPath, "wx", 0o600);
                token = randomUUID();
            }
            catch (error) {
                if (!isSystemError(error, "EEXIST")) {
                    throw resourceLockError(resourcePath, `Cannot acquire the project-resource lock: ${error instanceof Error ? error.message : String(error)}.`);
                }
            }
        }
        if (!handle || !token) {
            await resourceLockFile(lockPath, resourcePath, uid, scope === "project" ? 2n : 1n);
            if (scope === "project" && await recoverDeadProjectWriteLock(lockPath, resourcePath, uid))
                continue;
            const remaining = deadline - performance.now();
            if (remaining <= 0)
                throw resourceLockError(resourcePath, "Timed out waiting for another writer of this project resource.");
            await delay(Math.min(RESOURCE_LOCK_POLL_MS, remaining));
            continue;
        }
        let recorded = scope === "project";
        let callbackFailed = false;
        let callbackError;
        try {
            if (scope === "resource") {
                await handle.writeFile(JSON.stringify({ version: 1, token, pid: process.pid, acquiredAt: new Date().toISOString() }));
                await handle.sync();
                recorded = true;
            }
            if (await resolveProjectPath(root, candidate, pathOptions) !== resourcePath) {
                throw resourceLockError(resourcePath, "The authored resource resolved to a different canonical path while waiting for its lock.");
            }
            return await operation();
        }
        catch (error) {
            callbackFailed = true;
            callbackError = error;
            throw error;
        }
        finally {
            try {
                await releaseResourceLock(handle, lockPath, resourcePath, uid, recorded ? token : undefined);
            }
            catch (error) {
                if (callbackFailed)
                    throw new AggregateError([callbackError, error], "The project-resource operation failed and its lock could not be safely released.");
                throw error;
            }
        }
    }
}
/** Replace exact resource bytes without following its target symlink or leaking temporary files. */
export async function writeProjectBytesAtomic(root, candidate, value) {
    const absoluteCandidate = absoluteProjectPath(root, candidate);
    const resourcePath = await resolveProjectPath(root, absoluteCandidate, { allowRoot: false });
    const parentPath = path.dirname(resourcePath);
    await mkdir(parentPath, { recursive: true });
    const safeParent = await resolveProjectPath(root, parentPath, { mustExist: true });
    try {
        const entry = await lstat(absoluteCandidate);
        if (entry.isSymbolicLink()) {
            throw new GameStudioProjectError("UNSAFE_SYMLINK", `Refusing to overwrite symbolic link ${absoluteCandidate}`, absoluteCandidate);
        }
    }
    catch (error) {
        if (!isSystemError(error, "ENOENT"))
            throw error;
    }
    // Recheck after creating parents so an exchanged ancestor cannot escape the root.
    const confirmedTarget = await resolveProjectPath(root, absoluteCandidate, { allowRoot: false });
    if (path.dirname(confirmedTarget) !== safeParent || confirmedTarget !== resourcePath) {
        throw new GameStudioProjectError("UNSAFE_SYMLINK", `The native project resource location changed while preparing ${absoluteCandidate}`, absoluteCandidate);
    }
    const temporaryPath = path.join(safeParent, `.${path.basename(resourcePath)}.${process.pid}.${randomUUID()}.tmp`);
    let handle;
    try {
        handle = await open(temporaryPath, "wx", 0o600);
        await handle.writeFile(value);
        await handle.sync();
        await handle.close();
        handle = undefined;
        await rename(temporaryPath, resourcePath);
    }
    catch (error) {
        if (handle)
            await handle.close();
        await rm(temporaryPath, { force: true });
        throw error;
    }
}
/** Preserve the native, indented JSON resource encoding and trailing newline. */
export async function writeProjectJsonAtomic(root, candidate, value) {
    await writeProjectBytesAtomic(root, candidate, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
}
export async function resourceRevision(root, candidate) {
    const resourcePath = await resolveProjectPath(root, candidate, { mustExist: true, allowRoot: false });
    return createHash("sha256").update(await readFile(resourcePath)).digest("hex");
}
/** Preserve the UUID-v5-compatible IDs used by existing scene and actor authoring. */
export function stableResourceId(...parts) {
    const bytes = createHash("sha256").update(parts.join("\u0000")).digest();
    bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
    bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
    const hexadecimal = bytes.subarray(0, 16).toString("hex");
    return `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20, 32)}`;
}
export function resourceSlug(value, fallback = "resource") {
    const slug = value
        .normalize("NFKD")
        .replace(/[\u0300-\u036f]/g, "")
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "_")
        .replace(/^_+|_+$/g, "");
    return slug || fallback;
}
export function nextResourceIndex(resources) {
    return resources.reduce((highest, resource) => {
        const index = resource._index;
        return typeof index === "number" && Number.isFinite(index)
            ? Math.max(highest, index)
            : highest;
    }, -1) + 1;
}
//# sourceMappingURL=project-files.js.map

SHA-256: 95c50c61791e2dccef667cd54d38b0d867f299a7224625f2e80b21102f69d526