← Files ModRetro Chromatic PluginARCHIVED FILE
dist/project-session.js
34.4 KB · Oct 2, 2026 · 00:37 UTC
import { realpathSync, statSync } from "node:fs";
import { lstat, mkdir, open, readdir, readFile, realpath, rmdir, stat, unlink, } from "node:fs/promises";
import path from "node:path";
import { discoverProject, GameStudioProjectError, inventoryProject, } from "./project.js";
import { ProjectWorldIndex } from "./project-world-index.js";
import { assertSafePlatformPath } from "./platform.js";
import { readWrecklightTemplate } from "./bundled-template.js";
export const BLANK_PROJECT_GAME_TYPES = ["TOPDOWN", "PLATFORM", "ADVENTURE", "SHMUP", "POINTNCLICK"];
export const BLANK_PROJECT_COLOR_MODES = ["mono", "mixed", "color"];
const EXCLUDED_TEMPLATE_DIRECTORIES = new Set([
".git",
".local",
"artifacts",
"build",
"dist",
"node_modules",
]);
function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
function assertWithin(root, candidate) {
if (!isWithin(root, candidate)) {
throw new GameStudioProjectError("PATH_OUTSIDE_PROJECT", `The project path must remain inside the authorized workspace: ${candidate}`, candidate);
}
}
function nonEmptyPath(value, label) {
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
throw new GameStudioProjectError("INVALID_INPUT", `${label} must be a non-empty path`);
}
return value.trim();
}
function canonicalDirectory(value, label) {
const requested = path.resolve(nonEmptyPath(value, label));
try {
const canonical = realpathSync(requested);
if (!statSync(canonical).isDirectory()) {
throw new Error("not a directory");
}
return canonical;
}
catch (error) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `${label} must identify an existing directory: ${requested}`, requested);
}
}
function isMissing(error) {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function isExcludedTemplateEntry(name, directory) {
if (directory)
return EXCLUDED_TEMPLATE_DIRECTORIES.has(name);
return (/\.(?:gb|gbc|tmp|temp|swp)$/iu.test(name) ||
name === ".DS_Store" ||
name.startsWith(".~") ||
name.endsWith("~"));
}
function sameIdentity(left, right) {
return left.dev === right.dev && left.ino === right.ino;
}
async function rememberCreatedDirectory(destination, entries) {
const identity = await lstat(destination);
if (!identity.isDirectory() || await realpath(destination) !== destination || (await readdir(destination)).length !== 0) {
throw new GameStudioProjectError("PROJECT_BUSY", "The new project directory changed before copying began; existing entries were preserved.", destination);
}
entries.push({ path: destination, identity });
}
/** Check the owned ancestors before writing or removing an entry in a new project. */
async function assertCreatedParents(entries, destination) {
for (const entry of entries) {
if (!entry.identity.isDirectory() || !isWithin(entry.path, destination))
continue;
const current = await lstat(entry.path);
if (!current.isDirectory() || !sameIdentity(current, entry.identity) || await realpath(entry.path) !== entry.path) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", "A project creation directory changed during the operation.", entry.path);
}
}
}
async function writeCreatedFile(destination, bytes, entries) {
await assertCreatedParents(entries, destination);
// Exclusive creation never truncates a file introduced by another caller.
const handle = await open(destination, "wx");
try {
const entry = { path: destination, identity: await handle.stat() };
entries.push(entry);
try {
await handle.writeFile(bytes);
}
finally {
entry.identity = await handle.stat();
}
}
finally {
await handle.close();
}
}
/** Remove only entries this call created; leave concurrent additions or edits intact. */
async function cleanupCreatedEntries(entries) {
const errors = [];
for (const entry of [...entries].reverse()) {
try {
await assertCreatedParents(entries, entry.path);
const current = await lstat(entry.path);
if (!sameIdentity(current, entry.identity) || current.isSymbolicLink() ||
(entry.identity.isFile() && (current.size !== entry.identity.size || current.mtimeMs !== entry.identity.mtimeMs))) {
throw new Error("A created entry changed; it was preserved instead of removed.");
}
if (entry.identity.isDirectory())
await rmdir(entry.path);
else
await unlink(entry.path);
}
catch (error) {
if (!isMissing(error))
errors.push(error);
}
}
return errors;
}
async function assertCompleteCreation(entries) {
for (const entry of entries) {
await assertCreatedParents(entries, entry.path);
const current = await lstat(entry.path);
if (!sameIdentity(current, entry.identity) || current.isSymbolicLink() ||
(entry.identity.isFile() && (current.size !== entry.identity.size || current.mtimeMs !== entry.identity.mtimeMs))) {
throw new GameStudioProjectError("PROJECT_BUSY", "A newly created project entry changed during copying.", entry.path);
}
if (entry.identity.isDirectory()) {
const expected = entries.filter((child) => path.dirname(child.path) === entry.path).map((child) => path.basename(child.path)).sort();
const actual = (await readdir(entry.path)).sort();
if (expected.length !== actual.length || expected.some((name, index) => name !== actual[index])) {
throw new GameStudioProjectError("PROJECT_BUSY", "Another writer added or removed project entries during creation.", entry.path);
}
}
}
}
async function copyTrustedTemplate(source, destination, descriptorPath, createdEntries, overrides = new Map()) {
const entries = await readdir(source, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (isExcludedTemplateEntry(entry.name, entry.isDirectory()))
continue;
const sourcePath = path.join(source, entry.name);
const destinationPath = path.join(destination, entry.name);
const details = await lstat(sourcePath);
if (details.isSymbolicLink()) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `The starter template must not contain symbolic links: ${sourcePath}`, sourcePath);
}
if (details.isDirectory()) {
await assertCreatedParents(createdEntries, destinationPath);
await mkdir(destinationPath);
await rememberCreatedDirectory(destinationPath, createdEntries);
await copyTrustedTemplate(sourcePath, destinationPath, descriptorPath, createdEntries, overrides);
}
else if (details.isFile()) {
// Publish the customized descriptor last, after all editable files exist.
if (sourcePath !== descriptorPath) {
await writeCreatedFile(destinationPath, overrides.get(sourcePath) ?? await readFile(sourcePath), createdEntries);
}
}
else {
throw new GameStudioProjectError("INVALID_PROJECT", `The starter template contains an unsupported filesystem entry: ${sourcePath}`, sourcePath);
}
}
}
async function blankProjectOverrides(template, configuration) {
const inventory = await inventoryProject(template);
const scene = inventory.scenes[0];
if (inventory.format !== "distributed" || inventory.scenes.length !== 1 || !scene
|| inventory.settings.startSceneId !== scene.id || scene.actors.length || scene.triggers.length
|| Object.entries(scene).some(([key, value]) => /^(?:script|[A-Za-z][A-Za-z0-9]*Script)$/.test(key)
&& (!Array.isArray(value) || value.length > 0))) {
throw new GameStudioProjectError("INVALID_BLANK_TEMPLATE", "The blank starter must contain one start scene with no actors, triggers or event scripts.");
}
const playerSprites = inventory.settings.defaultPlayerSprites;
const playerId = playerSprites && typeof playerSprites === "object" && !Array.isArray(playerSprites)
? playerSprites[configuration.gameType] : undefined;
const hasAsset = (id, type) => typeof id === "string"
&& inventory.assets.filter((asset) => asset.id === id && asset.type === type && asset.hasMetadata).length === 1;
if (inventory.diagnostics.some((diagnostic) => diagnostic.severity === "error")
|| !hasAsset(scene.backgroundId, "background") || !hasAsset(playerId, "sprite")
|| !hasAsset(inventory.settings.defaultFontId, "font")) {
throw new GameStudioProjectError("INVALID_BLANK_TEMPLATE", "The blank starter must provide valid background, player and font resources for the requested game type.");
}
const settingsPath = path.join(template, "project", "settings.gbsres");
const scenePath = path.join(template, scene.resourcePath);
const { actors: _actors, triggers: _triggers, resourcePath: _resourcePath, ...sceneData } = scene;
const json = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
return new Map([
[settingsPath, json({ ...inventory.settings, defaultSceneTypeId: configuration.gameType, colorMode: configuration.colorMode })],
[scenePath, json({ ...sceneData, type: configuration.gameType })],
]);
}
async function copyCapturedTemplate(files, destination, createdEntries) {
const directories = new Set([""]);
for (const [relative, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
if (relative === "project.gbsproj")
continue; // Customized descriptor is published last.
const parts = relative.split("/");
for (let i = 1; i < parts.length; i++) {
const directory = parts.slice(0, i).join("/");
if (directories.has(directory))
continue;
const target = path.join(destination, directory);
await assertCreatedParents(createdEntries, target);
await mkdir(target);
await rememberCreatedDirectory(target, createdEntries);
directories.add(directory);
}
await writeCreatedFile(path.join(destination, relative), bytes, createdEntries);
}
}
/** Owns one explicitly authorized workspace and its selected native game project. */
export class ProjectSession {
runtimeRoot;
selectedProject;
selectionGeneration = 0;
#configuredProject;
#bundleRoots;
#templateSourceRoots = new Set();
#toolchainRoot;
#isEmulatorActive;
#isProjectBusy;
#workspaceRoot;
#activeOperations = 0;
#transitioning = false;
#worldIndex;
constructor(options = {}) {
const runtimeRoot = options.runtimeRoot ?? process.env.GB_STUDIO_RUNTIME_ROOT ?? path.resolve(import.meta.dirname, "..");
this.runtimeRoot = canonicalDirectory(runtimeRoot, "The runtime root");
const pluginRoot = options.pluginRoot ?? process.env.GB_STUDIO_PLUGIN_ROOT;
this.#bundleRoots = [...new Set([this.runtimeRoot,
...(pluginRoot ? [canonicalDirectory(pluginRoot, "The installed plugin root")] : []),
])];
const explicitWorkspace = options.workspaceRoot ?? process.env.GB_STUDIO_WORKSPACE_ROOT;
const explicitProject = options.projectRoot ?? process.env.GB_STUDIO_PROJECT_ROOT;
this.#configuredProject = explicitProject?.trim() || undefined;
this.#toolchainRoot = options.toolchainRoot;
this.#isEmulatorActive = options.isEmulatorActive ?? (() => false);
this.#isProjectBusy = options.isProjectBusy ?? (() => false);
if (explicitWorkspace?.trim()) {
this.#workspaceRoot = canonicalDirectory(explicitWorkspace, "The authorized workspace root");
}
else if (this.#configuredProject !== undefined) {
const projectDirectory = this.#configuredProject.toLowerCase().endsWith(".gbsproj")
? path.dirname(path.resolve(this.#configuredProject))
: this.#configuredProject;
this.#workspaceRoot = canonicalDirectory(projectDirectory, "The configured project root");
}
}
get workspaceRoot() {
return this.#workspaceRoot;
}
/** A build/recording root must not include either immutable sample copy. */
authorizedOperationRoot(projectRoot = this.selectedProject?.projectRoot) {
const root = this.#workspaceRoot ?? projectRoot;
if (root === undefined) {
throw new GameStudioProjectError("PROJECT_SELECTION_REQUIRED", "No project or workspace is authorized. For a native game project, call project_select with its absolute .gbsproj path. For a standalone ROM or C source, configure GB_STUDIO_WORKSPACE_ROOT to its containing workspace before starting this server. session_status shows the current authorization.");
}
const canonical = canonicalDirectory(root, "The operation root");
if (!this.#overlapsBundledSample(canonical))
return canonical;
if (projectRoot !== undefined) {
const project = canonicalDirectory(projectRoot, "The selected project root");
if (isWithin(canonical, project) && !this.#overlapsBundledSample(project))
return project;
}
throw this.#bundledSampleError(canonical);
}
async initialize() {
if (this.selectedProject !== undefined)
return this.selectedProject;
if (this.#configuredProject === undefined)
return undefined;
return this.select({ projectPath: this.#configuredProject, allowStarter: true });
}
requireSelectedProject() {
if (this.selectedProject === undefined) {
throw new GameStudioProjectError("PROJECT_SELECTION_REQUIRED", "No native game project is selected. Call project_select with the intended .gbsproj path; use an absolute path if no workspace is configured. session_status shows the current selection.");
}
return this.selectedProject;
}
async discover(projectPath) {
if (projectPath === undefined)
return this.requireSelectedProject();
if (this.#workspaceRoot === undefined) {
throw new GameStudioProjectError("PROJECT_SELECTION_REQUIRED", "Authorize an exact project with project_select before discovering other projects.");
}
return this.#discoverBounded(projectPath, this.#workspaceRoot);
}
async select(input) {
this.#assertCanTransition();
this.#transitioning = true;
try {
const candidate = nonEmptyPath(input.projectPath, "The project path");
if (this.#workspaceRoot === undefined && !path.isAbsolute(candidate)) {
throw new GameStudioProjectError("PROJECT_SELECTION_REQUIRED", "The first selected project must be an absolute path when no workspace is configured.", candidate);
}
const discovered = this.#workspaceRoot === undefined
? await this.#discoverExactProject(candidate)
: await this.#discoverBounded(candidate, this.#workspaceRoot);
await this.#assertStarterAllowed(discovered.projectRoot, input.allowStarter === true);
if (this.#workspaceRoot === undefined) {
this.#workspaceRoot = discovered.projectRoot;
}
const previousIndex = this.#worldIndex;
this.selectedProject = discovered;
this.selectionGeneration += 1;
this.#worldIndex = undefined;
previousIndex?.dispose();
return discovered;
}
finally {
this.#transitioning = false;
}
}
create(input) {
return this.#create(input);
}
createBlank(input) {
return this.#create({ ...input, template: "starter" }, {
gameType: input.gameType ?? "TOPDOWN",
colorMode: input.colorMode ?? "color",
});
}
async #create(input, blank) {
this.#assertCanTransition();
this.#transitioning = true;
const createdEntries = [];
try {
if (blank && (!BLANK_PROJECT_GAME_TYPES.includes(blank.gameType) || !BLANK_PROJECT_COLOR_MODES.includes(blank.colorMode))) {
throw new GameStudioProjectError("INVALID_INPUT", "Choose a supported blank-project game type and native color mode.");
}
if (input.template !== "starter" && input.template !== "wrecklight") {
throw new GameStudioProjectError("INVALID_INPUT", "Choose the starter or wrecklight template explicitly.");
}
if (typeof input.name !== "string" || input.name.trim().length === 0) {
throw new GameStudioProjectError("INVALID_INPUT", "The project name must be a non-empty string.");
}
if (input.author !== undefined && (typeof input.author !== "string" || input.author.trim().length === 0)) {
throw new GameStudioProjectError("INVALID_INPUT", "The project author must be a non-empty string when provided.");
}
if (input.templateSourcePath !== undefined && (input.template !== "wrecklight"
|| typeof input.templateSourcePath !== "string" || !path.isAbsolute(input.templateSourcePath))) {
throw new GameStudioProjectError("INVALID_INPUT", "templateSourcePath must be an absolute directory and is only supported for wrecklight.");
}
const template = input.templateSourcePath === undefined
? path.join(this.runtimeRoot, "examples", input.template) : path.resolve(input.templateSourcePath);
let templateDetails;
try {
templateDetails = await lstat(template);
}
catch (error) {
if (isMissing(error)) {
if (input.template === "wrecklight" && input.templateSourcePath === undefined) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", "Wrecklight is not included in this package. Supply templateSourcePath with its matching local source directory.");
}
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `The trusted ${input.template} template does not exist: ${template}`, template);
}
throw error;
}
if (templateDetails.isSymbolicLink() || !templateDetails.isDirectory() || await realpath(template) !== template) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `The trusted ${input.template} template must be a real directory: ${template}`, template);
}
if (input.templateSourcePath !== undefined && this.selectedProject !== undefined
&& (isWithin(template, this.selectedProject.projectRoot) || isWithin(this.selectedProject.projectRoot, template))) {
throw new GameStudioProjectError("INVALID_INPUT", "The read-only template source must not overlap the currently selected project.", template);
}
const captured = input.template === "wrecklight" ? await readWrecklightTemplate(template) : undefined;
// The explicit path grants this read/copy only, not a wider workspace.
// Keep the authenticated source protected in this session as well.
if (input.templateSourcePath !== undefined)
this.#templateSourceRoots.add(template);
const overrides = blank ? await blankProjectOverrides(template, blank) : undefined;
const destination = await this.#resolveNewDestination(input.destinationPath);
const templateProject = captured === undefined ? await this.#discoverBounded(template, template) : {
projectPath: path.join(template, "project.gbsproj"),
descriptor: JSON.parse(captured.get("project.gbsproj").toString("utf8")),
};
const descriptor = {
...templateProject.descriptor,
name: input.name.trim(),
...(input.author === undefined ? {} : { author: input.author.trim() }),
};
try {
// Claim the exact new directory exclusively. rename() could replace a
// concurrently created empty directory on some supported filesystems.
await mkdir(destination);
}
catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
throw new GameStudioProjectError("PROJECT_ALREADY_EXISTS", `The project destination already exists: ${destination}`, destination);
}
throw error;
}
await rememberCreatedDirectory(destination, createdEntries);
if (captured === undefined)
await copyTrustedTemplate(template, destination, templateProject.projectPath, createdEntries, overrides);
else
await copyCapturedTemplate(captured, destination, createdEntries);
await writeCreatedFile(path.join(destination, path.basename(templateProject.projectPath)), `${JSON.stringify(descriptor, null, 2)}\n`, createdEntries);
await assertCompleteCreation(createdEntries);
const created = await this.#discoverBounded(destination, this.#workspaceRoot ?? destination);
// Creating an exact project is an explicit grant for that directory only.
// Never expose its parent as a temporary or persistent workspace.
this.#workspaceRoot ??= created.projectRoot;
if (input.select === true) {
const previousIndex = this.#worldIndex;
this.selectedProject = created;
this.selectionGeneration += 1;
this.#worldIndex = undefined;
previousIndex?.dispose();
}
return created;
}
catch (error) {
const cleanupErrors = await cleanupCreatedEntries(createdEntries);
if (cleanupErrors.length > 0) {
const failure = new GameStudioProjectError(error instanceof GameStudioProjectError ? error.code : "PROJECT_CREATE_FAILED", `${error instanceof Error ? error.message : "Project creation failed"} Cleanup was incomplete; changed or inaccessible entries were preserved.`);
failure.cause = new AggregateError([error, ...cleanupErrors]);
throw failure;
}
throw error;
}
finally {
this.#transitioning = false;
}
}
beginActivity() {
if (this.#transitioning) {
throw new GameStudioProjectError("PROJECT_BUSY", "A project selection transition is already in progress.");
}
this.#activeOperations += 1;
let released = false;
return () => {
if (!released) {
released = true;
this.#activeOperations -= 1;
}
};
}
async withActivity(action) {
const release = this.beginActivity();
try {
return await action();
}
finally {
release();
}
}
/** Observe an existing index without selecting a project or starting its bootstrap. */
indexStats() {
return this.#worldIndex?.stats() ?? null;
}
/** Acquire the transition guard before capturing the selected project or index. */
async withSelectedProjectAccess(action) {
const release = this.beginActivity();
try {
const project = this.requireSelectedProject();
const generation = this.selectionGeneration;
const index = this.#worldIndex ??= new ProjectWorldIndex(project.projectPath, generation, {
...(this.#toolchainRoot === undefined ? {} : { toolchainRoot: this.#toolchainRoot }),
});
await index.ensureFresh();
if (this.selectionGeneration !== generation ||
this.selectedProject?.projectPath !== project.projectPath ||
this.#worldIndex !== index) {
throw new GameStudioProjectError("PROJECT_BUSY", "The selected project changed while acquiring indexed project access.");
}
return await action(project, index);
}
finally {
release();
}
}
/** Release the process-local watcher without writing into the authored project. */
close() {
const index = this.#worldIndex;
this.#worldIndex = undefined;
index?.dispose();
}
#assertCanTransition() {
if (this.#transitioning || this.#activeOperations > 0 || this.#isProjectBusy()) {
throw new GameStudioProjectError("PROJECT_BUSY", "Finish the active project mutation or build before selecting another project.");
}
if (this.#isEmulatorActive()) {
throw new GameStudioProjectError("EMULATOR_ACTIVE", "Close the active emulator before selecting another project.");
}
}
async #assertStarterAllowed(projectRoot, allowed) {
if (this.#overlapsBundledSample(projectRoot))
throw this.#bundledSampleError(projectRoot);
if (allowed)
return;
try {
const starter = await realpath(path.join(this.runtimeRoot, "examples", "starter"));
if (starter === projectRoot) {
throw new GameStudioProjectError("STARTER_SELECTION_REQUIRES_OPT_IN", "Selecting the bundled starter requires allowStarter: true.", projectRoot);
}
}
catch (error) {
if (!isMissing(error))
throw error;
}
}
#overlapsBundledSample(candidate) {
return [...this.#templateSourceRoots].some((source) => isWithin(source, candidate) || isWithin(candidate, source))
|| this.#bundleRoots.some((root) => {
const sample = path.join(root, "examples", "wrecklight");
return isWithin(sample, candidate) || isWithin(candidate, sample);
});
}
#bundledSampleError(candidate) {
return new GameStudioProjectError("BUNDLED_TEMPLATE_READ_ONLY", "The bundled Wrecklight sample is read-only. Use project_create with template: wrecklight, then select the editable copy for builds and recordings.", candidate);
}
async #discoverExactProject(candidate) {
const requested = path.resolve(candidate);
let canonical;
try {
canonical = await realpath(requested);
}
catch (error) {
if (isMissing(error)) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `The selected project does not exist: ${requested}`, requested);
}
throw error;
}
const details = await stat(canonical);
if (details.isDirectory())
return this.#discoverBounded(canonical, canonical);
if (!details.isFile() || !canonical.toLowerCase().endsWith(".gbsproj")) {
throw new GameStudioProjectError("INVALID_INPUT", "Select a project directory or an exact .gbsproj descriptor.", requested);
}
return this.#discoverBounded(canonical, path.dirname(canonical));
}
async #discoverBounded(candidate, workspaceRoot) {
const requested = path.resolve(workspaceRoot, nonEmptyPath(candidate, "The project path"));
assertWithin(workspaceRoot, requested);
let canonical;
try {
canonical = await realpath(requested);
}
catch (error) {
if (isMissing(error)) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `The selected project does not exist: ${requested}`, requested);
}
throw error;
}
assertWithin(workspaceRoot, canonical);
const details = await stat(canonical);
let descriptor;
if (details.isFile()) {
if (!canonical.toLowerCase().endsWith(".gbsproj")) {
throw new GameStudioProjectError("INVALID_INPUT", `Project descriptors must use the .gbsproj extension: ${canonical}`, canonical);
}
descriptor = canonical;
}
else if (details.isDirectory()) {
let directory = canonical;
while (true) {
const entries = await readdir(directory, { withFileTypes: true });
const matches = entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.toLowerCase().endsWith(".gbsproj"))
.map((entry) => path.join(directory, entry.name))
.sort((left, right) => left.localeCompare(right));
if (matches.length > 1) {
throw new GameStudioProjectError("AMBIGUOUS_PROJECT", `More than one .gbsproj file exists in ${directory}; select an explicit descriptor.`, directory);
}
if (matches[0] !== undefined) {
descriptor = await realpath(matches[0]);
assertWithin(workspaceRoot, descriptor);
break;
}
if (directory === workspaceRoot)
break;
directory = path.dirname(directory);
assertWithin(workspaceRoot, directory);
}
}
if (descriptor === undefined) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `No .gbsproj project was found within the authorized workspace for ${canonical}`, canonical);
}
assertWithin(workspaceRoot, descriptor);
const discovered = await discoverProject(descriptor);
assertWithin(workspaceRoot, discovered.projectRoot);
assertWithin(workspaceRoot, discovered.projectPath);
return discovered;
}
#assertOutsideTemplates(destination) {
for (const source of this.#templateSourceRoots) {
if (isWithin(source, destination) || isWithin(destination, source)) {
throw new GameStudioProjectError("INVALID_INPUT", "The project destination must not overlap its read-only template source.", destination);
}
}
for (const root of this.#bundleRoots) {
for (const name of ["starter", "wrecklight"]) {
if (isWithin(path.join(root, "examples", name), destination)) {
throw new GameStudioProjectError("INVALID_INPUT", "The project destination must not be inside a bundled template.", destination);
}
}
}
}
async #resolveNewDestination(candidate) {
const workspaceRoot = this.#workspaceRoot;
const requested = nonEmptyPath(candidate, "The destination path");
try {
assertSafePlatformPath(requested);
}
catch (error) {
throw new GameStudioProjectError("INVALID_INPUT", error instanceof Error ? error.message : "The destination path is unsafe.");
}
if (workspaceRoot === undefined) {
if (!path.isAbsolute(requested) || (process.platform === "win32" && !/^[A-Za-z]:[\\/]/u.test(requested))) {
throw new GameStudioProjectError("PROJECT_SELECTION_REQUIRED", "The first project_create destination must be an absolute path to a new directory under an existing real parent when no workspace is configured.");
}
const destination = path.resolve(requested);
const parent = path.dirname(destination);
let canonicalParent;
try {
canonicalParent = await realpath(parent);
if (!(await stat(canonicalParent)).isDirectory()) {
throw new GameStudioProjectError("INVALID_INPUT", "The project destination parent must be a directory.");
}
}
catch (error) {
if (isMissing(error)) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", "The first project destination parent must already exist.");
}
throw error;
}
if (canonicalParent !== parent) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", "The first project destination must use its canonical parent path, without symbolic-link ancestors.");
}
this.#assertOutsideTemplates(destination);
return destination;
}
const destination = path.resolve(workspaceRoot, requested);
assertWithin(workspaceRoot, destination);
if (destination === workspaceRoot) {
throw new GameStudioProjectError("INVALID_INPUT", "The project destination must be a new directory inside the workspace.", destination);
}
// Check both immutable copies before creating any missing parent directory.
this.#assertOutsideTemplates(destination);
const parent = path.dirname(destination);
const segments = path.relative(workspaceRoot, parent).split(path.sep).filter(Boolean);
let current = workspaceRoot;
for (const segment of segments) {
current = path.join(current, segment);
try {
const details = await lstat(current);
if (details.isSymbolicLink()) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Project destination ancestors must not be symbolic links: ${current}`, current);
}
if (!details.isDirectory()) {
throw new GameStudioProjectError("INVALID_INPUT", `Project destination ancestors must be directories: ${current}`, current);
}
const canonical = await realpath(current);
this.#assertOutsideTemplates(canonical);
if (canonical !== current) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", "Project destination ancestors must use their canonical paths.", current);
}
}
catch (error) {
if (!isMissing(error))
throw error;
await mkdir(current);
}
}
try {
await lstat(destination);
throw new GameStudioProjectError("PROJECT_ALREADY_EXISTS", `The project destination already exists: ${destination}`, destination);
}
catch (error) {
if (!isMissing(error))
throw error;
}
assertWithin(workspaceRoot, await realpath(parent));
return destination;
}
}
//# sourceMappingURL=project-session.js.mapSHA-256: 2f4aef692f90aecbfca1b092d916f71cdfd5f1aa5b3444a15cac2208fd2888f8