forked from Crown-Studio/roblox-ts-template
205 lines
6.4 KiB
JavaScript
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.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|