Skip to content

Commit a2ebbab

Browse files
committed
fix(cli): address wenshao review — Windows deferred, PATH, rollback, headers
- Fix Windows deferred update: move pendingDir cleanup to catch block so it only runs on error, not on successful deferred path - Add ensurePathInShellRc: auto-append ~/.local/bin to shell rc on npm→standalone migration so the wrapper is actually discoverable - Wire rollbackStandaloneUpdate into /doctor rollback subcommand - Add license headers to all 4 new source files - Fix spawnAndCapture double-settle with settled flag - Add tests for ensureBinWrapper and ensurePathInShellRc - Clarify signature verification comments (test key, not production)
1 parent 09c9d8d commit a2ebbab

6 files changed

Lines changed: 309 additions & 22 deletions

File tree

packages/cli/src/ui/commands/doctorCommand.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,14 @@ describe('doctorCommand', () => {
177177
it('should complete memory subcommand names', async () => {
178178
await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([
179179
'memory',
180+
'rollback',
180181
]);
181182
await expect(
182183
doctorCommand.completion!(mockContext, 'mem'),
183184
).resolves.toEqual(['memory']);
185+
await expect(
186+
doctorCommand.completion!(mockContext, 'roll'),
187+
).resolves.toEqual(['rollback']);
184188
await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual(
185189
[],
186190
);
@@ -1049,6 +1053,8 @@ describe('doctorCommand', () => {
10491053
});
10501054

10511055
it('should advertise the memory subcommand on the parent doctor argumentHint', () => {
1052-
expect(doctorCommand.argumentHint).toBe('[memory] [--sample] [--snapshot]');
1056+
expect(doctorCommand.argumentHint).toBe(
1057+
'[memory|rollback] [--sample] [--snapshot]',
1058+
);
10531059
});
10541060
});

packages/cli/src/ui/commands/doctorCommand.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
isHighHeapPressure,
1717
writeMemoryHeapSnapshot,
1818
} from '../../utils/memoryDiagnostics.js';
19+
import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js';
20+
import { getInstallationInfo } from '../../utils/installationInfo.js';
1921
import { t } from '../../i18n/index.js';
2022
import {
2123
collectMemoryDiagnostics,
@@ -24,7 +26,8 @@ import {
2426
import { formatMemoryUsage } from '../utils/formatters.js';
2527

2628
const MEMORY_SUBCOMMAND = 'memory';
27-
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND] as const;
29+
const ROLLBACK_SUBCOMMAND = 'rollback';
30+
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, ROLLBACK_SUBCOMMAND] as const;
2831
function getHeapSnapshotSensitiveDataWarning(): string {
2932
return t(
3033
'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.',
@@ -49,12 +52,13 @@ export const doctorCommand: SlashCommand = {
4952
},
5053
kind: CommandKind.BUILT_IN,
5154
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
52-
argumentHint: '[memory] [--sample] [--snapshot]',
55+
argumentHint: '[memory|rollback] [--sample] [--snapshot]',
5356
examples: [
5457
'/doctor',
5558
'/doctor memory',
5659
'/doctor memory --sample',
5760
'/doctor memory --snapshot',
61+
'/doctor rollback',
5862
],
5963
completion: async (_context, partialArg) => {
6064
const trimmed = partialArg.trimStart();
@@ -71,6 +75,17 @@ export const doctorCommand: SlashCommand = {
7175
const shouldWriteHeapSnapshot = subCommandArgs.includes('--snapshot');
7276
const shouldSampleMemory = subCommandArgs.includes('--sample');
7377

78+
if (subCommand === ROLLBACK_SUBCOMMAND) {
79+
if (executionMode === 'acp') {
80+
return {
81+
type: 'message' as const,
82+
messageType: 'error' as const,
83+
content: t('Rollback is not available in ACP mode.'),
84+
};
85+
}
86+
return rollbackDoctorAction(context);
87+
}
88+
7489
if (subCommand === MEMORY_SUBCOMMAND) {
7590
if (abortSignal?.aborted) {
7691
return;
@@ -233,6 +248,15 @@ export const doctorCommand: SlashCommand = {
233248
argumentHint: '[--json] [--sample] [--snapshot]',
234249
action: memoryDoctorAction,
235250
},
251+
{
252+
name: 'rollback',
253+
get description() {
254+
return t('Roll back a standalone update to the previous version');
255+
},
256+
kind: CommandKind.BUILT_IN,
257+
supportedModes: ['interactive', 'non_interactive'] as const,
258+
action: rollbackDoctorAction,
259+
},
236260
],
237261
};
238262

@@ -382,3 +406,52 @@ function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string {
382406
);
383407
return lines.join('\n');
384408
}
409+
410+
function rollbackDoctorAction(context: CommandContext) {
411+
const installInfo = getInstallationInfo(process.cwd(), false);
412+
if (!installInfo.isStandalone || !installInfo.standaloneDir) {
413+
const msg = t('Rollback is only available for standalone installations.');
414+
if (context.executionMode === 'interactive') {
415+
context.ui.addItem({ type: 'info', text: msg }, Date.now());
416+
return;
417+
}
418+
return {
419+
type: 'message' as const,
420+
messageType: 'info' as const,
421+
content: msg,
422+
};
423+
}
424+
425+
if (process.platform === 'win32') {
426+
const winMsg = t(
427+
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.',
428+
);
429+
if (context.executionMode === 'interactive') {
430+
context.ui.addItem({ type: 'info', text: winMsg }, Date.now());
431+
return;
432+
}
433+
return {
434+
type: 'message' as const,
435+
messageType: 'info' as const,
436+
content: winMsg,
437+
};
438+
}
439+
440+
const success = rollbackStandaloneUpdate(installInfo.standaloneDir);
441+
const msg = success
442+
? t(
443+
'Rollback successful. Restart your terminal to use the previous version.',
444+
)
445+
: t('Rollback failed: no previous version found (.old directory missing).');
446+
const messageType = success ? 'info' : 'error';
447+
448+
if (context.executionMode === 'interactive') {
449+
context.ui.addItem({ type: messageType, text: msg }, Date.now());
450+
return;
451+
}
452+
return {
453+
type: 'message' as const,
454+
messageType: messageType as 'info' | 'error',
455+
content: msg,
456+
};
457+
}

packages/cli/src/utils/standalone-update-verify.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
17
import { describe, it, expect } from 'vitest';
28
import { verifySignature } from './standalone-update-verify.js';
39

packages/cli/src/utils/standalone-update-verify.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
17
/**
28
* Ed25519 signature verification for standalone update integrity.
39
*

packages/cli/src/utils/standalone-update.test.ts

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
17
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
28
import * as fs from 'node:fs';
39
import * as path from 'node:path';
410
import * as os from 'node:os';
5-
import { rollbackStandaloneUpdate } from './standalone-update.js';
11+
import {
12+
rollbackStandaloneUpdate,
13+
ensureBinWrapper,
14+
ensurePathInShellRc,
15+
} from './standalone-update.js';
616

717
describe('standalone-update', () => {
818
let tempDir: string;
@@ -54,7 +64,6 @@ describe('standalone-update', () => {
5464
fs.mkdirSync(standaloneDir);
5565
fs.mkdirSync(oldDir);
5666

57-
// Current version
5867
fs.writeFileSync(
5968
path.join(standaloneDir, 'manifest.json'),
6069
JSON.stringify({
@@ -65,7 +74,6 @@ describe('standalone-update', () => {
6574
);
6675
fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new');
6776

68-
// Old version
6977
fs.writeFileSync(
7078
path.join(oldDir, 'manifest.json'),
7179
JSON.stringify({
@@ -79,20 +87,17 @@ describe('standalone-update', () => {
7987
const result = rollbackStandaloneUpdate(standaloneDir);
8088
expect(result).toBe(true);
8189

82-
// Verify the swap happened
8390
const manifest = JSON.parse(
8491
fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'),
8592
);
8693
expect(manifest.version).toBe('0.16.2');
8794
expect(
8895
fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'),
8996
).toBe('old');
90-
91-
// .old should no longer exist
9297
expect(fs.existsSync(oldDir)).toBe(false);
9398
});
9499

95-
it('returns false if .old has invalid manifest content', () => {
100+
it('succeeds even with minimal manifest in .old', () => {
96101
const standaloneDir = path.join(tempDir, 'qwen-code');
97102
const oldDir = `${standaloneDir}.old`;
98103
fs.mkdirSync(standaloneDir);
@@ -102,12 +107,143 @@ describe('standalone-update', () => {
102107
path.join(standaloneDir, 'manifest.json'),
103108
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),
104109
);
105-
// Old dir has manifest — rollback should succeed even with minimal manifest
106110
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
107111

108112
const result = rollbackStandaloneUpdate(standaloneDir);
109-
// It should succeed because manifest.json EXISTS (content validation is not done in rollback)
110113
expect(result).toBe(true);
111114
});
112115
});
116+
117+
describe('ensureBinWrapper', () => {
118+
it('creates a Unix shell wrapper script', () => {
119+
const libDir = path.join(tempDir, '.local', 'lib');
120+
const standaloneDir = path.join(libDir, 'qwen-code');
121+
fs.mkdirSync(standaloneDir, { recursive: true });
122+
123+
// Isolate HOME so ensurePathInShellRc doesn't touch real shell rc
124+
const origHome = process.env['HOME'];
125+
const origShell = process.env['SHELL'];
126+
process.env['HOME'] = tempDir;
127+
process.env['SHELL'] = '/bin/zsh';
128+
try {
129+
ensureBinWrapper(standaloneDir, 'darwin-arm64');
130+
} finally {
131+
process.env['HOME'] = origHome;
132+
process.env['SHELL'] = origShell;
133+
}
134+
135+
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen');
136+
expect(fs.existsSync(wrapperPath)).toBe(true);
137+
const content = fs.readFileSync(wrapperPath, 'utf-8');
138+
expect(content).toContain('#!/bin/sh');
139+
expect(content).toContain(standaloneDir);
140+
const mode = fs.statSync(wrapperPath).mode;
141+
expect(mode & 0o111).toBeGreaterThan(0);
142+
});
143+
144+
it('creates a Windows cmd wrapper', () => {
145+
const libDir = path.join(tempDir, '.local', 'lib');
146+
const standaloneDir = path.join(libDir, 'qwen-code');
147+
fs.mkdirSync(standaloneDir, { recursive: true });
148+
149+
ensureBinWrapper(standaloneDir, 'win-x64');
150+
151+
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd');
152+
expect(fs.existsSync(wrapperPath)).toBe(true);
153+
const content = fs.readFileSync(wrapperPath, 'utf-8');
154+
expect(content).toContain('@echo off');
155+
});
156+
157+
it('does not overwrite existing wrapper', () => {
158+
const libDir = path.join(tempDir, '.local', 'lib');
159+
const standaloneDir = path.join(libDir, 'qwen-code');
160+
const binDir = path.join(tempDir, '.local', 'bin');
161+
fs.mkdirSync(standaloneDir, { recursive: true });
162+
fs.mkdirSync(binDir, { recursive: true });
163+
164+
const origHome = process.env['HOME'];
165+
const origShell = process.env['SHELL'];
166+
process.env['HOME'] = tempDir;
167+
process.env['SHELL'] = '/bin/zsh';
168+
169+
const wrapperPath = path.join(binDir, 'qwen');
170+
fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 });
171+
172+
try {
173+
ensureBinWrapper(standaloneDir, 'linux-x64');
174+
expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe('existing-content');
175+
} finally {
176+
process.env['HOME'] = origHome;
177+
process.env['SHELL'] = origShell;
178+
}
179+
});
180+
});
181+
182+
describe('ensurePathInShellRc', () => {
183+
it('appends PATH export to zshrc when SHELL is zsh', () => {
184+
const binDir = path.join(tempDir, 'bin');
185+
const zshrc = path.join(tempDir, '.zshrc');
186+
fs.writeFileSync(zshrc, '# existing config\n');
187+
188+
const origShell = process.env['SHELL'];
189+
const origHome = process.env['HOME'];
190+
process.env['SHELL'] = '/bin/zsh';
191+
process.env['HOME'] = tempDir;
192+
193+
try {
194+
ensurePathInShellRc(binDir);
195+
const content = fs.readFileSync(zshrc, 'utf-8');
196+
expect(content).toContain('# Added by Qwen Code standalone installer');
197+
expect(content).toContain(`export PATH="${binDir}:$PATH"`);
198+
} finally {
199+
process.env['SHELL'] = origShell;
200+
process.env['HOME'] = origHome;
201+
}
202+
});
203+
204+
it('skips if marker already in rc file', () => {
205+
const binDir = path.join(tempDir, 'bin');
206+
const zshrc = path.join(tempDir, '.zshrc');
207+
fs.writeFileSync(
208+
zshrc,
209+
`# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\n`,
210+
);
211+
212+
const origShell = process.env['SHELL'];
213+
const origHome = process.env['HOME'];
214+
process.env['SHELL'] = '/bin/zsh';
215+
process.env['HOME'] = tempDir;
216+
217+
try {
218+
ensurePathInShellRc(binDir);
219+
const content = fs.readFileSync(zshrc, 'utf-8');
220+
const matches = content.match(
221+
/# Added by Qwen Code standalone installer/g,
222+
);
223+
expect(matches).toHaveLength(1);
224+
} finally {
225+
process.env['SHELL'] = origShell;
226+
process.env['HOME'] = origHome;
227+
}
228+
});
229+
230+
it('does nothing for unknown shells', () => {
231+
const binDir = path.join(tempDir, 'bin');
232+
const origShell = process.env['SHELL'];
233+
const origHome = process.env['HOME'];
234+
process.env['SHELL'] = '/bin/csh';
235+
process.env['HOME'] = tempDir;
236+
237+
try {
238+
ensurePathInShellRc(binDir);
239+
// No rc file should be created
240+
expect(
241+
fs.readdirSync(tempDir).filter((f) => f.startsWith('.')),
242+
).toHaveLength(0);
243+
} finally {
244+
process.env['SHELL'] = origShell;
245+
process.env['HOME'] = origHome;
246+
}
247+
});
248+
});
113249
});

0 commit comments

Comments
 (0)