Superheroes_Incremental/scripts/bedrock-local-state.mjs

205 lines
6.4 KiB
JavaScript

import { randomUUID } from "node:crypto";
import {
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { hostname } from "node:os";
import { join, resolve } from "node:path";
import process from "node:process";
import { parseStateContents, serializeStateFile, validateEnvironmentName } from "@bedrock-rbx/core";
import { type } from "arktype";
const ok = (data) => ({ success: true, data });
const fail = (reason) => ({ success: false, err: { reason } });
function environmentFile(directory, environment) {
const valid = validateEnvironmentName(environment);
if (!valid.success) throw new Error(valid.err.reason);
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(environment)) {
throw new Error("Environment name is reserved on Windows.");
}
return join(directory, `${environment}.json`);
}
function stateFailure(file, error) {
return {
success: false,
err: {
kind: error.code === "EACCES" || error.code === "EPERM" ? "stateAccessDenied" : "stateError",
file,
reason: error.message,
},
};
}
/** Create a filesystem adapter without creating files during read-only commands. */
export function createLocalStatePorts(directory) {
directory = resolve(directory);
const lockPath = (environment) => `${environmentFile(directory, environment)}.lock`;
const statePort = {
async read(environment) {
let file = directory;
try {
file = environmentFile(directory, environment);
let raw;
try {
raw = readFileSync(file, "utf8");
} catch (error) {
if (error.code === "ENOENT") return ok({});
throw error;
}
const parsed = parseStateContents(raw, file);
if (!parsed.success) return parsed;
if (parsed.data.environment !== environment) {
throw new Error(`State belongs to "${parsed.data.environment}", not "${environment}".`);
}
return ok({ state: parsed.data });
} catch (error) {
return stateFailure(file, error);
}
},
async write(state) {
let file = directory;
let temporary;
try {
file = environmentFile(directory, state.environment);
const raw = serializeStateFile(state);
const parsed = parseStateContents(raw, file);
if (!parsed.success) return parsed;
mkdirSync(directory, { recursive: true });
temporary = `${file}.${randomUUID()}.tmp`;
const handle = openSync(temporary, "wx", 0o600);
try {
writeFileSync(handle, `${raw}\n`, "utf8");
fsyncSync(handle);
} finally {
closeSync(handle);
}
// Rename on the same filesystem keeps readers from seeing partial JSON.
renameSync(temporary, file);
return ok(undefined);
} catch (error) {
return stateFailure(file, error);
} finally {
if (temporary) {
try {
rmSync(temporary, { force: true });
} catch {
// Keep the original failure if the filesystem also refuses cleanup.
}
}
}
},
};
const stateLockPort = {
async acquire(environment, options = {}) {
let lock;
let created = false;
try {
lock = lockPath(environment);
mkdirSync(directory, { recursive: true });
// Directory creation is exclusive across independent CLI processes.
mkdirSync(lock);
created = true;
const token = randomUUID();
const holding = {
token,
owner: `${hostname()}:${process.pid}`,
since: new Date().toISOString(),
operation: options.operation,
};
writeFileSync(join(lock, "owner.json"), JSON.stringify(holding), { mode: 0o600 });
return ok({
async release() {
try {
const current = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
if (current.token !== token)
return fail("The local state lock is held by another run.");
rmSync(lock, { recursive: true });
return ok(undefined);
} catch (error) {
return fail(`Cannot release local state lock: ${error.message}`);
}
},
});
} catch (error) {
if (created) {
try {
rmSync(lock, { recursive: true, force: true });
} catch {
return fail(
`Cannot initialize or remove local state lock at ${lock}: ${error.message}`,
);
}
}
return fail(
error.code === "EEXIST"
? `Local state is locked for "${environment}". Stop the other deploy before using bedrock state unlock --env ${environment}.`
: `Cannot acquire local state lock: ${error.message}`,
);
}
},
async inspect(environment) {
try {
const lock = lockPath(environment);
try {
return ok(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")));
} catch (error) {
if (error.code === "ENOENT") {
return ok(existsSync(lock) ? { owner: "Unknown local run" } : undefined);
}
throw error;
}
} catch (error) {
return fail(`Cannot inspect local state lock: ${error.message}`);
}
},
async forceRelease(environment) {
try {
const lock = lockPath(environment);
if (!existsSync(lock)) return ok(undefined);
const holding = await stateLockPort.inspect(environment);
rmSync(lock, { recursive: true });
return ok(holding.success ? holding.data : { owner: "Unknown local run" });
} catch (error) {
return fail(`Cannot unlock local state: ${error.message}`);
}
},
};
return { statePort, stateLockPort };
}
export default {
name: "crownstudio-local-state",
stateBackends: [
{
name: "local",
schema: type({ directory: "string > 0" }),
createPort: ({ stateConfig }) => ok(createLocalStatePorts(stateConfig.directory).statePort),
createLockPort: ({ stateConfig }) =>
ok(createLocalStatePorts(stateConfig.directory).stateLockPort),
migratePrompts: [
{
key: "directory",
label: "Local state directory?",
placeholder: ".bedrock/state",
validationMessage: "A local state directory is required.",
},
],
},
],
};