Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ckb-tui-frozen-version-migration.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 58 additions & 2 deletions src/cfg/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<typeof toWrite.tools>).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);
Expand Down
115 changes: 115 additions & 0 deletions tests/setting.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading