mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
#!/usr/bin/env tsx
|
|
|
|
import {
|
|
constants,
|
|
copyFileSync,
|
|
existsSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
extractCanonicalRegion,
|
|
injectOperatorBlackTokens,
|
|
normalizedSha256,
|
|
replaceCanonicalRegion,
|
|
validateDemoOnlyCss,
|
|
} from "./lib/ui-primitives/canonical.js";
|
|
import { isRecord, loadUiPrimitiveScope } from "./lib/ui-primitives/scope.js";
|
|
import {
|
|
loadOperatorBlackTokens,
|
|
renderOperatorBlackTokenBlock,
|
|
} from "./lib/ui-primitives/tokens.js";
|
|
|
|
export type SyncMode = "check" | "write";
|
|
|
|
export interface SyncPrimitiveOptions {
|
|
id: string;
|
|
canonicalPath: string;
|
|
demoPath: string;
|
|
tokenBlock: string;
|
|
mode: SyncMode;
|
|
}
|
|
|
|
export interface SyncPrimitiveResult {
|
|
id: string;
|
|
canonicalChanged: boolean;
|
|
demoChanged: boolean;
|
|
}
|
|
|
|
interface PreparedFileChange {
|
|
path: string;
|
|
content: string;
|
|
}
|
|
|
|
interface PreparedPrimitive extends SyncPrimitiveResult {
|
|
changes: PreparedFileChange[];
|
|
}
|
|
|
|
function prepareUiPrimitiveFiles(options: SyncPrimitiveOptions): PreparedPrimitive {
|
|
if (!existsSync(options.canonicalPath)) {
|
|
throw new Error(`canonical file is missing: ${options.canonicalPath}`);
|
|
}
|
|
if (!existsSync(options.demoPath)) {
|
|
throw new Error(`demo file is missing: ${options.demoPath}`);
|
|
}
|
|
|
|
const canonicalBefore = readFileSync(options.canonicalPath, "utf8");
|
|
const demoBefore = readFileSync(options.demoPath, "utf8");
|
|
const canonicalAfter = injectOperatorBlackTokens(
|
|
canonicalBefore,
|
|
options.tokenBlock,
|
|
options.canonicalPath,
|
|
);
|
|
const demoAfter = replaceCanonicalRegion(
|
|
demoBefore,
|
|
canonicalAfter,
|
|
options.id,
|
|
options.demoPath,
|
|
);
|
|
validateDemoOnlyCss(demoAfter, canonicalAfter, options.demoPath);
|
|
|
|
const region = extractCanonicalRegion(demoAfter, options.demoPath);
|
|
if (normalizedSha256(region) !== normalizedSha256(canonicalAfter)) {
|
|
throw new Error(`${options.id} canonical/demo normalized SHA-256 mismatch`);
|
|
}
|
|
|
|
const canonicalChanged = canonicalBefore !== canonicalAfter;
|
|
const demoChanged = demoBefore !== demoAfter;
|
|
const changes: PreparedFileChange[] = [];
|
|
if (canonicalChanged) changes.push({ path: options.canonicalPath, content: canonicalAfter });
|
|
if (demoChanged) changes.push({ path: options.demoPath, content: demoAfter });
|
|
return { id: options.id, canonicalChanged, demoChanged, changes };
|
|
}
|
|
|
|
interface PendingInstallation extends PreparedFileChange {
|
|
temporaryPath: string;
|
|
backupPath: string;
|
|
}
|
|
|
|
export interface AtomicCommitOptions {
|
|
beforeInstall?: (path: string, index: number) => void;
|
|
journalPath?: string;
|
|
allowedPaths?: string[];
|
|
}
|
|
|
|
type TransactionState = "preparing" | "installing" | "committed";
|
|
|
|
interface TransactionJournal {
|
|
version: 1;
|
|
state: TransactionState;
|
|
entries: Array<{ path: string }>;
|
|
}
|
|
|
|
function cleanupPath(path: string): void {
|
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
}
|
|
|
|
function stagingPaths(path: string): { temporaryPath: string; backupPath: string } {
|
|
return {
|
|
temporaryPath: `${path}.hf-ui-sync.tmp`,
|
|
backupPath: `${path}.hf-ui-sync.bak`,
|
|
};
|
|
}
|
|
|
|
export function uiPrimitiveTransactionJournalPath(paths: string[]): string {
|
|
const first = paths[0];
|
|
if (!first) throw new Error("A sync transaction requires at least one file path");
|
|
return resolve(dirname(first), ".hf-ui-sync.transaction.json");
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function parseTransactionJournal(
|
|
value: unknown,
|
|
source: string,
|
|
allowedPaths: string[],
|
|
): TransactionJournal {
|
|
if (!isRecord(value) || value.version !== 1) {
|
|
throw new Error(`${source} is not a supported UI primitive transaction journal`);
|
|
}
|
|
if (value.state !== "preparing" && value.state !== "installing" && value.state !== "committed") {
|
|
throw new Error(`${source} has an invalid transaction state`);
|
|
}
|
|
if (!Array.isArray(value.entries)) throw new Error(`${source} entries must be an array`);
|
|
const allowed = new Set(allowedPaths);
|
|
const entries: TransactionJournal["entries"] = [];
|
|
for (const entry of value.entries) {
|
|
if (!isRecord(entry)) throw new Error(`${source} contains an invalid transaction entry`);
|
|
if (JSON.stringify(Object.keys(entry).sort()) !== JSON.stringify(["path"])) {
|
|
throw new Error(`${source} transaction entries may contain only path`);
|
|
}
|
|
if (typeof entry.path !== "string" || !allowed.has(entry.path)) {
|
|
throw new Error(`${source} contains a path outside the allowed UI primitive scope`);
|
|
}
|
|
if (entries.some(({ path }) => path === entry.path)) {
|
|
throw new Error(`${source} contains duplicate transaction paths`);
|
|
}
|
|
entries.push({ path: entry.path });
|
|
}
|
|
return { version: 1, state: value.state, entries };
|
|
}
|
|
|
|
function readTransactionJournal(journalPath: string, allowedPaths: string[]): TransactionJournal {
|
|
const parsed: unknown = JSON.parse(readFileSync(journalPath, "utf8"));
|
|
return parseTransactionJournal(parsed, journalPath, allowedPaths);
|
|
}
|
|
|
|
function writeTransactionJournal(journalPath: string, journal: TransactionJournal): void {
|
|
const content = `${JSON.stringify(journal, null, 2)}\n`;
|
|
const nextPath = `${journalPath}.next`;
|
|
cleanupPath(nextPath);
|
|
writeFileSync(nextPath, content, { flag: "wx" });
|
|
renameSync(nextPath, journalPath);
|
|
}
|
|
|
|
function processIsAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return isRecord(error) && error.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function acquireTransactionLock(journalPath: string): string {
|
|
const lockPath = `${journalPath}.lock`;
|
|
try {
|
|
writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx" });
|
|
return lockPath;
|
|
} catch (error) {
|
|
if (!existsSync(lockPath)) throw error;
|
|
const owner = Number.parseInt(readFileSync(lockPath, "utf8").trim(), 10);
|
|
if (Number.isFinite(owner) && owner > 0 && processIsAlive(owner)) {
|
|
throw new Error(`another UI primitive sync transaction is active (pid ${owner})`);
|
|
}
|
|
cleanupPath(lockPath);
|
|
writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx" });
|
|
return lockPath;
|
|
}
|
|
}
|
|
|
|
function recoverTransaction(journalPath: string, allowedPaths: string[]): void {
|
|
const journal = readTransactionJournal(journalPath, allowedPaths);
|
|
if (journal.state !== "committed") {
|
|
for (const entry of [...journal.entries].reverse()) {
|
|
const { backupPath } = stagingPaths(entry.path);
|
|
if (existsSync(backupPath)) renameSync(backupPath, entry.path);
|
|
}
|
|
}
|
|
for (const entry of journal.entries) {
|
|
const { temporaryPath, backupPath } = stagingPaths(entry.path);
|
|
cleanupPath(temporaryPath);
|
|
cleanupPath(backupPath);
|
|
}
|
|
cleanupPath(`${journalPath}.next`);
|
|
cleanupPath(journalPath);
|
|
}
|
|
|
|
export function recoverUiPrimitiveChanges(paths: string[], journalPath?: string): void {
|
|
if (paths.length === 0) return;
|
|
const allowedPaths = [...new Set(paths)];
|
|
if (allowedPaths.length !== paths.length || allowedPaths.some((path) => resolve(path) !== path)) {
|
|
throw new Error("Allowed UI primitive transaction paths must be unique and absolute");
|
|
}
|
|
const resolvedJournalPath = journalPath ?? uiPrimitiveTransactionJournalPath(paths);
|
|
const lockPath = acquireTransactionLock(resolvedJournalPath);
|
|
try {
|
|
if (existsSync(resolvedJournalPath)) {
|
|
recoverTransaction(resolvedJournalPath, allowedPaths);
|
|
return;
|
|
}
|
|
for (const path of allowedPaths) {
|
|
const { temporaryPath, backupPath } = stagingPaths(path);
|
|
if (existsSync(backupPath)) renameSync(backupPath, path);
|
|
cleanupPath(temporaryPath);
|
|
}
|
|
cleanupPath(`${resolvedJournalPath}.next`);
|
|
} finally {
|
|
cleanupPath(lockPath);
|
|
}
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function commitUiPrimitiveChangesAtomically(
|
|
changes: PreparedFileChange[],
|
|
options: AtomicCommitOptions = {},
|
|
): void {
|
|
if (changes.length === 0) return;
|
|
const installations: PendingInstallation[] = changes.map((change) => ({
|
|
...change,
|
|
...stagingPaths(change.path),
|
|
}));
|
|
const journalPath =
|
|
options.journalPath ?? uiPrimitiveTransactionJournalPath(installations.map(({ path }) => path));
|
|
const entries = installations.map(({ path }) => ({ path }));
|
|
const allowedPaths = options.allowedPaths ?? installations.map(({ path }) => path);
|
|
if (
|
|
new Set(allowedPaths).size !== allowedPaths.length ||
|
|
allowedPaths.some((path) => resolve(path) !== path) ||
|
|
entries.some(({ path }) => !allowedPaths.includes(path))
|
|
) {
|
|
throw new Error("Atomic sync changes must be a subset of unique absolute allowed paths");
|
|
}
|
|
|
|
const lockPath = acquireTransactionLock(journalPath);
|
|
try {
|
|
if (existsSync(journalPath)) {
|
|
throw new Error(`unfinished sync transaction requires recovery: ${journalPath}`);
|
|
}
|
|
for (const item of installations) {
|
|
if (existsSync(item.temporaryPath) || existsSync(item.backupPath)) {
|
|
throw new Error(`refusing to overwrite stale sync staging file for ${item.path}`);
|
|
}
|
|
}
|
|
|
|
writeTransactionJournal(journalPath, { version: 1, state: "preparing", entries });
|
|
for (const item of installations)
|
|
writeFileSync(item.temporaryPath, item.content, { flag: "wx" });
|
|
for (const item of installations) {
|
|
copyFileSync(item.path, item.backupPath, constants.COPYFILE_EXCL);
|
|
}
|
|
writeTransactionJournal(journalPath, { version: 1, state: "installing", entries });
|
|
for (const [index, item] of installations.entries()) {
|
|
options.beforeInstall?.(item.path, index);
|
|
renameSync(item.temporaryPath, item.path);
|
|
}
|
|
writeTransactionJournal(journalPath, { version: 1, state: "committed", entries });
|
|
for (const item of installations) cleanupPath(item.backupPath);
|
|
cleanupPath(journalPath);
|
|
} catch (error) {
|
|
if (existsSync(journalPath)) recoverTransaction(journalPath, allowedPaths);
|
|
throw error;
|
|
} finally {
|
|
cleanupPath(`${journalPath}.next`);
|
|
cleanupPath(lockPath);
|
|
}
|
|
}
|
|
|
|
export function syncUiPrimitiveFiles(options: SyncPrimitiveOptions): SyncPrimitiveResult {
|
|
if (options.mode === "write") {
|
|
recoverUiPrimitiveChanges([options.canonicalPath, options.demoPath]);
|
|
}
|
|
const prepared = prepareUiPrimitiveFiles(options);
|
|
if (options.mode === "write") commitUiPrimitiveChangesAtomically(prepared.changes);
|
|
return {
|
|
id: prepared.id,
|
|
canonicalChanged: prepared.canonicalChanged,
|
|
demoChanged: prepared.demoChanged,
|
|
};
|
|
}
|
|
|
|
export interface SyncRunOptions {
|
|
repoRoot: string;
|
|
mode: SyncMode;
|
|
only?: string;
|
|
}
|
|
|
|
export interface SyncRunResult {
|
|
selected: number;
|
|
changed: string[];
|
|
errors: string[];
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function syncUiPrimitives(options: SyncRunOptions): SyncRunResult {
|
|
const scope = loadUiPrimitiveScope(
|
|
resolve(options.repoRoot, "registry/ui-primitives/operator-black.scope.json"),
|
|
);
|
|
const tokens = loadOperatorBlackTokens(
|
|
resolve(options.repoRoot, "registry/ui-primitives/operator-black.tokens.json"),
|
|
);
|
|
if (options.only && !scope.items.includes(options.only)) {
|
|
throw new Error(`Unknown Operator Black primitive: ${options.only}`);
|
|
}
|
|
const selected = options.only ? [options.only] : scope.items;
|
|
const tokenBlock = renderOperatorBlackTokenBlock(tokens);
|
|
const changed: string[] = [];
|
|
const errors: string[] = [];
|
|
const prepared: PreparedPrimitive[] = [];
|
|
const allowedScopePaths = scope.items.flatMap((id) => {
|
|
const root = resolve(options.repoRoot, "registry/components", id);
|
|
return [resolve(root, `${id}.html`), resolve(root, "demo.html")];
|
|
});
|
|
const journalPath = resolve(
|
|
options.repoRoot,
|
|
"registry/ui-primitives/.operator-black-sync.transaction.json",
|
|
);
|
|
|
|
if (options.mode === "write") {
|
|
recoverUiPrimitiveChanges(allowedScopePaths, journalPath);
|
|
}
|
|
|
|
for (const id of selected) {
|
|
const root = resolve(options.repoRoot, "registry/components", id);
|
|
const canonicalPath = resolve(root, `${id}.html`);
|
|
const demoPath = resolve(root, "demo.html");
|
|
try {
|
|
const result = prepareUiPrimitiveFiles({
|
|
id,
|
|
canonicalPath,
|
|
demoPath,
|
|
tokenBlock,
|
|
mode: "check",
|
|
});
|
|
prepared.push(result);
|
|
if (result.canonicalChanged || result.demoChanged) changed.push(id);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
errors.push(`${id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
if (options.mode === "write" && errors.length === 0) {
|
|
try {
|
|
commitUiPrimitiveChangesAtomically(
|
|
prepared.flatMap((item) => item.changes),
|
|
{
|
|
journalPath,
|
|
allowedPaths: allowedScopePaths,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
errors.push(`write transaction: ${message}`);
|
|
}
|
|
}
|
|
|
|
return { selected: selected.length, changed, errors };
|
|
}
|
|
|
|
interface CliOptions {
|
|
mode: SyncMode;
|
|
only?: string;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function parseSyncCliOptions(args: string[]): CliOptions {
|
|
let mode: SyncMode | undefined;
|
|
let only: string | undefined;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--write" || arg === "--check") {
|
|
const nextMode: SyncMode = arg === "--write" ? "write" : "check";
|
|
if (mode) throw new Error("Choose exactly one occurrence of --write or --check");
|
|
mode = nextMode;
|
|
} else if (arg === "--only") {
|
|
if (only) throw new Error("--only may be supplied only once");
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error("--only requires a primitive ID");
|
|
only = value;
|
|
index += 1;
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg ?? ""}`);
|
|
}
|
|
}
|
|
if (!mode) throw new Error("Choose exactly one of --write or --check");
|
|
return only ? { mode, only } : { mode };
|
|
}
|
|
|
|
function main(): void {
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(scriptDir, "..");
|
|
try {
|
|
const cli = parseSyncCliOptions(process.argv.slice(2));
|
|
const result = syncUiPrimitives({ repoRoot, ...cli });
|
|
for (const error of result.errors) console.error(`✗ ${error}`);
|
|
if (cli.mode === "check" && result.changed.length > 0) {
|
|
console.error(`✗ sync drift: ${result.changed.join(", ")}`);
|
|
}
|
|
const failures = result.errors.length + (cli.mode === "check" ? result.changed.length : 0);
|
|
console.log(
|
|
`Operator Black sync: ${result.selected} selected, ${result.changed.length} changed, ${result.errors.length} errors`,
|
|
);
|
|
if (failures > 0) process.exitCode = 1;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const entry = process.argv[1];
|
|
if (entry && import.meta.url === pathToFileURL(entry).href) main();
|