fix(sdk): harden version synchronization

This commit is contained in:
Kayshen-X 2026-07-15 21:10:08 +08:00
parent bb905db75c
commit 427e2b8263
5 changed files with 644 additions and 32 deletions

View file

@ -23,7 +23,7 @@ Tooling is **Cargo** (Rust — the product). The root has **no `package.json`**;
- **CLI:** `cargo build -p op-cli` → binary `op`
- **MCP server:** built into the desktop/web host (`--mcp <path>`); crate `op-mcp`
- **Iconify catalog (Rust assets):** from `packages/`: `bun run generate-iconify-catalog`
- **Bump SDK versions:** from `packages/`: `bun run bump <version>` (syncs the SDK `package.json`s; Rust versions live in Cargo.toml)
- **Sync SDK versions:** from `packages/`: `bun run sync-version` (reads the canonical version from root `Cargo.toml`); verify with `bun run sync-version:check`
## Architecture

View file

@ -516,7 +516,8 @@ cargo run -p op-cli -- <args> # CLI (binary: op)
# Web SDK / JS tooling (run from packages/)
cd packages && bun run lint # Lint the web SDK (oxlint); also: bun run format
cd packages && bun run generate-iconify-catalog # Regenerate the Rust icon catalog assets
cd packages && bun run bump <version> # Sync SDK package.json versions
cd packages && bun run sync-version # Sync SDK versions from root Cargo.toml
cd packages && bun run sync-version:check # Verify SDK versions match root Cargo.toml
```
### Rust workspace details

View file

@ -10,7 +10,7 @@ Run these from `packages/`:
- **Lint / format the SDK:** `bun run lint` (oxlint) / `bun run format` (oxfmt).
- **Iconify catalog (Rust assets):** `bun run generate-iconify-catalog``scripts/generate-iconify-catalog.mjs` reads `@iconify-json/*` and writes `crates/op-editor-ui/assets/iconify-catalog-{core,brands}.json` (the icon catalog embedded in / served by the Rust web target).
- **Bump SDK versions:** `bun run bump <version>` (syncs the SDK `package.json`s; Rust versions live in Cargo.toml).
- **Sync SDK versions:** `bun run sync-version` reads the canonical version from root `Cargo.toml` and updates all SDK consumers; verify with `bun run sync-version:check`.
## op-web-sdk (`op-web-sdk/`)

View file

@ -4,8 +4,8 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const packagesRoot = resolve(scriptDirectory, '..');
const repositoryRoot = resolve(packagesRoot, '..');
const defaultPackagesRoot = resolve(scriptDirectory, '..');
const defaultRepositoryRoot = resolve(defaultPackagesRoot, '..');
const manifestPaths = [
'package.json',
@ -21,6 +21,7 @@ const sdkEntryPaths = [
];
const sdkWorkspaceNames = ['op-web-sdk', 'op-web-sdk-react', 'op-web-sdk-vue'];
const managedPaths = [...manifestPaths, ...sdkEntryPaths, 'bun.lock'];
const versionExportPattern = /^([\t ]*)export const VERSION = '([^'\r\n]*)';([\t ]*)$/gm;
@ -30,8 +31,179 @@ export function renderPackageManifest(source, version) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function maskSdkCommentsAndTemplates(source) {
const masked = source.split('');
let state = 'code';
const mask = (index) => {
if (source[index] !== '\n' && source[index] !== '\r') {
masked[index] = ' ';
}
};
function maskQuoted(start, quote) {
let index = start;
while (index < source.length) {
const character = source[index];
mask(index);
if (index > start && character === quote) {
return index + 1;
}
if (character === '\\') {
mask(index + 1);
index += 2;
} else {
index += 1;
}
}
return index;
}
function maskLineComment(start) {
let index = start;
while (index < source.length && source[index] !== '\n' && source[index] !== '\r') {
mask(index);
index += 1;
}
return index;
}
function maskBlockComment(start) {
let index = start;
while (index < source.length) {
mask(index);
if (source[index] === '*' && source[index + 1] === '/') {
mask(index + 1);
return index + 2;
}
index += 1;
}
return index;
}
function maskTemplateExpression(start) {
let depth = 1;
let index = start;
while (index < source.length) {
const character = source[index];
const next = source[index + 1];
if (character === "'" || character === '"') {
index = maskQuoted(index, character);
continue;
}
if (character === '`') {
index = maskTemplateLiteral(index);
continue;
}
if (character === '/' && next === '/') {
index = maskLineComment(index);
continue;
}
if (character === '/' && next === '*') {
index = maskBlockComment(index);
continue;
}
mask(index);
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
index += 1;
if (depth === 0) {
return index;
}
continue;
}
index += 1;
}
return index;
}
function maskTemplateLiteral(start) {
mask(start);
let index = start + 1;
while (index < source.length) {
const character = source[index];
const next = source[index + 1];
if (character === '\\') {
mask(index);
mask(index + 1);
index += 2;
} else if (character === '`') {
mask(index);
return index + 1;
} else if (character === '$' && next === '{') {
mask(index);
mask(index + 1);
index = maskTemplateExpression(index + 2);
} else {
mask(index);
index += 1;
}
}
return index;
}
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (state === 'single-quote' || state === 'double-quote') {
if (character === '\\') {
index += 1;
} else if (
(state === 'single-quote' && character === "'") ||
(state === 'double-quote' && character === '"')
) {
state = 'code';
}
continue;
}
if (state === 'line-comment') {
if (character === '\n' || character === '\r') {
state = 'code';
} else {
mask(index);
}
continue;
}
if (state === 'block-comment') {
mask(index);
if (character === '*' && next === '/') {
mask(index + 1);
index += 1;
state = 'code';
}
continue;
}
if (character === "'") {
state = 'single-quote';
} else if (character === '"') {
state = 'double-quote';
} else if (character === '`') {
index = maskTemplateLiteral(index) - 1;
} else if (character === '/' && next === '/') {
mask(index);
mask(index + 1);
index += 1;
state = 'line-comment';
} else if (character === '/' && next === '*') {
mask(index);
mask(index + 1);
index += 1;
state = 'block-comment';
}
}
return masked.join('');
}
function sdkVersionMatches(source) {
return [...source.matchAll(versionExportPattern)];
return [...maskSdkCommentsAndTemplates(source).matchAll(versionExportPattern)];
}
export function renderSdkEntry(source, version) {
@ -40,9 +212,9 @@ export function renderSdkEntry(source, version) {
throw new Error(`Expected exactly one public VERSION export, found ${matches.length}`);
}
return source.replace(versionExportPattern, (_declaration, prefix, _current, suffix) => {
return `${prefix}export const VERSION = '${version}';${suffix}`;
});
const [declaration, prefix, _current, suffix] = matches[0];
const start = matches[0].index;
return `${source.slice(0, start)}${prefix}export const VERSION = '${version}';${suffix}${source.slice(start + declaration.length)}`;
}
function skipTrivia(source, start) {
@ -233,7 +405,7 @@ function repositoryPath(packageRelativePath) {
return `packages/${packageRelativePath}`;
}
async function readConsumers() {
async function readConsumers(packagesRoot) {
const manifestConsumers = await Promise.all(
manifestPaths.map(async (path) => {
const source = await readFile(resolve(packagesRoot, path), 'utf8');
@ -265,7 +437,7 @@ async function readConsumers() {
return [...manifestConsumers, ...sdkConsumers, ...lockConsumers];
}
async function renderVersionedFiles(version) {
async function renderVersionedFiles(version, packagesRoot) {
const manifests = await Promise.all(
manifestPaths.map(async (path) => {
const absolutePath = resolve(packagesRoot, path);
@ -299,13 +471,20 @@ async function renderVersionedFiles(version) {
return [...manifests, ...sdkEntries];
}
function canonicalVersion() {
const output = execFileSync('sh', ['scripts/workspace-version.sh', 'Cargo.toml'], {
cwd: repositoryRoot,
function defaultRunCommand(command, arguments_, { cwd, captureOutput = false }) {
return execFileSync(command, arguments_, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
stdio: captureOutput ? ['ignore', 'pipe', 'inherit'] : 'inherit',
});
const version = output.trim();
}
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');
}
@ -321,34 +500,128 @@ function reportDrift(drift) {
}
}
export async function main(arguments_) {
const check = arguments_.length === 1 && arguments_[0] === '--check';
if (arguments_.length > 0 && !check) {
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]');
}
const version = canonicalVersion();
if (!check) {
const rendered = await renderVersionedFiles(version);
await Promise.all(
rendered
.filter(({ source, output }) => source !== output)
.map(({ absolutePath, output }) => writeFile(absolutePath, output)),
);
execFileSync('bun', ['install', '--lockfile-only'], {
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,
stdio: 'inherit',
captureOutput: true,
});
} catch (error) {
throw new Error(`Bun preflight failed: ${errorMessage(error)}. Install Bun and retry.`, {
cause: error,
});
}
const drift = collectVersionDrift(version, await readConsumers());
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 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 = check ? 'Verified' : 'Synchronized';
const action = mode === 'check' ? 'Verified' : 'Synchronized';
console.log(`${action} package and SDK versions at ${version}.`);
}

View file

@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import * as syncVersion from './sync-version.mjs';
import {
collectVersionDrift,
inspectBunLockWorkspaceVersions,
@ -8,6 +13,95 @@ import {
renderSdkEntry,
} from './sync-version.mjs';
const fixtureManagedPaths = [
'package.json',
'op-web-sdk/package.json',
'op-web-sdk-react/package.json',
'op-web-sdk-vue/package.json',
'op-web-sdk/src/index.ts',
'op-web-sdk-react/src/index.ts',
'op-web-sdk-vue/src/index.ts',
'bun.lock',
];
function fixtureLockfile(version) {
return `{
"lockfileVersion": 1,
"workspaces": {
"": { "name": "@zseven-w/openpencil-packages" },
"op-web-sdk": { "version": "${version}" },
"op-web-sdk-react": { "version": "${version}" },
"op-web-sdk-vue": { "version": "${version}" },
},
}
`;
}
async function createRepositoryFixture(t, version = '1.0.0') {
const repositoryRoot = await mkdtemp(join(tmpdir(), 'sync-version-'));
const packagesRoot = join(repositoryRoot, 'packages');
t.after(() => rm(repositoryRoot, { recursive: true, force: true }));
const files = {
'package.json': `${JSON.stringify({ name: 'fixture-root', version }, null, 2)}\n`,
'op-web-sdk/package.json': `${JSON.stringify({ name: 'op-web-sdk', version }, null, 2)}\n`,
'op-web-sdk-react/package.json': `${JSON.stringify({ name: 'op-web-sdk-react', version }, null, 2)}\n`,
'op-web-sdk-vue/package.json': `${JSON.stringify({ name: 'op-web-sdk-vue', version }, null, 2)}\n`,
'op-web-sdk/src/index.ts': `export const VERSION = '${version}';\n`,
'op-web-sdk-react/src/index.ts': `export const VERSION = '${version}';\n`,
'op-web-sdk-vue/src/index.ts': `export const VERSION = '${version}';\n`,
'bun.lock': fixtureLockfile(version),
};
await Promise.all(
Object.entries(files).map(async ([path, contents]) => {
const absolutePath = join(packagesRoot, path);
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, contents);
}),
);
return {
repositoryRoot,
packagesRoot,
managedPaths: fixtureManagedPaths.map((path) => join(packagesRoot, path)),
};
}
async function readManagedFiles(paths) {
return Promise.all(paths.map((path) => readFile(path)));
}
function createCommandRunner(packagesRoot, version = '2.3.4', { regenerateLock = true } = {}) {
const calls = [];
return {
calls,
runCommand: async (command, arguments_) => {
calls.push([command, ...arguments_]);
if (command === 'sh') {
return `${version}\n`;
}
if (arguments_[0] === '--version') {
return '1.3.11\n';
}
if (arguments_[0] === 'install') {
if (regenerateLock) {
await writeFile(join(packagesRoot, 'bun.lock'), fixtureLockfile(version));
}
return '';
}
throw new Error(`Unexpected command: ${command} ${arguments_.join(' ')}`);
},
};
}
test('argument parsing selects write or check mode and rejects unknown arguments', () => {
assert.equal(syncVersion.parseArguments([]), 'write');
assert.equal(syncVersion.parseArguments(['--check']), 'check');
assert.throws(() => syncVersion.parseArguments(['1.2.3']), /usage/i);
assert.throws(() => syncVersion.parseArguments(['--check', '--extra']), /usage/i);
});
test('package manifest rendering changes only the top-level version and ends with a newline', () => {
const input = `{
"name": "example",
@ -69,6 +163,76 @@ export const VERSION = '2.3.4';
);
});
test('SDK entry rendering ignores declaration-shaped lines inside block comments', () => {
const input = `/*
export const VERSION = 'documentation-only';
*/
export const VERSION = '1.0.0';
`;
assert.equal(
renderSdkEntry(input, '2.3.4'),
`/*
export const VERSION = 'documentation-only';
*/
export const VERSION = '2.3.4';
`,
);
});
test('SDK entry rendering ignores declaration-shaped lines inside template literals', () => {
const input = `const documentation = \`
export const VERSION = 'documentation-only';
\`;
export const VERSION = '1.0.0';
`;
assert.equal(
renderSdkEntry(input, '2.3.4'),
`const documentation = \`
export const VERSION = 'documentation-only';
\`;
export const VERSION = '2.3.4';
`,
);
});
test('SDK entry rendering ignores declarations inside nested template literals', () => {
const input = `const documentation = \`
\${\`nested
export const VERSION = 'documentation-only';
\`}
\`;
export const VERSION = '1.0.0';
`;
assert.equal(
renderSdkEntry(input, '2.3.4'),
`const documentation = \`
\${\`nested
export const VERSION = 'documentation-only';
\`}
\`;
export const VERSION = '2.3.4';
`,
);
});
test('SDK entry rendering does not treat comment markers inside quoted strings as comments', () => {
const input = `const blockMarker = '/*';
const lineMarker = "//";
export const VERSION = '1.0.0';
`;
assert.equal(
renderSdkEntry(input, '2.3.4'),
`const blockMarker = '/*';
const lineMarker = "//";
export const VERSION = '2.3.4';
`,
);
});
test('Bun lock inspection finds every versioned SDK workspace and ignores the root workspace', () => {
const lockfile = `{
"workspaces": {
@ -183,3 +347,177 @@ test('an already synchronized in-memory repository reports no drift', () => {
[],
);
});
test('check mode reports stale paths without changing managed files', async (t) => {
const fixture = await createRepositoryFixture(t);
const before = await readManagedFiles(fixture.managedPaths);
const runner = createCommandRunner(fixture.packagesRoot);
const result = await syncVersion.synchronizeVersions({
mode: 'check',
...fixture,
runCommand: runner.runCommand,
});
assert.equal(result.version, '2.3.4');
assert.deepEqual(
result.drift.map(({ path }) => path),
[
'packages/package.json',
'packages/op-web-sdk/package.json',
'packages/op-web-sdk-react/package.json',
'packages/op-web-sdk-vue/package.json',
'packages/op-web-sdk/src/index.ts',
'packages/op-web-sdk-react/src/index.ts',
'packages/op-web-sdk-vue/src/index.ts',
'packages/bun.lock#workspaces.op-web-sdk',
'packages/bun.lock#workspaces.op-web-sdk-react',
'packages/bun.lock#workspaces.op-web-sdk-vue',
],
);
assert.deepEqual(await readManagedFiles(fixture.managedPaths), before);
assert.deepEqual(runner.calls, [['sh', 'scripts/workspace-version.sh', 'Cargo.toml']]);
});
test('write mode synchronizes every consumer and is idempotent', async (t) => {
const fixture = await createRepositoryFixture(t);
const runner = createCommandRunner(fixture.packagesRoot);
const first = await syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand: runner.runCommand,
});
const afterFirst = await readManagedFiles(fixture.managedPaths);
const second = await syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand: runner.runCommand,
});
assert.deepEqual(first.drift, []);
assert.deepEqual(second.drift, []);
assert.deepEqual(await readManagedFiles(fixture.managedPaths), afterFirst);
await Promise.all(
fixture.managedPaths.slice(0, 4).map(async (path) => {
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, '2.3.4');
}),
);
await Promise.all(
fixture.managedPaths.slice(4, 7).map(async (path) => {
assert.match(await readFile(path, 'utf8'), /VERSION = '2\.3\.4'/);
}),
);
assert.deepEqual(
inspectBunLockWorkspaceVersions(await readFile(fixture.managedPaths[7], 'utf8')),
{
'op-web-sdk': '2.3.4',
'op-web-sdk-react': '2.3.4',
'op-web-sdk-vue': '2.3.4',
},
);
assert.equal(runner.calls.filter(([, argument]) => argument === 'install').length, 2);
});
test('write mode performs a Bun preflight before making any changes', async (t) => {
const fixture = await createRepositoryFixture(t);
const before = await readManagedFiles(fixture.managedPaths);
const calls = [];
const runCommand = async (command, arguments_) => {
calls.push([command, ...arguments_]);
if (command === 'sh') {
return '2.3.4\n';
}
if (arguments_[0] === '--version') {
throw new Error('bun unavailable');
}
throw new Error('Bun install should not run after a failed preflight');
};
await assert.rejects(
syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand,
}),
/bun.*preflight.*unavailable/i,
);
assert.deepEqual(await readManagedFiles(fixture.managedPaths), before);
assert.deepEqual(calls, [
['sh', 'scripts/workspace-version.sh', 'Cargo.toml'],
['bun', '--version'],
]);
});
test('write mode restores every managed file when Bun lock regeneration fails', async (t) => {
const fixture = await createRepositoryFixture(t);
const before = await readManagedFiles(fixture.managedPaths);
const runCommand = async (command, arguments_) => {
if (command === 'sh') {
return '2.3.4\n';
}
if (arguments_[0] === '--version') {
return '1.3.11\n';
}
await writeFile(join(fixture.packagesRoot, 'bun.lock'), 'partially regenerated\n');
throw new Error('install exploded');
};
await assert.rejects(
syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand,
}),
/lockfile regeneration failed.*install exploded/i,
);
assert.deepEqual(await readManagedFiles(fixture.managedPaths), before);
});
test('write mode restores every managed file when post-write validation fails', async (t) => {
const fixture = await createRepositoryFixture(t);
const before = await readManagedFiles(fixture.managedPaths);
const runner = createCommandRunner(fixture.packagesRoot, '2.3.4', {
regenerateLock: false,
});
await assert.rejects(
syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand: runner.runCommand,
}),
/post-write validation failed.*bun\.lock/i,
);
assert.deepEqual(await readManagedFiles(fixture.managedPaths), before);
});
test('write mode waits for sibling writes to settle before rollback', async (t) => {
const fixture = await createRepositoryFixture(t);
const before = await readManagedFiles(fixture.managedPaths);
const runner = createCommandRunner(fixture.packagesRoot);
const attempts = new Map();
const writeManagedFile = async (path, contents) => {
const attempt = attempts.get(path) ?? 0;
attempts.set(path, attempt + 1);
if (attempt === 0 && path === fixture.managedPaths[0]) {
throw new Error('simulated write failure');
}
if (attempt === 0 && path === fixture.managedPaths[1]) {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 30));
}
await writeFile(path, contents);
};
await assert.rejects(
syncVersion.synchronizeVersions({
mode: 'write',
...fixture,
runCommand: runner.runCommand,
writeManagedFile,
}),
/simulated write failure/i,
);
await new Promise((resolvePromise) => setTimeout(resolvePromise, 40));
assert.deepEqual(await readManagedFiles(fixture.managedPaths), before);
});