import { execFileSync } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const defaultPackagesRoot = resolve(scriptDirectory, '..'); const defaultRepositoryRoot = resolve(defaultPackagesRoot, '..'); const manifestPaths = [ 'package.json', 'op-web-sdk/package.json', 'op-web-sdk-react/package.json', 'op-web-sdk-vue/package.json', ]; const sdkEntryPaths = [ 'op-web-sdk/src/index.ts', 'op-web-sdk-react/src/index.ts', 'op-web-sdk-vue/src/index.ts', ]; const sdkWorkspaceNames = ['op-web-sdk', 'op-web-sdk-react', 'op-web-sdk-vue']; const managedPaths = [...manifestPaths, ...sdkEntryPaths, 'bun.lock']; export function renderPackageManifest(source, version) { const manifest = JSON.parse(source); manifest.version = version; return `${JSON.stringify(manifest, null, 2)}\n`; } function formatParseDiagnostic(sourceFile, diagnostic) { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); if (diagnostic.start === undefined) { return message; } const { line, character } = sourceFile.getLineAndCharacterOfPosition(diagnostic.start); return `${line + 1}:${character + 1}: ${message}`; } function sdkVersionMatches(source) { const sourceFile = ts.createSourceFile( 'sdk-entry.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); if (sourceFile.parseDiagnostics.length > 0) { const details = sourceFile.parseDiagnostics .map((diagnostic) => formatParseDiagnostic(sourceFile, diagnostic)) .join('; '); throw new Error(`Unable to parse SDK entry: ${details}`); } const matches = []; for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) { continue; } const isExported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false; const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0; if (!isExported || !isConst) { continue; } for (const declaration of statement.declarationList.declarations) { if ( !ts.isIdentifier(declaration.name) || declaration.name.text !== 'VERSION' || declaration.initializer === undefined || !ts.isStringLiteral(declaration.initializer) ) { continue; } matches.push({ start: declaration.initializer.getStart(sourceFile), end: declaration.initializer.getEnd(), version: declaration.initializer.text, }); } } return matches; } export function renderSdkEntry(source, version) { const matches = sdkVersionMatches(source); if (matches.length !== 1) { throw new Error( `Expected exactly one public VERSION export (a top-level exported const with a string literal initializer), found ${matches.length}`, ); } const [match] = matches; const quote = source[match.start] === '"' ? '"' : "'"; return `${source.slice(0, match.start)}${quote}${version}${quote}${source.slice(match.end)}`; } function skipTrivia(source, start) { let index = start; while (index < source.length) { if (/\s/.test(source[index])) { index += 1; continue; } if (source.startsWith('//', index)) { const lineEnd = source.indexOf('\n', index + 2); return lineEnd === -1 ? source.length : skipTrivia(source, lineEnd + 1); } if (source.startsWith('/*', index)) { const commentEnd = source.indexOf('*/', index + 2); if (commentEnd === -1) { throw new Error('Unterminated block comment in bun.lock'); } index = commentEnd + 2; continue; } break; } return index; } function readJsoncString(source, start) { if (source[start] !== '"') { throw new Error(`Expected a quoted string at offset ${start}`); } let value = ''; let index = start + 1; while (index < source.length) { const character = source[index]; if (character === '"') { return { value, end: index + 1 }; } if (character !== '\\') { value += character; index += 1; continue; } const escaped = source[index + 1]; const simpleEscapes = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', }; if (escaped === 'u') { const hexadecimal = source.slice(index + 2, index + 6); if (!/^[0-9a-f]{4}$/i.test(hexadecimal)) { throw new Error(`Invalid Unicode escape at offset ${index}`); } value += String.fromCharCode(Number.parseInt(hexadecimal, 16)); index += 6; continue; } if (!(escaped in simpleEscapes)) { throw new Error(`Invalid string escape at offset ${index}`); } value += simpleEscapes[escaped]; index += 2; } throw new Error('Unterminated string in bun.lock'); } function skipJsoncValue(source, start) { const valueStart = skipTrivia(source, start); if (source[valueStart] === '"') { return readJsoncString(source, valueStart).end; } const opening = source[valueStart]; const closing = opening === '{' ? '}' : opening === '[' ? ']' : undefined; if (closing !== undefined) { let depth = 1; let index = valueStart + 1; while (index < source.length && depth > 0) { index = skipTrivia(source, index); if (source[index] === '"') { index = readJsoncString(source, index).end; continue; } if (source[index] === opening) { depth += 1; } else if (source[index] === closing) { depth -= 1; } index += 1; } if (depth !== 0) { throw new Error(`Unterminated ${opening} value in bun.lock`); } return index; } let index = valueStart; while (index < source.length) { const afterTrivia = skipTrivia(source, index); if (afterTrivia !== index) { index = afterTrivia; continue; } if (',}]'.includes(source[index])) { break; } index += 1; } return index; } function readJsoncObjectEntries(source, start) { const objectStart = skipTrivia(source, start); if (source[objectStart] !== '{') { throw new Error(`Expected an object at offset ${objectStart}`); } const entries = []; let index = objectStart + 1; while (index < source.length) { index = skipTrivia(source, index); if (source[index] === '}') { return entries; } const key = readJsoncString(source, index); index = skipTrivia(source, key.end); if (source[index] !== ':') { throw new Error(`Expected a colon at offset ${index}`); } const valueStart = skipTrivia(source, index + 1); const valueEnd = skipJsoncValue(source, valueStart); entries.push({ key: key.value, valueStart, valueEnd }); index = skipTrivia(source, valueEnd); if (source[index] === ',') { index += 1; } else if (source[index] !== '}') { throw new Error(`Expected a comma or closing brace at offset ${index}`); } } throw new Error('Unterminated object in bun.lock'); } export function inspectBunLockWorkspaceVersions(source) { const rootEntries = readJsoncObjectEntries(source, 0); const workspaces = rootEntries.find((entry) => entry.key === 'workspaces'); if (workspaces === undefined) { throw new Error('bun.lock does not contain a top-level workspaces object'); } const versions = {}; for (const workspace of readJsoncObjectEntries(source, workspaces.valueStart)) { if (source[workspace.valueStart] !== '{') { continue; } const version = readJsoncObjectEntries(source, workspace.valueStart).find( (entry) => entry.key === 'version', ); if (version === undefined || source[version.valueStart] !== '"') { continue; } versions[workspace.key] = readJsoncString(source, version.valueStart).value; } return versions; } export function renderBunLockWorkspaceVersions(source, version) { const rootEntries = readJsoncObjectEntries(source, 0); const workspaces = rootEntries.find((entry) => entry.key === 'workspaces'); if (workspaces === undefined) { throw new Error('bun.lock does not contain a top-level workspaces object'); } const workspaceEntries = readJsoncObjectEntries(source, workspaces.valueStart); const replacements = sdkWorkspaceNames.map((workspaceName) => { const workspace = workspaceEntries.find((entry) => entry.key === workspaceName); if (workspace === undefined || source[workspace.valueStart] !== '{') { throw new Error(`bun.lock is missing workspace ${workspaceName}`); } const versionEntry = readJsoncObjectEntries(source, workspace.valueStart).find( (entry) => entry.key === 'version', ); if (versionEntry === undefined || source[versionEntry.valueStart] !== '"') { throw new Error(`bun.lock workspace ${workspaceName} is missing a string version`); } return { start: versionEntry.valueStart, end: versionEntry.valueEnd, output: JSON.stringify(version), }; }); let output = source; for (const replacement of replacements.toSorted((left, right) => right.start - left.start)) { output = output.slice(0, replacement.start) + replacement.output + output.slice(replacement.end); } return output; } export function collectVersionDrift(expectedVersion, consumers) { return consumers .filter(({ actualVersion }) => actualVersion !== expectedVersion) .map(({ path, actualVersion }) => ({ path, expectedVersion, actualVersion, })); } function repositoryPath(packageRelativePath) { return `packages/${packageRelativePath}`; } async function readConsumers(packagesRoot) { const manifestConsumers = await Promise.all( manifestPaths.map(async (path) => { const source = await readFile(resolve(packagesRoot, path), 'utf8'); return { path: repositoryPath(path), actualVersion: JSON.parse(source).version, }; }), ); const sdkConsumers = await Promise.all( sdkEntryPaths.map(async (path) => { const source = await readFile(resolve(packagesRoot, path), 'utf8'); const matches = sdkVersionMatches(source); return { path: repositoryPath(path), actualVersion: matches.length === 1 ? matches[0].version : undefined, }; }), ); const lockfile = await readFile(resolve(packagesRoot, 'bun.lock'), 'utf8'); const lockVersions = inspectBunLockWorkspaceVersions(lockfile); const lockConsumers = sdkWorkspaceNames.map((workspace) => ({ path: `packages/bun.lock#workspaces.${workspace}`, actualVersion: lockVersions[workspace], })); return [...manifestConsumers, ...sdkConsumers, ...lockConsumers]; } async function renderVersionedFiles(version, packagesRoot) { const manifests = await Promise.all( manifestPaths.map(async (path) => { const absolutePath = resolve(packagesRoot, path); const source = await readFile(absolutePath, 'utf8'); return { absolutePath, source, output: renderPackageManifest(source, version), }; }), ); const sdkEntries = await Promise.all( sdkEntryPaths.map(async (path) => { const absolutePath = resolve(packagesRoot, path); const source = await readFile(absolutePath, 'utf8'); try { return { absolutePath, source, output: renderSdkEntry(source, version), }; } catch (error) { throw new Error(`${repositoryPath(path)}: ${error.message}`, { cause: error, }); } }), ); return [...manifests, ...sdkEntries]; } function defaultRunCommand(command, arguments_, { cwd, captureOutput = false }) { return execFileSync(command, arguments_, { cwd, encoding: 'utf8', stdio: captureOutput ? ['ignore', 'pipe', 'inherit'] : 'inherit', }); } async function canonicalVersion(repositoryRoot, runCommand) { const output = await runCommand('sh', ['scripts/workspace-version.sh', 'Cargo.toml'], { cwd: repositoryRoot, captureOutput: true, }); const version = String(output).trim(); if (version.length === 0 || /\s/.test(version)) { throw new Error('scripts/workspace-version.sh returned an invalid version'); } return version; } function reportDrift(drift) { for (const { path, expectedVersion, actualVersion } of drift) { const actual = actualVersion ?? 'missing or invalid'; console.error( `${path}: expected version ${expectedVersion}, actual ${actual}; run "bun run sync-version"`, ); } } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } async function snapshotManagedFiles(packagesRoot) { return Promise.all( managedPaths.map(async (path) => ({ absolutePath: resolve(packagesRoot, path), contents: await readFile(resolve(packagesRoot, path)), })), ); } async function writeManagedFiles(files, writeManagedFile) { const results = await Promise.allSettled( files.map(({ absolutePath, contents }) => writeManagedFile(absolutePath, contents)), ); const failure = results.find((result) => result.status === 'rejected'); if (failure !== undefined) { throw failure.reason; } } async function restoreManagedFiles(snapshot, writeManagedFile) { await writeManagedFiles(snapshot, writeManagedFile); } function postWriteValidationError(drift) { const consumers = drift .map(({ path, expectedVersion, actualVersion }) => { return `${path} (expected ${expectedVersion}, actual ${actualVersion ?? 'missing or invalid'})`; }) .join('; '); return new Error(`Post-write validation failed: ${consumers}`); } export function parseArguments(arguments_) { if (arguments_.length === 0) { return 'write'; } if (arguments_.length === 1 && arguments_[0] === '--check') { return 'check'; } throw new Error('Usage: node scripts/sync-version.mjs [--check]'); } export async function synchronizeVersions({ mode, repositoryRoot = defaultRepositoryRoot, packagesRoot = defaultPackagesRoot, runCommand = defaultRunCommand, writeManagedFile = writeFile, }) { if (mode !== 'write' && mode !== 'check') { throw new Error(`Unknown synchronization mode: ${mode}`); } const version = await canonicalVersion(repositoryRoot, runCommand); if (mode === 'check') { const drift = collectVersionDrift(version, await readConsumers(packagesRoot)); return { version, drift }; } const rendered = await renderVersionedFiles(version, packagesRoot); const snapshot = await snapshotManagedFiles(packagesRoot); try { await runCommand('bun', ['--version'], { cwd: packagesRoot, captureOutput: true, }); } catch (error) { throw new Error(`Bun preflight failed: ${errorMessage(error)}. Install Bun and retry.`, { cause: error, }); } try { await writeManagedFiles( rendered .filter(({ source, output }) => source !== output) .map(({ absolutePath, output }) => ({ absolutePath, contents: output })), writeManagedFile, ); try { await runCommand('bun', ['install', '--lockfile-only'], { cwd: packagesRoot, captureOutput: false, }); } catch (error) { throw new Error(`Bun lockfile regeneration failed: ${errorMessage(error)}`, { cause: error, }); } const lockPath = resolve(packagesRoot, 'bun.lock'); const lockSource = await readFile(lockPath, 'utf8'); const lockOutput = renderBunLockWorkspaceVersions(lockSource, version); if (lockSource !== lockOutput) { await writeManagedFile(lockPath, lockOutput); } const drift = collectVersionDrift(version, await readConsumers(packagesRoot)); if (drift.length > 0) { throw postWriteValidationError(drift); } return { version, drift }; } catch (error) { try { await restoreManagedFiles(snapshot, writeManagedFile); } catch (rollbackError) { throw new Error( `Version synchronization failed (${errorMessage(error)}) and rollback failed (${errorMessage(rollbackError)}). Restore the managed package files from version control.`, { cause: rollbackError }, ); } throw error; } } export async function main(arguments_) { const mode = parseArguments(arguments_); const { version, drift } = await synchronizeVersions({ mode }); if (drift.length > 0) { reportDrift(drift); process.exitCode = 1; return; } const action = mode === 'check' ? 'Verified' : 'Synchronized'; console.log(`${action} package and SDK versions at ${version}.`); } const isMainModule = process.argv[1] !== undefined && pathToFileURL(resolve(process.argv[1])).href === import.meta.url; if (isMainModule) { main(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1; }); }