Skip to content

Commit 1551ef9

Browse files
authored
Merge pull request #504 from ckb-devrel/develop
Merge v0.4.13 into master
2 parents be56ce4 + 44ab81d commit 1551ef9

4 files changed

Lines changed: 166 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# @offckb/cli
22

3+
## 0.4.13
4+
5+
### Patch Changes
6+
7+
- 0923956: Fix two CLI UX bugs reported in #498:
8+
9+
- `offckb balance` without an address no longer leaks the SDK's `Unknown address format undefined`. The address argument is now required, so commander prints a clear `error: missing required argument 'toAddress'`.
10+
- Commander parameter/option errors (unknown option, invalid option value, missing argument) are printed exactly once on stderr instead of twice.
11+
312
## 0.4.12
413

514
### Patch Changes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@offckb/cli",
3-
"version": "0.4.12",
3+
"version": "0.4.13",
44
"description": "ckb development network for your first try",
55
"author": "CKB EcoFund",
66
"license": "MIT",

src/cli.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ mainnetForkOverrideOption(
234234
});
235235

236236
program
237-
.command('balance [toAddress]')
237+
.command('balance <toAddress>')
238238
.description('Check account balance (CKB + detected SUDT/xUDT), only devnet and testnet')
239239
.option('--network <network>', 'Specify the network to check', 'devnet')
240240
.addOption(new Option('--udt-kind <kind>', 'Filter by UDT kind').choices(['sudt', 'xudt']))
@@ -387,7 +387,13 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
387387
if (error instanceof CommanderError && error.exitCode === 0) return;
388388
const message = error instanceof Error ? error.message : String(error);
389389
const code = error instanceof CommanderError ? error.code : 'COMMAND_FAILED';
390-
logger.failure(code, message);
390+
// Commander errors were already written to stderr once by writeErr in
391+
// configureCommanderErrors (non-JSON mode); re-emitting would duplicate
392+
// the line. In JSON mode writeErr is suppressed, so logger.failure emits
393+
// the single structured record instead.
394+
if (!(error instanceof CommanderError) || logger.isJsonMode()) {
395+
logger.failure(code, message);
396+
}
391397
process.exitCode = error instanceof CommanderError ? error.exitCode : 1;
392398
}
393399
}

tests/cli-errors.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const mockBalanceOf = jest.fn();
2+
const mockLogsCommand = jest.fn();
3+
const jsonMode = { value: false };
4+
5+
jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() }));
6+
jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() }));
7+
jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() }));
8+
jest.mock('../src/cmd/deposit', () => ({ deposit: jest.fn() }));
9+
jest.mock('../src/cmd/deploy', () => ({ deploy: jest.fn() }));
10+
jest.mock('../src/cmd/transfer', () => ({ transfer: jest.fn() }));
11+
jest.mock('../src/cmd/balance', () => ({ balanceOf: (...args: unknown[]) => mockBalanceOf(...args) }));
12+
jest.mock('../src/cmd/udt', () => ({ udtIssue: jest.fn(), udtDestroy: jest.fn() }));
13+
jest.mock('../src/cmd/create', () => ({ createScriptProject: jest.fn() }));
14+
jest.mock('../src/cmd/config', () => ({ Config: jest.fn() }));
15+
jest.mock('../src/cmd/devnet-config', () => ({ devnetConfig: jest.fn() }));
16+
jest.mock('../src/cmd/devnet-fork', () => ({ devnetFork: jest.fn() }));
17+
jest.mock('../src/cmd/devnet-info', () => ({ devnetInfo: jest.fn() }));
18+
jest.mock('../src/cmd/debug', () => ({
19+
debugSingleScript: jest.fn(),
20+
debugTransaction: jest.fn(),
21+
parseSingleScriptOption: jest.fn(),
22+
}));
23+
jest.mock('../src/cmd/system-scripts', () => ({ printSystemScripts: jest.fn() }));
24+
jest.mock('../src/cmd/transfer-all', () => ({ transferAll: jest.fn() }));
25+
jest.mock('../src/cmd/logs', () => ({ logsCommand: (...args: unknown[]) => mockLogsCommand(...args) }));
26+
jest.mock('../src/cmd/status', () => ({ status: jest.fn() }));
27+
jest.mock('../src/scripts/gen', () => ({ genSystemScriptsJsonFile: jest.fn() }));
28+
jest.mock('../src/tools/ckb-debugger', () => ({ CKBDebugger: { runWithArgs: jest.fn() } }));
29+
30+
// The logger mock mirrors the real UnifiedLogger: in JSON mode failure()
31+
// writes one structured record to stderr, otherwise it writes the plain
32+
// message to stderr like the winston console transport does. That way the
33+
// tests can assert each commander error reaches stderr exactly once.
34+
const mockFailure = jest.fn((code: string, message: string) => {
35+
process.stderr.write(jsonMode.value ? `${JSON.stringify({ ok: false, code, message })}\n` : `${message}\n`);
36+
});
37+
jest.mock('../src/util/logger', () => ({
38+
logger: {
39+
success: jest.fn(),
40+
info: jest.fn(),
41+
warn: jest.fn(),
42+
error: jest.fn(),
43+
debug: jest.fn(),
44+
result: jest.fn(),
45+
failure: (...args: unknown[]) => mockFailure(args[0] as string, args[1] as string),
46+
setJsonMode: (enabled: boolean) => {
47+
jsonMode.value = enabled;
48+
},
49+
isJsonMode: () => jsonMode.value,
50+
hasResult: () => false,
51+
},
52+
}));
53+
54+
function loadCli() {
55+
jest.resetModules();
56+
const cli = require('../src/cli') as typeof import('../src/cli');
57+
return { runCli: cli.runCli };
58+
}
59+
60+
function captureStderr() {
61+
const writes: string[] = [];
62+
const spy = jest.spyOn(process.stderr, 'write');
63+
spy.mockImplementation(((chunk: unknown) => {
64+
writes.push(String(chunk));
65+
return true;
66+
}) as typeof process.stderr.write);
67+
return {
68+
writes,
69+
text: () => writes.join(''),
70+
count: (needle: string) => writes.filter((w) => w.includes(needle)).length,
71+
restore: () => spy.mockRestore(),
72+
};
73+
}
74+
75+
describe('CLI error output', () => {
76+
beforeEach(() => {
77+
jest.clearAllMocks();
78+
jsonMode.value = false;
79+
process.exitCode = undefined;
80+
});
81+
82+
afterEach(() => {
83+
process.exitCode = undefined;
84+
});
85+
86+
it('balance without an address prints a clear missing-argument error once', async () => {
87+
const { runCli } = loadCli();
88+
const stderr = captureStderr();
89+
try {
90+
await runCli(['node', 'offckb', 'balance']);
91+
} finally {
92+
stderr.restore();
93+
}
94+
95+
expect(stderr.count("missing required argument 'toAddress'")).toBe(1);
96+
expect(stderr.text()).not.toContain('Unknown address format');
97+
expect(mockBalanceOf).not.toHaveBeenCalled();
98+
expect(process.exitCode).toBe(1);
99+
});
100+
101+
it('prints an unknown-option error exactly once on stderr', async () => {
102+
const { runCli } = loadCli();
103+
const stderr = captureStderr();
104+
try {
105+
await runCli(['node', 'offckb', 'balance', 'ckt1qaddress', '--unknown-flag']);
106+
} finally {
107+
stderr.restore();
108+
}
109+
110+
expect(stderr.count("unknown option '--unknown-flag'")).toBe(1);
111+
expect(process.exitCode).toBe(1);
112+
});
113+
114+
it('prints an invalid option value error exactly once on stderr', async () => {
115+
const { runCli } = loadCli();
116+
const stderr = captureStderr();
117+
try {
118+
await runCli(['node', 'offckb', 'logs', '--tail', 'abc']);
119+
} finally {
120+
stderr.restore();
121+
}
122+
123+
expect(stderr.count('--tail must be a positive integer')).toBe(1);
124+
expect(process.exitCode).toBe(1);
125+
});
126+
127+
it('emits a single structured record for commander errors in JSON mode', async () => {
128+
const { runCli } = loadCli();
129+
const stderr = captureStderr();
130+
try {
131+
await runCli(['node', 'offckb', '--json', 'balance']);
132+
} finally {
133+
stderr.restore();
134+
}
135+
136+
expect(stderr.count('commander.missingArgument')).toBe(1);
137+
expect(stderr.text()).toContain('"ok":false');
138+
expect(stderr.text()).not.toContain("error: missing required argument 'toAddress'\nerror:");
139+
expect(process.exitCode).toBe(1);
140+
});
141+
142+
it('still invokes balanceOf when an address is provided', async () => {
143+
const { runCli } = loadCli();
144+
await runCli(['node', 'offckb', 'balance', 'ckt1qaddress']);
145+
146+
expect(mockBalanceOf).toHaveBeenCalledWith('ckt1qaddress', expect.anything());
147+
});
148+
});

0 commit comments

Comments
 (0)