def-peter_obsidian-aera-theme/scripts/version-bump.mjs

675 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-07-16 09:13:25 +00:00
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
2026-07-16 10:17:35 +00:00
import {
mkdir,
open,
readFile,
readdir,
rename,
rm,
rmdir,
stat,
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { performance } from "node:perf_hooks";
2026-07-16 09:37:50 +00:00
import { setTimeout as delay } from "node:timers/promises";
2026-07-16 09:13:25 +00:00
import { fileURLToPath } from "node:url";
import { validateManifest, validateVersions } from "./theme-policy.mjs";
const modulePath = fileURLToPath(import.meta.url);
2026-07-16 09:37:50 +00:00
const lockName = ".aera-version-bump.lock";
const lockTimeoutMs = 30_000;
const lockRetryMs = 10;
2026-07-16 10:17:35 +00:00
const malformedLockStaleMs = 1_000;
const retryLockCode = "AERA_LOCK_RETRY";
2026-07-16 09:13:25 +00:00
function throwValidationErrors(errors, context = "") {
if (errors.length) {
const prefix = context ? `${context}: ` : "";
throw new Error(`${prefix}${errors.join("; ")}`);
}
}
export function bumpMetadata(manifest, versions, targetVersion) {
throwValidationErrors(validateManifest(manifest), "manifest.json");
throwValidationErrors(validateVersions(manifest, versions), "versions.json");
const targetErrors = validateManifest({ ...manifest, version: targetVersion });
if (targetErrors.includes("manifest version must use x.y.z")) {
throw new Error("target version must use x.y.z");
}
throwValidationErrors(targetErrors);
const nextManifest = { ...manifest, version: targetVersion };
const nextVersions = {
...versions,
[targetVersion]: manifest.minAppVersion,
};
throwValidationErrors([
...validateManifest(nextManifest),
...validateVersions(nextManifest, nextVersions),
]);
return { manifest: nextManifest, versions: nextVersions };
}
function jsonContents(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
async function readJson(path) {
let contents;
try {
contents = await readFile(path, "utf8");
} catch (error) {
throw new Error(`could not read ${path}: ${error.message}`);
}
try {
return { contents, value: JSON.parse(contents) };
2026-07-16 09:13:25 +00:00
} catch (error) {
throw new Error(`could not parse ${path}: ${error.message}`);
}
}
async function stageFile(path, contents, temporaryPath) {
2026-07-16 09:13:25 +00:00
let temporaryFile;
let ownsTemporaryPath = false;
try {
temporaryFile = await open(temporaryPath, "wx");
ownsTemporaryPath = true;
await temporaryFile.writeFile(contents);
await temporaryFile.close();
temporaryFile = undefined;
return {
2026-07-16 11:08:41 +00:00
get path() {
return temporaryPath;
},
2026-07-16 09:13:25 +00:00
release() {
ownsTemporaryPath = false;
},
2026-07-16 11:08:41 +00:00
preserve() {
ownsTemporaryPath = false;
},
relocate(path) {
temporaryPath = path;
},
2026-07-16 09:13:25 +00:00
async cleanup() {
await temporaryFile?.close().catch(() => {});
if (ownsTemporaryPath) {
await rm(temporaryPath, { force: true });
ownsTemporaryPath = false;
}
},
};
} catch (error) {
2026-07-16 10:17:35 +00:00
const cleanupFailures = [];
try {
await temporaryFile?.close();
} catch (cleanupFailure) {
cleanupFailures.push({ action: "close", error: cleanupFailure });
}
if (ownsTemporaryPath) {
try {
await rm(temporaryPath, { force: true });
ownsTemporaryPath = false;
} catch (cleanupFailure) {
cleanupFailures.push({ action: "remove", error: cleanupFailure });
}
}
if (cleanupFailures.length) {
throw stageCleanupError(
error,
temporaryPath,
cleanupFailures,
ownsTemporaryPath,
);
}
throw error;
}
}
async function inspectLockOwner(ownerPath) {
try {
const [contents, metadata] = await Promise.all([
readFile(ownerPath, "utf8"),
stat(ownerPath),
]);
let owner;
try {
owner = JSON.parse(contents);
} catch {
owner = null;
}
const validOwner =
owner &&
typeof owner.token === "string" &&
owner.token.length > 0 &&
Number.isSafeInteger(owner.pid) &&
owner.pid > 0 &&
typeof owner.createdAt === "number";
return {
createdAt: validOwner ? owner.createdAt : null,
identity: validOwner
? `token:${owner.token}`
: `invalid:${metadata.dev}:${metadata.ino}:${metadata.mtimeMs}:${contents}`,
mtimeMs: metadata.mtimeMs,
pid: validOwner ? owner.pid : null,
token: validOwner ? owner.token : null,
};
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
async function inspectLock(lockPath) {
try {
const [entries, metadata] = await Promise.all([
readdir(lockPath, { withFileTypes: true }),
stat(lockPath),
]);
if (entries.length === 0) {
return {
mtimeMs: metadata.mtimeMs,
owner: null,
ownerPath: null,
removable: true,
};
}
const ownerEntries = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith(".json"),
);
if (ownerEntries.length === 1) {
const ownerPath = join(lockPath, ownerEntries[0].name);
2026-07-16 10:17:35 +00:00
const owner = await inspectLockOwner(ownerPath);
const workspacePath = owner?.token ? join(lockPath, owner.token) : null;
const validEntries = entries.every(
(entry) =>
entry.name === ownerEntries[0].name ||
(workspacePath && entry.isDirectory() && entry.name === owner.token),
);
2026-07-16 10:17:35 +00:00
return {
mtimeMs: owner?.mtimeMs ?? metadata.mtimeMs,
owner,
ownerPath,
removable: validEntries,
workspacePath,
2026-07-16 10:17:35 +00:00
};
}
return {
mtimeMs: metadata.mtimeMs,
owner: null,
ownerPath: null,
removable: false,
};
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
function processIsRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code !== "ESRCH";
}
}
function lockIsStale(lock) {
if (!lock.removable) return false;
if (lock.owner?.pid !== null && lock.owner?.pid !== undefined) {
return !processIsRunning(lock.owner.pid);
}
return Date.now() - lock.mtimeMs >= malformedLockStaleMs;
}
async function removeLockDirectory(lockPath) {
try {
await rmdir(lockPath);
return true;
} catch (error) {
if (
error?.code === "ENOENT" ||
error?.code === "ENOTEMPTY" ||
error?.code === "EEXIST"
) {
return false;
}
throw error;
}
}
async function releaseDirectoryLock(lockPath, ownerPath, workspacePath, token) {
2026-07-16 10:17:35 +00:00
const owner = await inspectLockOwner(ownerPath);
if (owner?.token !== token) return;
try {
await rmdir(workspacePath);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(
`version workspace retained at ${workspacePath}: ${error.message}`,
);
}
}
2026-07-16 10:17:35 +00:00
try {
await rm(ownerPath);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
await removeLockDirectory(lockPath);
}
2026-07-16 11:08:41 +00:00
async function validateReadyRollback(directory, type, rollbackPath) {
let rollbackValue;
try {
rollbackValue = JSON.parse(await readFile(rollbackPath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return false;
throw new Error(`invalid retained rollback ${rollbackPath}: ${error.message}`);
}
let errors;
if (type === "manifest") {
const versions = (await readJson(join(directory, "versions.json"))).value;
errors = [
...validateManifest(rollbackValue),
...validateVersions(rollbackValue, versions),
];
} else {
const manifest = (await readJson(join(directory, "manifest.json"))).value;
errors = validateVersions(manifest, rollbackValue);
}
if (errors.length) {
throw new Error(
`invalid retained rollback ${rollbackPath}: ${errors.join("; ")}`,
);
}
return true;
}
async function recoverWorkspaceRollbacks(lockPath, workspacePath) {
let entries;
try {
entries = await readdir(workspacePath, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
const rollbackPattern =
/^\.(manifest|versions)\.json\.aera-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.rollback\.ready$/;
for (const entry of entries) {
const match = entry.isFile() ? rollbackPattern.exec(entry.name) : null;
if (!match) continue;
const rollbackPath = join(workspacePath, entry.name);
const destination = join(dirname(lockPath), `${match[1]}.json`);
if (!(await validateReadyRollback(dirname(lockPath), match[1], rollbackPath))) {
return false;
}
try {
await rename(rollbackPath, destination);
} catch (error) {
if (error?.code === "ENOENT") return false;
throw new Error(
`could not retry retained rollback ${rollbackPath} -> ${destination}: ${error.message}; original contents retained at ${rollbackPath}`,
);
}
}
return true;
}
2026-07-16 10:17:35 +00:00
async function removeStaleLock(lockPath, observed) {
if (observed.ownerPath) {
const currentOwner = await inspectLockOwner(observed.ownerPath);
if (currentOwner?.identity !== observed.owner?.identity) return false;
if (observed.workspacePath) {
2026-07-16 11:08:41 +00:00
if (!(await recoverWorkspaceRollbacks(lockPath, observed.workspacePath))) {
return false;
}
await rm(observed.workspacePath, { recursive: true, force: true });
}
2026-07-16 10:17:35 +00:00
try {
await rm(observed.ownerPath);
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}
return removeLockDirectory(lockPath);
}
async function createDirectoryLock(lockPath) {
const token = randomUUID();
const ownerPath = join(lockPath, `${token}.json`);
const preparedOwnerPath = join(
dirname(lockPath),
`.aera-version-bump-owner-${token}.tmp`,
);
let lockFile;
try {
lockFile = await open(preparedOwnerPath, "wx");
await lockFile.writeFile(
`${JSON.stringify({ token, pid: process.pid, createdAt: Date.now() })}\n`,
);
await lockFile.close();
} catch (error) {
await lockFile?.close().catch(() => {});
await rm(preparedOwnerPath, { force: true }).catch(() => {});
throw error;
}
try {
await mkdir(lockPath);
} catch (error) {
try {
await rm(preparedOwnerPath);
} catch (cleanupFailure) {
throw stageCleanupError(
error,
preparedOwnerPath,
[{ action: "remove", error: cleanupFailure }],
true,
);
}
2026-07-16 09:13:25 +00:00
throw error;
}
2026-07-16 10:17:35 +00:00
let publicationError;
try {
await rename(preparedOwnerPath, ownerPath);
const published = await inspectLock(lockPath);
if (
published?.owner?.token !== token ||
published.ownerPath !== ownerPath
) {
publicationError = new Error("version lock owner publication lost its gate");
}
} catch (error) {
publicationError = error;
}
if (publicationError) {
await rm(preparedOwnerPath, { force: true }).catch(() => {});
await rm(ownerPath, { force: true }).catch(() => {});
await removeLockDirectory(lockPath).catch(() => {});
const retryError = new Error(
`could not publish version lock owner ${ownerPath}: ${errorMessage(publicationError)}`,
);
retryError.code = retryLockCode;
throw retryError;
}
const workspacePath = join(lockPath, token);
try {
await mkdir(workspacePath);
} catch (error) {
await rm(ownerPath, { force: true }).catch(() => {});
await removeLockDirectory(lockPath).catch(() => {});
throw error;
}
2026-07-16 10:17:35 +00:00
return {
token,
workspacePath,
2026-07-16 10:17:35 +00:00
async release() {
await releaseDirectoryLock(lockPath, ownerPath, workspacePath, token);
2026-07-16 10:17:35 +00:00
},
};
2026-07-16 09:13:25 +00:00
}
2026-07-16 09:37:50 +00:00
async function acquireDirectoryLock(directory) {
const lockPath = join(directory, lockName);
2026-07-16 10:17:35 +00:00
const deadline = performance.now() + lockTimeoutMs;
2026-07-16 09:37:50 +00:00
while (true) {
try {
2026-07-16 10:17:35 +00:00
return await createDirectoryLock(lockPath);
2026-07-16 09:37:50 +00:00
} catch (error) {
2026-07-16 10:17:35 +00:00
if (error?.code === retryLockCode) {
if (performance.now() >= deadline) throw error;
await delay(lockRetryMs);
continue;
}
2026-07-16 09:37:50 +00:00
if (error?.code !== "EEXIST") {
throw new Error(`could not acquire version lock ${lockPath}: ${error.message}`);
}
2026-07-16 10:17:35 +00:00
const observed = await inspectLock(lockPath);
if (observed && lockIsStale(observed)) {
await removeStaleLock(lockPath, observed);
continue;
}
if (performance.now() >= deadline) {
2026-07-16 09:37:50 +00:00
throw new Error(`timed out waiting for version lock ${lockPath}`);
}
await delay(lockRetryMs);
}
}
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
2026-07-16 10:17:35 +00:00
function stageCleanupError(originalError, path, failures, retained) {
const details = failures
.map(({ action, error }) => `${action} ${path}: ${errorMessage(error)}`)
.join("; ");
const retainedDetails = retained ? `; temporary retained at ${path}` : "";
return new AggregateError(
[originalError, ...failures.map(({ error }) => error)],
`${errorMessage(originalError)}; cleanup failed: ${details}${retainedDetails}`,
);
}
2026-07-16 09:37:50 +00:00
function rollbackError(originalError, failures) {
const details = failures
2026-07-16 11:08:41 +00:00
.map(({ action, error, path, retained }) => {
const retainedDetails = retained
? `; original contents retained at ${path}`
: "";
return `${action} ${path}: ${errorMessage(error)}${retainedDetails}`;
})
2026-07-16 09:37:50 +00:00
.join("; ");
return new AggregateError(
[originalError, ...failures.map(({ error }) => error)],
`${errorMessage(originalError)}; rollback failed: ${details}`,
);
}
async function cleanupStagedFiles(staged) {
const results = await Promise.all(
staged.map(async (file) => {
try {
await file.temporary.cleanup();
return null;
} catch (error) {
return { error, path: file.temporary.path };
}
}),
);
return results.filter(Boolean);
}
function cleanupError(originalError, failures) {
const details = failures
.map(
({ error, path }) =>
`${path}: ${errorMessage(error)}; temporary retained at ${path}`,
)
.join("; ");
return new AggregateError(
[originalError, ...failures.map(({ error }) => error)],
`${errorMessage(originalError)}; cleanup failed: ${details}`,
);
}
async function replaceTogether(replacements, workspacePath) {
2026-07-16 09:13:25 +00:00
const transactionId = randomUUID();
const staged = [];
try {
for (const replacement of replacements) {
const temporaryPath = join(
workspacePath,
`.${basename(replacement.path)}.aera-${transactionId}.install.tmp`,
);
2026-07-16 09:13:25 +00:00
staged.push({
...replacement,
temporary: await stageFile(
replacement.path,
replacement.contents,
temporaryPath,
),
2026-07-16 09:13:25 +00:00
});
}
} catch (error) {
2026-07-16 09:37:50 +00:00
const cleanupFailures = await cleanupStagedFiles(staged);
if (cleanupFailures.length) throw cleanupError(error, cleanupFailures);
2026-07-16 09:13:25 +00:00
throw error;
}
const installed = [];
2026-07-16 09:13:25 +00:00
try {
for (const file of staged) {
await rename(file.temporary.path, file.path);
2026-07-16 09:13:25 +00:00
file.temporary.release();
installed.push(file);
2026-07-16 09:13:25 +00:00
}
} catch (error) {
2026-07-16 09:37:50 +00:00
const rollbackFailures = [];
const rollbackTemporaries = [];
for (const file of installed.toReversed()) {
2026-07-16 11:08:41 +00:00
const preparingPath = join(
workspacePath,
2026-07-16 11:08:41 +00:00
`.${basename(file.path)}.aera-${transactionId}.rollback.preparing`,
);
2026-07-16 11:08:41 +00:00
const readyPath = preparingPath.replace(/\.preparing$/, ".ready");
let rollbackTemporary;
let rollbackReady = false;
try {
2026-07-16 11:08:41 +00:00
rollbackTemporary = await stageFile(
file.path,
file.rollbackContents,
2026-07-16 11:08:41 +00:00
preparingPath,
);
rollbackTemporaries.push({ temporary: rollbackTemporary });
2026-07-16 11:08:41 +00:00
await rename(rollbackTemporary.path, readyPath);
rollbackTemporary.relocate(readyPath);
rollbackReady = true;
await rename(rollbackTemporary.path, file.path);
rollbackTemporary.release();
} catch (rollbackFailure) {
2026-07-16 11:08:41 +00:00
if (rollbackReady) rollbackTemporary.preserve();
rollbackFailures.push({
action: "restore original",
error: rollbackFailure,
2026-07-16 11:08:41 +00:00
path: rollbackReady ? readyPath : preparingPath,
retained: rollbackReady,
});
2026-07-16 09:13:25 +00:00
}
}
const replacementError = rollbackFailures.length
2026-07-16 09:37:50 +00:00
? rollbackError(error, rollbackFailures)
: error;
const cleanupFailures = await cleanupStagedFiles([
...staged,
...rollbackTemporaries,
]);
if (cleanupFailures.length) {
throw cleanupError(replacementError, cleanupFailures);
}
throw replacementError;
2026-07-16 09:37:50 +00:00
}
const cleanupFailures = await cleanupStagedFiles(staged);
if (cleanupFailures.length) {
throw cleanupError(new Error("install completed"), cleanupFailures);
2026-07-16 09:13:25 +00:00
}
}
export async function syncVersion(directory = process.cwd()) {
2026-07-16 10:17:35 +00:00
const absoluteDirectory = resolve(directory);
const lock = await acquireDirectoryLock(absoluteDirectory);
2026-07-16 09:37:50 +00:00
let operationError;
try {
2026-07-16 10:17:35 +00:00
const packagePath = join(absoluteDirectory, "package.json");
const manifestPath = join(absoluteDirectory, "manifest.json");
const versionsPath = join(absoluteDirectory, "versions.json");
const [packageDocument, manifestDocument, versionsDocument] =
await Promise.all([
readJson(packagePath),
readJson(manifestPath),
readJson(versionsPath),
]);
const next = bumpMetadata(
manifestDocument.value,
versionsDocument.value,
packageDocument.value?.version,
);
2026-07-16 09:13:25 +00:00
await replaceTogether(
[
{
path: versionsPath,
contents: jsonContents(next.versions),
rollbackContents: versionsDocument.contents,
},
{
path: manifestPath,
contents: jsonContents(next.manifest),
rollbackContents: manifestDocument.contents,
},
],
lock.workspacePath,
);
2026-07-16 09:37:50 +00:00
} catch (error) {
operationError = error;
throw error;
} finally {
try {
await lock.release();
} catch (releaseError) {
if (operationError) {
throw new AggregateError(
[operationError, releaseError],
`${errorMessage(operationError)}; could not release version lock: ${errorMessage(releaseError)}`,
);
}
throw releaseError;
}
}
2026-07-16 09:13:25 +00:00
}
function singleLine(value) {
return String(value).replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
function isDirectRun() {
if (process.argv[1] === undefined) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(modulePath);
} catch {
return false;
}
}
if (isDirectRun()) {
try {
await syncVersion();
console.log("Theme metadata version synchronized");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`ERROR: ${singleLine(message)}`);
process.exitCode = 1;
}
}