From e23202803b52c890b53e47fb451c6c86472a0b61 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 31 Jul 2026 10:29:09 +0800 Subject: [PATCH] Merge pull request #480 from ckb-devrel/agent/claude-bear/19afdb3b fix(config): unfreeze bundled ckb-tui version for upgraded installs --- .../ckb-tui-frozen-version-migration.md | 5 + src/cfg/setting.ts | 60 ++++++++- tests/setting.test.ts | 115 ++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 .changeset/ckb-tui-frozen-version-migration.md create mode 100644 tests/setting.test.ts diff --git a/.changeset/ckb-tui-frozen-version-migration.md b/.changeset/ckb-tui-frozen-version-migration.md new file mode 100644 index 0000000..fc70cf3 --- /dev/null +++ b/.changeset/ckb-tui-frozen-version-migration.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +Fix upgraded installs staying on an old bundled ckb-tui. Releases up to 0.4.10 wrote the entire merged settings object on any `offckb config set` (proxy or ckb-version), freezing the then-current bundled ckb-tui version (v0.1.3) into `settings.json`. After upgrading offckb, that frozen value overrode the new shipped default, so affected users never moved to v0.1.4 — and the stale-binary digest check compares against the configured version, so it never triggered a reinstall for them either. `readSettings` now upgrades a persisted ckb-tui version that is older than the shipped default (a newer hand-set version is still respected), and `writeSettings` no longer persists the version when it merely equals the default. diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index 8c52b3e..23649cf 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -109,7 +109,8 @@ export function readSettings(): Settings { const parsed = JSON.parse(data); validateSettings(parsed); // Deep-clone defaults before merging to prevent mutation of the shared default - return deepMerge(deepClone(defaultSettings), parsed) as Settings; + const settings = deepMerge(deepClone(defaultSettings), parsed) as Settings; + return upgradeFrozenBundledVersions(settings); } else { // Callers mutate the returned settings in place; never hand out the // shared module-level defaults. @@ -124,13 +125,68 @@ export function readSettings(): Settings { export function writeSettings(settings: Settings): void { try { fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(settings, null, 2)); + // Don't persist the bundled ckb-tui version when it merely equals the + // shipped default: there is no CLI command that sets it, so an entry + // identical to the default is an artifact of dumping the merged settings, + // and writing it would freeze today's default into the user's config + // (readSettings would keep honoring it after a future bump). A version + // that differs from the default is a deliberate hand-edit and is kept. + const toWrite = deepClone(settings); + if (toWrite.tools?.ckbTui?.version === defaultSettings.tools.ckbTui.version) { + delete (toWrite.tools as Partial).ckbTui; + } + fs.writeFileSync(configPath, JSON.stringify(toWrite, null, 2)); logger.info('save new settings'); } catch (error) { logger.error('Error writing settings:', error); } } +/** + * Releases up to 0.4.10 wrote the entire merged settings object on any + * `offckb config set`, freezing the then-current bundled ckb-tui version + * (e.g. "v0.1.3") into the user's settings.json. Since no CLI command can set + * tools.ckbTui.version deliberately, a frozen value older than the shipped + * default is treated as such an artifact and upgraded, so existing installs + * pick up ckb-tui fixes (and the stale-binary reinstall keyed off the + * configured version) instead of staying on the old release forever. A + * persisted version newer than the default — only possible via a hand-edit — + * is respected, as is an unparseable value (install-time validation reports + * it). Returns -1/0/1 semantics via compareVersions; null when unparseable. + */ +function upgradeFrozenBundledVersions(settings: Settings): Settings { + const configured = settings.tools?.ckbTui?.version; + const shipped = defaultSettings.tools.ckbTui.version; + if (typeof configured !== 'string' || configured === shipped) { + return settings; + } + const order = compareVersions(configured, shipped); + if (order !== null && order < 0) { + logger.info(`Upgrading bundled ckb-tui version from ${configured} to ${shipped} (the shipped default).`); + settings.tools.ckbTui.version = shipped; + } + return settings; +} + +/** Compare two strict vX.Y.Z versions; null when either fails to parse. */ +function compareVersions(a: string, b: string): number | null { + const parse = (v: string): number[] | null => { + const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(v); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; + }; + const pa = parse(a); + const pb = parse(b); + if (!pa || !pb) { + return null; + } + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1; + } + } + return 0; +} + export function getCKBBinaryInstallPath(version: string) { const setting = readSettings(); return path.join(setting.bins.rootFolder, version); diff --git a/tests/setting.test.ts b/tests/setting.test.ts new file mode 100644 index 0000000..8a8a6ad --- /dev/null +++ b/tests/setting.test.ts @@ -0,0 +1,115 @@ +import fs from 'fs'; +import path from 'path'; + +jest.mock('../src/util/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +// Redirect the offckb config/data/cache roots into a temp directory. The root +// is created inside the mock factory because configPath is computed once at +// module import time — a beforeEach reassignment would come too late. +jest.mock('../src/cfg/env-path', () => { + const nodeFs = require('fs'); + const nodeOs = require('os'); + const nodePath = require('path'); + const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'offckb-settings-')); + return { + __esModule: true, + default: () => ({ + data: nodePath.join(root, 'data'), + config: nodePath.join(root, 'config'), + cache: nodePath.join(root, 'cache'), + log: nodePath.join(root, 'log'), + temp: nodePath.join(root, 'temp'), + }), + }; +}); + +import { readSettings, writeSettings, defaultSettings, configPath } from '../src/cfg/setting'; +import { logger } from '../src/util/logger'; + +describe('settings ckb-tui version handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + fs.rmSync(configPath, { force: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + }); + + const writeConfig = (config: unknown) => fs.writeFileSync(configPath, JSON.stringify(config)); + + describe('readSettings', () => { + it('returns the shipped default when no config file exists', () => { + expect(readSettings().tools.ckbTui.version).toBe(defaultSettings.tools.ckbTui.version); + }); + + it('upgrades a frozen older bundled ckb-tui version to the shipped default', () => { + // What a <=0.4.10 `config set` left behind: the whole merged settings, + // including the then-current bundled version. + writeConfig({ proxy: { host: '127.0.0.1', port: 8080 }, tools: { ckbTui: { version: 'v0.1.3' } } }); + + const settings = readSettings(); + + expect(settings.tools.ckbTui.version).toBe(defaultSettings.tools.ckbTui.version); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('v0.1.3')); + // Unrelated user settings survive the upgrade. + expect(settings.proxy).toEqual({ host: '127.0.0.1', port: 8080 }); + }); + + it('respects a persisted version newer than the shipped default', () => { + writeConfig({ tools: { ckbTui: { version: 'v9.9.9' } } }); + + expect(readSettings().tools.ckbTui.version).toBe('v9.9.9'); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('Upgrading bundled ckb-tui')); + }); + + it('leaves the shipped default untouched without logging an upgrade', () => { + writeConfig({ tools: { ckbTui: { version: defaultSettings.tools.ckbTui.version } } }); + + expect(readSettings().tools.ckbTui.version).toBe(defaultSettings.tools.ckbTui.version); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('Upgrading bundled ckb-tui')); + }); + + it('leaves an unparseable version for install-time validation to report', () => { + writeConfig({ tools: { ckbTui: { version: 'not-a-version' } } }); + + expect(readSettings().tools.ckbTui.version).toBe('not-a-version'); + }); + }); + + describe('writeSettings', () => { + it('omits the bundled ckb-tui version when it equals the shipped default', () => { + const settings = readSettings(); + writeSettings(settings); + + const written = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(written.tools.ckbTui).toBeUndefined(); + }); + + it('persists a bundled ckb-tui version that differs from the shipped default', () => { + const settings = readSettings(); + settings.tools.ckbTui.version = 'v9.9.9'; + writeSettings(settings); + + const written = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(written.tools.ckbTui).toEqual({ version: 'v9.9.9' }); + }); + + it('does not mutate the caller-provided settings object', () => { + const settings = readSettings(); + writeSettings(settings); + + expect(settings.tools.ckbTui.version).toBe(defaultSettings.tools.ckbTui.version); + }); + + it('round-trips: a config set on an upgraded install no longer freezes the version', () => { + // Simulates a user with a frozen v0.1.3 who later runs `config set`: + // the read upgrades in memory, the write drops the incidental entry. + writeConfig({ tools: { ckbTui: { version: 'v0.1.3' } } }); + writeSettings(readSettings()); + + const written = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(written.tools.ckbTui).toBeUndefined(); + expect(readSettings().tools.ckbTui.version).toBe(defaultSettings.tools.ckbTui.version); + }); + }); +});