mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(cli): add standalone auto-update support (#4629)
* feat(cli): add standalone auto-update support Standalone installs (via install-qwen-standalone.sh) previously fell through to npm global detection, causing auto-update to run `npm install -g` which doesn't update the standalone binary. Now the CLI detects standalone installs by looking for manifest.json in ancestor directories, then performs a self-update: download archive from OSS/GitHub, verify SHA256, extract, and atomically replace the installation directory. Includes: input validation (semver + target allowlist), lockfile for concurrency, Windows deferred replacement via helper script, and tar path traversal protection. Closes #4627 * feat(cli): harden standalone update — smoke test, rollback, signature - Smoke test: spawn new binary with --version after extraction, before replacing the installation. Rejects broken/wrong-arch packages. - Rollback: keep .old directory after updates. Export rollbackStandaloneUpdate() for future /doctor rollback integration. - Signature: Ed25519 verification of SHA256SUMS via node:crypto. Embeds public key; CI signs with scripts/sign-release.sh. Graceful degradation when .sig is not yet published (transition period). Set QWEN_REQUIRE_SIGNATURE=1 to enforce strict mode. * fix(cli): address CI + review findings on standalone update - Fix TS4111: use bracket notation for process.env index signature - Fix CodeQL: validate paths for cmd.exe metacharacters before bat script - Fix Windows lock race: keep lock alive during deferred swap, release in bat script after move completes * fix(cli): use event payload in update UI + add rollback tests - handleAutoUpdate: handleUpdateFailed/handleUpdateSuccess now read the message from the event payload instead of hardcoding text. Windows deferred users now see the correct "applied after exit" message. - Add standalone-update.test.ts with 4 tests covering rollback scenarios. * fix(cli): address review findings — 5 bugs in standalone-update 1. SHA256SUMS: handle GNU coreutils binary-mode `*` prefix in filename 2. acquireLock: treat NaN (empty/corrupt lock file) as stale, not held 3. tryFetch: consume response body on non-OK status to release sockets 4. Windows bat: add 30s timeout to PID wait loop preventing infinite hang 5. Finally block: clean orphaned .new directory on Windows error path * fix(cli): update handleAutoUpdate tests for payload-driven messages Tests previously asserted hardcoded handler text. After making handlers read messages from event payload, the assertions need to reflect the emitted payload values instead. * feat(cli): auto-migrate npm users to standalone when prefix is not writable When a global npm install requires sudo (prefix not writable), the auto-update now falls back to the standalone path instead of spawning a doomed `npm install -g` that always EACCES. - installationInfo.ts: detect writable prefix with fs.accessSync; return isStandalone=true with fallback dir when not writable - standalone-update.ts: support first-time migration (no existing manifest), detect platform target, create bin wrapper, refuse to overwrite unmanaged directories Closes #4643 * fix(cli): remove unused _mockedAccessSync (TS6133) * 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) * fix(cli): add i18n translations for /doctor rollback command The mustTranslateKeys test requires zh-CN and zh-TW translations for all built-in command descriptions. Add translations for the new rollback subcommand and its user-facing messages. * fix(cli): skip Unix-only wrapper tests on Windows The ensureBinWrapper Unix-wrapper test asserts on POSIX file mode bits (mode & 0o111) which Windows NTFS does not implement. Skip Unix-specific tests when running on Windows runners. * fix(cli): address CodeQL + test coverage on standalone-update - Add assertPathWithin() and resolve install paths before spawning the smoke-test process. Closes the CodeQL "shell command built from environment values" finding by guaranteeing nodeBin/cliBin stay inside the freshly extracted install directory. - Add performStandaloneUpdate test coverage for the four pre-flight failure cases (bad version, missing manifest, unknown target, concurrent lock) so the core update path is no longer untested. * fix(cli): address wenshao review round 2 — bat error handling, rollback safety, shell injection, lock race, test coverage Five issues raised in PR #4629 review (2026-06-01): 1. Windows bat: add errorlevel checks after each move, rollback on second-move failure, timestamped log to qwen-update.log 2. Unix rollback: inner try/catch on recovery rename; compound error with manual mv instructions if both renames fail 3. Shell injection: assertSafeForShellEmbed() validates paths before embedding in /bin/sh wrappers and shell rc (platform-aware: allows backslash on Windows) 4. Lock-file race: bat writes .swap sentinel before rename; acquireLock refuses to reclaim stale-PID lock while sentinel exists 5. Test coverage: 4 new handleAutoUpdate standalone-path tests (done, deferred, error, npm-not-entered) + 3 shell-safety rejection tests * fix(cli): prevent symlink attack on staging directory via mkdtempSync Replace fixed-name .qwen-code-update-staging with mkdtempSync random suffix — an attacker who can write to parentDir can no longer pre-create a symlink to hijack recursive deletion. Also adds lstat guard before cleanup rmSync (defense-in-depth) and fixes a potential tempDir leak if mkdtempSync for extractDir fails. * fix(cli): harden standalone-update — download size guard, shell-meta coverage, cleanup logging - Add MAX_DOWNLOAD_BYTES (512 MB) streaming size guard in downloadToFile to prevent resource exhaustion from oversized responses - Extend UNSAFE_SHELL_META_UNIX to also reject semicolon and single-quote - Add debugLogger.warn when extractDir cleanup is skipped (unexpected type) - Add Windows backslash acceptance test for assertSafeForShellEmbed * fix(cli): harden standalone-update — tar preservePaths, timeout signal, shell-rc validation Defense-in-depth improvements from independent code review: - Explicitly set preservePaths:false on tar extract to prevent symlink path traversal - Check err.signal in spawnAndCapture for cross-platform timeout detection - Validate binDir in ensurePathInShellRc independently of calling context * fix(cli): address review round 3 — archive timeout, stderr capture, rollback result type 1. Separate ARCHIVE_TIMEOUT_MS (5 min) from FETCH_TIMEOUT_MS (30s) so large archive downloads on slow networks don't time out prematurely 2. Capture stderr from smoke-test execFile and include it in the error message so users see the actual crash reason (missing lib, etc.) 3. Change rollbackStandaloneUpdate return from boolean to discriminated result type (ok/reason/detail) so doctorCommand shows accurate messages for each failure mode instead of a generic "not found" * fix(test): drop pre-mkdir in shell-safety double-quote test The 'rejects standaloneDir with embedded double-quote' test called fs.mkdirSync on a path containing `"`, which ENOENTs on Windows since NTFS forbids `"` in path components. The validator assertSafeForShellEmbed runs synchronously before any FS access in ensureBinWrapper, so the pre-mkdir was vestigial — removing it makes the test pass on Windows without weakening the assertion. * refactor(standalone-update): remove over-engineered defenses Remove theoretical security mechanisms that solve near-impossible attack scenarios but introduce real complexity and new bug surface: - sentinel mechanism (solves a <2s race window requiring 3 independent low-probability events to align) - assertSafeForShellEmbed + UNSAFE_SHELL_META regexes (default paths are ~/.local/lib/qwen-code, never contain metacharacters) - assertPathWithin (execFile doesn't invoke shell; CodeQL false positive) - lstatSync symlink guard (mkdtempSync random naming already prevents predictable symlink pre-creation) Retains unsafeCmdChars in atomicReplace (bat script interpolation is a real context), bat error handling, archive timeout, and stderr capture. * fix(cli): harden standalone-update error paths and lock release * fix(cli): address multi-model review findings on standalone-update Security: - Preserve per-mirror error details in downloadWithFallback diagnostics - Deduplicate SHA256SUMS.sig download via downloadWithFallback - Upgrade signature-skip log from info to warn with enforcement hint - Add post-extraction path traversal validation for Windows ZIP - Add symlink target validation in tar extraction filter - Add shell metacharacter validation (assertSafeForShellEmbed) for binDir in ensurePathInShellRc and standaloneDir in ensureBinWrapper - Validate cmd.exe metacharacters BEFORE filesystem mutation in atomicReplace Windows path Correctness: - Fix spawnAndCapture treating string error codes as exit 0 - Release lock when mkdtempSync for extractDir fails - Add concurrent lock protection to rollbackStandaloneUpdate - Fix misleading manual-recovery message after auto-recovery succeeds - Fix t() dynamic i18n key — split into translated prefix + raw detail - Reject unsupported architectures in detectTarget instead of silent fallback to linux-x64 Performance: - Compute SHA256 hash during download stream (single-pass), eliminating the second full read of the 50-150 MB archive Quality: - Fix macOS bash shell rc priority (.bash_profile over .bashrc) - Rename npmPrefixDir to npmPackageDir for accuracy - Extract magic number 3 to MAX_MANIFEST_SEARCH_DEPTH constant Tests: - Add rollback concurrent lock protection tests (live + dead PID) - Add fish shell ensurePathInShellRc test - Add shell metacharacter rejection test for ensurePathInShellRc * fix(cli): move shell validation after platform branch in ensureBinWrapper assertSafeForShellEmbed rejects backslashes, which are normal in Windows paths. Move the call into the Unix-only else branch so Windows wrapper creation uses only the cmd.exe metacharacter check. * fix(cli): add post-extraction path validation for Unix tar Matches the Windows zip path's validateExtractedPaths call. The tar filter's string-level path.resolve check cannot detect chained symlink attacks (symlink A → ".", then A/payload → "../../etc"). The post-extraction realpath walk catches these. * fix(cli): fix standalone auto-update reachability and rebase conflicts - Add missing isStandalone/standaloneDir fields to getStandaloneInstallInfo so handleAutoUpdate and /doctor rollback can detect standalone installs - Remove redundant findStandaloneDir (conflicted with stricter detection) - Fix race condition: move mkdirSync after lock acquisition - Ensure parent directory exists before lock file creation - Clean up empty .old directory after first-time npm→standalone migration - Align bash RC file selection with install script (Linux → .bashrc) - Use ?? instead of || for manifest.target fallback - Upgrade ensureBinWrapper failure logging from debug to warn - Add debug logging for SHA256SUMS.sig download failures
This commit is contained in:
parent
6d083fad5d
commit
9e4bea606a
16 changed files with 1734 additions and 19 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -17597,6 +17597,7 @@
|
|||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -1902,6 +1902,17 @@ export default {
|
|||
'Show current process memory diagnostics',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'Record a CPU profile for Chrome DevTools analysis',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'Roll back a standalone update to the previous version',
|
||||
'Rollback is not available in ACP mode.':
|
||||
'Rollback is not available in ACP mode.',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'Rollback is only available for standalone installations.',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'Rollback successful. Restart your terminal to use the previous version.',
|
||||
'Rollback failed:': 'Rollback failed:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.',
|
||||
'Save a durable memory to the memory system.':
|
||||
'Save a durable memory to the memory system.',
|
||||
'Ask a quick side question without affecting the main conversation':
|
||||
|
|
|
|||
|
|
@ -1489,6 +1489,16 @@ export default {
|
|||
'Show current process memory diagnostics': '顯示目前程序的內存診斷。',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'將獨立安裝回滾到上一個版本',
|
||||
'Rollback is not available in ACP mode.': '回滾在 ACP 模式下不可用。',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'回滾僅適用於獨立安裝。',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'回滾成功。請重啟終端以使用上一個版本。',
|
||||
'Rollback failed:': '回滾失敗:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'在 Windows 上回滾需要手動操作。請將安裝目錄中的 qwen-code.old 重新命名為 qwen-code。',
|
||||
'Save a durable memory to the memory system.': '將持久記憶保存到記憶系統。',
|
||||
'Ask a quick side question without affecting the main conversation':
|
||||
'在不影響主對話的情況下快速提問旁支問題',
|
||||
|
|
|
|||
|
|
@ -1724,6 +1724,16 @@ export default {
|
|||
'Show current process memory diagnostics': '显示当前进程的内存诊断。',
|
||||
'Record a CPU profile for Chrome DevTools analysis':
|
||||
'录制 CPU 性能分析文件,用于 Chrome DevTools 分析',
|
||||
'Roll back a standalone update to the previous version':
|
||||
'将独立安装回滚到上一个版本',
|
||||
'Rollback is not available in ACP mode.': '回滚在 ACP 模式下不可用。',
|
||||
'Rollback is only available for standalone installations.':
|
||||
'回滚仅适用于独立安装。',
|
||||
'Rollback successful. Restart your terminal to use the previous version.':
|
||||
'回滚成功。请重启终端以使用上一个版本。',
|
||||
'Rollback failed:': '回滚失败:',
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.':
|
||||
'在 Windows 上回滚需要手动操作。请将安装目录中的 qwen-code.old 重命名为 qwen-code。',
|
||||
'Save a durable memory to the memory system.':
|
||||
'将一条持久记忆保存到记忆系统。',
|
||||
'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。',
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ describe('doctorCommand', () => {
|
|||
await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([
|
||||
'memory',
|
||||
'cpu-profile',
|
||||
'rollback',
|
||||
]);
|
||||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'mem'),
|
||||
|
|
@ -185,6 +186,9 @@ describe('doctorCommand', () => {
|
|||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'cpu'),
|
||||
).resolves.toEqual(['cpu-profile']);
|
||||
await expect(
|
||||
doctorCommand.completion!(mockContext, 'roll'),
|
||||
).resolves.toEqual(['rollback']);
|
||||
await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
|
|
@ -1054,7 +1058,7 @@ describe('doctorCommand', () => {
|
|||
|
||||
it('should advertise the memory subcommand on the parent doctor argumentHint', () => {
|
||||
expect(doctorCommand.argumentHint).toBe(
|
||||
'[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
|
||||
'[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
startCpuProfile,
|
||||
stopCpuProfile,
|
||||
} from '../../utils/cpuProfiler.js';
|
||||
import { rollbackStandaloneUpdate } from '../../utils/standalone-update.js';
|
||||
import { getInstallationInfo } from '../../utils/installationInfo.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
collectMemoryDiagnostics,
|
||||
|
|
@ -30,7 +32,8 @@ import { formatMemoryUsage } from '../utils/formatters.js';
|
|||
|
||||
const MEMORY_SUBCOMMAND = 'memory';
|
||||
const CPU_PROFILE_SUBCOMMAND = 'cpu-profile';
|
||||
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND] as const;
|
||||
const ROLLBACK_SUBCOMMAND = 'rollback';
|
||||
const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND, ROLLBACK_SUBCOMMAND] as const;
|
||||
function getHeapSnapshotSensitiveDataWarning(): string {
|
||||
return t(
|
||||
'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.',
|
||||
|
|
@ -55,7 +58,7 @@ export const doctorCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
argumentHint: '[memory|cpu-profile] [--sample] [--snapshot] [--duration]',
|
||||
argumentHint: '[memory|cpu-profile|rollback] [--sample] [--snapshot] [--duration]',
|
||||
examples: [
|
||||
'/doctor',
|
||||
'/doctor memory',
|
||||
|
|
@ -63,6 +66,7 @@ export const doctorCommand: SlashCommand = {
|
|||
'/doctor memory --snapshot',
|
||||
'/doctor cpu-profile',
|
||||
'/doctor cpu-profile --duration 10',
|
||||
'/doctor rollback',
|
||||
],
|
||||
completion: async (_context, partialArg) => {
|
||||
const trimmed = partialArg.trimStart();
|
||||
|
|
@ -79,6 +83,17 @@ export const doctorCommand: SlashCommand = {
|
|||
const shouldWriteHeapSnapshot = subCommandArgs.includes('--snapshot');
|
||||
const shouldSampleMemory = subCommandArgs.includes('--sample');
|
||||
|
||||
if (subCommand === ROLLBACK_SUBCOMMAND) {
|
||||
if (executionMode === 'acp') {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content: t('Rollback is not available in ACP mode.'),
|
||||
};
|
||||
}
|
||||
return rollbackDoctorAction(context);
|
||||
}
|
||||
|
||||
if (subCommand === MEMORY_SUBCOMMAND) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
|
|
@ -255,6 +270,15 @@ export const doctorCommand: SlashCommand = {
|
|||
argumentHint: '[--duration <seconds>]',
|
||||
action: cpuProfileDoctorAction,
|
||||
},
|
||||
{
|
||||
name: 'rollback',
|
||||
get description() {
|
||||
return t('Roll back a standalone update to the previous version');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive'] as const,
|
||||
action: rollbackDoctorAction,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -580,3 +604,57 @@ async function cpuProfileDoctorAction(
|
|||
}
|
||||
return { type: 'message', messageType: 'info', content: successMsg };
|
||||
}
|
||||
|
||||
function rollbackDoctorAction(context: CommandContext) {
|
||||
const installInfo = getInstallationInfo(process.cwd(), false);
|
||||
if (!installInfo.isStandalone || !installInfo.standaloneDir) {
|
||||
const msg = t('Rollback is only available for standalone installations.');
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: 'info', text: msg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const winMsg = t(
|
||||
'Rollback on Windows requires manual intervention. Rename qwen-code.old to qwen-code in your installation directory.',
|
||||
);
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: 'info', text: winMsg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'info' as const,
|
||||
content: winMsg,
|
||||
};
|
||||
}
|
||||
|
||||
const result = rollbackStandaloneUpdate(installInfo.standaloneDir);
|
||||
let msg: string;
|
||||
let messageType: 'info' | 'error';
|
||||
if (result.ok) {
|
||||
msg = t(
|
||||
'Rollback successful. Restart your terminal to use the previous version.',
|
||||
);
|
||||
messageType = 'info';
|
||||
} else {
|
||||
msg = `${t('Rollback failed:')} ${result.detail}`;
|
||||
messageType = 'error';
|
||||
}
|
||||
|
||||
if (context.executionMode === 'interactive') {
|
||||
context.ui.addItem({ type: messageType, text: msg }, Date.now());
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: messageType as 'info' | 'error',
|
||||
content: msg,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js';
|
|||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import EventEmitter from 'node:events';
|
||||
import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
|
||||
import { performStandaloneUpdate } from './standalone-update.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
|
||||
vi.mock('./installationInfo.js', async () => {
|
||||
|
|
@ -22,6 +23,10 @@ vi.mock('./installationInfo.js', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('./standalone-update.js', () => ({
|
||||
performStandaloneUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./updateEventEmitter.js', async () => {
|
||||
const { EventEmitter } = await import('node:events');
|
||||
return {
|
||||
|
|
@ -38,6 +43,7 @@ interface MockChildProcess extends EventEmitter {
|
|||
}
|
||||
|
||||
const mockGetInstallationInfo = vi.mocked(getInstallationInfo);
|
||||
const mockPerformStandaloneUpdate = vi.mocked(performStandaloneUpdate);
|
||||
|
||||
describe('handleAutoUpdate', () => {
|
||||
let mockSpawn: Mock;
|
||||
|
|
@ -263,6 +269,125 @@ describe('handleAutoUpdate', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('handleAutoUpdate — standalone path', () => {
|
||||
let mockSpawn: Mock;
|
||||
let mockUpdateInfo: UpdateObject;
|
||||
let mockSettings: LoadedSettings;
|
||||
let emitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSpawn = vi.fn();
|
||||
vi.clearAllMocks();
|
||||
emitSpy = vi.spyOn(updateEventEmitter, 'emit');
|
||||
mockUpdateInfo = {
|
||||
update: {
|
||||
latest: '2.0.0',
|
||||
current: '1.0.0',
|
||||
type: 'major',
|
||||
name: '@qwen-code/qwen-code',
|
||||
},
|
||||
message: 'An update is available!',
|
||||
};
|
||||
mockSettings = {
|
||||
merged: { general: { enableAutoUpdate: true } },
|
||||
} as LoadedSettings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls performStandaloneUpdate and does NOT spawn npm', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: 'npm i -g @qwen-code/qwen-code@latest',
|
||||
updateMessage: '',
|
||||
isGlobal: false,
|
||||
isStandalone: true,
|
||||
standaloneDir: '/home/user/.local/lib/qwen-code',
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
mockPerformStandaloneUpdate.mockResolvedValue('done');
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()),
|
||||
);
|
||||
|
||||
expect(mockPerformStandaloneUpdate).toHaveBeenCalledWith(
|
||||
'/home/user/.local/lib/qwen-code',
|
||||
'2.0.0',
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits deferred message when result is "deferred"', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: undefined,
|
||||
updateMessage: '',
|
||||
isGlobal: false,
|
||||
isStandalone: true,
|
||||
standaloneDir: '/home/user/.local/lib/qwen-code',
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
mockPerformStandaloneUpdate.mockResolvedValue('deferred');
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()),
|
||||
);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-success', {
|
||||
message:
|
||||
'Update downloaded. It will be applied after you exit this session.',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits "done" success message when result is "done"', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: undefined,
|
||||
updateMessage: '',
|
||||
isGlobal: false,
|
||||
isStandalone: true,
|
||||
standaloneDir: '/home/user/.local/lib/qwen-code',
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
mockPerformStandaloneUpdate.mockResolvedValue('done');
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-success', expect.anything()),
|
||||
);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-success', {
|
||||
message:
|
||||
'Update successful! The new version will be used on your next run.',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits update-failed on rejection', async () => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: undefined,
|
||||
updateMessage: '',
|
||||
isGlobal: false,
|
||||
isStandalone: true,
|
||||
standaloneDir: '/home/user/.local/lib/qwen-code',
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
mockPerformStandaloneUpdate.mockRejectedValue(new Error('Download failed'));
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-failed', expect.anything()),
|
||||
);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed: Download failed. Re-run the installer to update manually.',
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUpdateHandler', () => {
|
||||
let addItem: Mock;
|
||||
let setUpdateInfo: Mock;
|
||||
|
|
@ -288,7 +413,7 @@ describe('setUpdateHandler', () => {
|
|||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update successful! The new version will be used on your next run.',
|
||||
text: 'Update successful!',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
|
@ -342,7 +467,7 @@ describe('setUpdateHandler', () => {
|
|||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update successful! The new version will be used on your next run.',
|
||||
text: 'Update successful!',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
|
@ -369,7 +494,7 @@ describe('setUpdateHandler', () => {
|
|||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Automatic update failed. Please try updating manually',
|
||||
text: 'Update failed',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
|
@ -402,7 +527,7 @@ describe('setUpdateHandler', () => {
|
|||
2,
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Update successful! The new version will be used on your next run.',
|
||||
text: 'Success!',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { updateEventEmitter } from './updateEventEmitter.js';
|
|||
import type { HistoryItemWithoutId } from '../ui/types.js';
|
||||
import { MessageType } from '../ui/types.js';
|
||||
import { spawnWrapper } from './spawnWrapper.js';
|
||||
import { performStandaloneUpdate } from './standalone-update.js';
|
||||
import type { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
|
|
@ -43,6 +44,27 @@ export function handleAutoUpdate(
|
|||
message: combinedMessage,
|
||||
});
|
||||
|
||||
if (
|
||||
installationInfo.isStandalone &&
|
||||
installationInfo.standaloneDir &&
|
||||
isAutoUpdateEnabled
|
||||
) {
|
||||
performStandaloneUpdate(installationInfo.standaloneDir, info.update.latest)
|
||||
.then((result) => {
|
||||
const message =
|
||||
result === 'deferred'
|
||||
? 'Update downloaded. It will be applied after you exit this session.'
|
||||
: 'Update successful! The new version will be used on your next run.';
|
||||
updateEventEmitter.emit('update-success', { message });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed: ${err.message}. Re-run the installer to update manually.`,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't automatically run the update if auto-update is disabled or no update command
|
||||
if (!installationInfo.updateCommand || !isAutoUpdateEnabled) {
|
||||
return;
|
||||
|
|
@ -113,20 +135,24 @@ export function setUpdateHandler(
|
|||
}, 60000);
|
||||
};
|
||||
|
||||
const handleUpdateFailed = () => {
|
||||
const handleUpdateFailed = (data: { message?: string }) => {
|
||||
setUpdateInfo(null);
|
||||
addItemOrDefer({
|
||||
type: MessageType.ERROR,
|
||||
text: `Automatic update failed. Please try updating manually`,
|
||||
text:
|
||||
data?.message ||
|
||||
'Automatic update failed. Please try updating manually',
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateSuccess = () => {
|
||||
const handleUpdateSuccess = (data: { message?: string }) => {
|
||||
successfullyInstalled = true;
|
||||
setUpdateInfo(null);
|
||||
addItemOrDefer({
|
||||
type: MessageType.INFO,
|
||||
text: `Update successful! The new version will be used on your next run.`,
|
||||
text:
|
||||
data?.message ||
|
||||
'Update successful! The new version will be used on your next run.',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ vi.mock('fs', async (importOriginal) => {
|
|||
existsSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
accessSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -198,9 +199,10 @@ describe('getInstallationInfo', () => {
|
|||
|
||||
expect(info.packageManager).toBe(PackageManager.STANDALONE);
|
||||
expect(info.isGlobal).toBe(true);
|
||||
expect(info.isStandalone).toBe(true);
|
||||
expect(info.standaloneDir).toBe(installDir);
|
||||
expect(info.updateCommand).toBeUndefined();
|
||||
expect(info.updateMessage).toContain('Standalone install detected');
|
||||
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
|
||||
expect(info.updateMessage).not.toContain('npm install');
|
||||
});
|
||||
|
||||
|
|
@ -242,8 +244,10 @@ describe('getInstallationInfo', () => {
|
|||
const info = getInstallationInfo(projectRoot, true);
|
||||
|
||||
expect(info.packageManager).toBe(PackageManager.STANDALONE);
|
||||
expect(info.isStandalone).toBe(true);
|
||||
expect(info.standaloneDir).toBe(installDir);
|
||||
expect(info.updateCommand).toBeUndefined();
|
||||
expect(info.updateMessage).toContain('install-qwen-standalone.ps1');
|
||||
expect(info.updateMessage).toContain('Standalone install detected');
|
||||
expect(info.updateMessage).not.toContain('npm install');
|
||||
});
|
||||
|
||||
|
|
@ -285,8 +289,9 @@ describe('getInstallationInfo', () => {
|
|||
|
||||
expect(info.packageManager).toBe(PackageManager.STANDALONE);
|
||||
expect(info.isGlobal).toBe(true);
|
||||
expect(info.isStandalone).toBe(true);
|
||||
expect(info.standaloneDir).toBe(installDir);
|
||||
expect(info.updateMessage).toContain('Standalone install detected');
|
||||
expect(info.updateMessage).toContain('install-qwen-standalone.sh');
|
||||
});
|
||||
|
||||
it('should fall back to npm when manifest.json is malformed', () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { createDebugLogger, isGitRepository } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as childProcess from 'node:child_process';
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ const STANDALONE_WINDOWS_INSTALLER =
|
|||
export interface InstallationInfo {
|
||||
packageManager: PackageManager;
|
||||
isGlobal: boolean;
|
||||
isStandalone?: boolean;
|
||||
standaloneDir?: string;
|
||||
updateCommand?: string;
|
||||
updateMessage?: string;
|
||||
}
|
||||
|
|
@ -174,7 +177,45 @@ export function getInstallationInfo(
|
|||
};
|
||||
}
|
||||
|
||||
// Assume global npm
|
||||
// Check if the package directory is writable to determine whether npm update requires sudo
|
||||
const npmPackageDir = path.dirname(path.dirname(realPath));
|
||||
let npmPrefixWritable = false;
|
||||
try {
|
||||
fs.accessSync(npmPackageDir, fs.constants.W_OK);
|
||||
npmPrefixWritable = true;
|
||||
} catch {
|
||||
// Not writable (e.g., /usr/local/lib/node_modules owned by root)
|
||||
}
|
||||
|
||||
if (!npmPrefixWritable && isAutoUpdateEnabled) {
|
||||
// npm prefix requires sudo — fall back to standalone update path
|
||||
// which installs to ~/.local/lib/qwen-code/ (user-writable)
|
||||
const installRoot = process.env['HOME'] || os.homedir();
|
||||
if (!installRoot || installRoot === '/') {
|
||||
// Cannot determine a safe user-writable location; skip migration
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
isGlobal: true,
|
||||
updateMessage:
|
||||
'Update requires sudo. Run: sudo npm install -g @qwen-code/qwen-code@latest',
|
||||
};
|
||||
}
|
||||
const fallbackStandaloneDir = path.join(
|
||||
installRoot,
|
||||
'.local',
|
||||
'lib',
|
||||
'qwen-code',
|
||||
);
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
isGlobal: true,
|
||||
isStandalone: true,
|
||||
standaloneDir: fallbackStandaloneDir,
|
||||
updateMessage:
|
||||
'npm install requires sudo. Migrating to standalone installer for automatic updates.',
|
||||
};
|
||||
}
|
||||
|
||||
const updateCommand = 'npm install -g @qwen-code/qwen-code@latest';
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
|
|
@ -203,14 +244,15 @@ function getStandaloneInstallInfo(
|
|||
process.platform === 'win32'
|
||||
? `powershell -ExecutionPolicy Bypass -c "irm ${STANDALONE_WINDOWS_INSTALLER} | iex"`
|
||||
: `curl -fsSL ${STANDALONE_UNIX_INSTALLER} | bash`;
|
||||
const updatePrefix = isAutoUpdateEnabled
|
||||
? 'Standalone install detected. Automatic in-place updates are not supported yet.'
|
||||
: 'Standalone install detected.';
|
||||
|
||||
return {
|
||||
packageManager: PackageManager.STANDALONE,
|
||||
isGlobal: true,
|
||||
updateMessage: `${updatePrefix} Please rerun the standalone installer to update: ${updateCommand}`,
|
||||
isStandalone: true,
|
||||
standaloneDir: installDir,
|
||||
updateMessage: isAutoUpdateEnabled
|
||||
? 'Standalone install detected. Attempting to automatically update now...'
|
||||
: `Standalone install detected. Please rerun the standalone installer to update: ${updateCommand}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
39
packages/cli/src/utils/standalone-update-verify.test.ts
Normal file
39
packages/cli/src/utils/standalone-update-verify.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { verifySignature } from './standalone-update-verify.js';
|
||||
|
||||
describe('standalone-update-verify', () => {
|
||||
// Since we can't use the embedded key's private counterpart in tests,
|
||||
// we test the structure and error paths.
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('rejects signature with wrong length', () => {
|
||||
expect(() => verifySignature('test content', 'dG9vc2hvcnQ=')).toThrow(
|
||||
'Invalid signature length',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid signature (correct length but wrong bytes)', () => {
|
||||
// 64 bytes of zeros, base64 encoded
|
||||
const fakeSig = Buffer.alloc(64, 0).toString('base64');
|
||||
expect(() => verifySignature('test content', fakeSig)).toThrow(
|
||||
'signature verification failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects tampered content with valid-length signature', () => {
|
||||
// Even a random 64-byte signature should fail verification
|
||||
const randomSig = Buffer.from(
|
||||
Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)),
|
||||
).toString('base64');
|
||||
expect(() =>
|
||||
verifySignature('some SHA256SUMS content', randomSig),
|
||||
).toThrow('signature verification failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/cli/src/utils/standalone-update-verify.ts
Normal file
56
packages/cli/src/utils/standalone-update-verify.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ed25519 signature verification for standalone update integrity.
|
||||
*
|
||||
* The release CI signs SHA256SUMS with an Ed25519 private key, producing
|
||||
* SHA256SUMS.sig (base64-encoded raw 64-byte signature). This module
|
||||
* verifies that signature using the embedded public key.
|
||||
*
|
||||
* Key generation (one-time):
|
||||
* openssl genpkey -algorithm Ed25519 -out release-signing-key.pem
|
||||
* openssl pkey -in release-signing-key.pem -pubout -outform DER | base64
|
||||
*/
|
||||
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
|
||||
// Ed25519 public key in DER/SPKI format, base64-encoded.
|
||||
// Replace this with the production key generated by the release team.
|
||||
// The corresponding private key must be stored in CI secrets only.
|
||||
const RELEASE_PUBLIC_KEY_DER_B64 =
|
||||
'MCowBQYDK2VwAyEAr9WRFLDauibZQKCe1oKfuFZn6zRMaEkD5+KVqN6mKM4=';
|
||||
|
||||
/**
|
||||
* Verifies an Ed25519 signature over the SHA256SUMS content.
|
||||
* @param sha256sumsContent - The raw text of SHA256SUMS
|
||||
* @param signatureBase64 - Base64-encoded 64-byte Ed25519 signature
|
||||
* @throws if signature is invalid or verification fails
|
||||
*/
|
||||
export function verifySignature(
|
||||
sha256sumsContent: string,
|
||||
signatureBase64: string,
|
||||
): void {
|
||||
const key = createPublicKey({
|
||||
key: Buffer.from(RELEASE_PUBLIC_KEY_DER_B64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
|
||||
const signature = Buffer.from(signatureBase64, 'base64');
|
||||
if (signature.length !== 64) {
|
||||
throw new Error(
|
||||
`Invalid signature length: expected 64 bytes, got ${signature.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const valid = verify(null, Buffer.from(sha256sumsContent), key, signature);
|
||||
if (!valid) {
|
||||
throw new Error(
|
||||
'SHA256SUMS signature verification failed — possible tampering detected',
|
||||
);
|
||||
}
|
||||
}
|
||||
405
packages/cli/src/utils/standalone-update.test.ts
Normal file
405
packages/cli/src/utils/standalone-update.test.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
rollbackStandaloneUpdate,
|
||||
ensureBinWrapper,
|
||||
ensurePathInShellRc,
|
||||
performStandaloneUpdate,
|
||||
} from './standalone-update.js';
|
||||
|
||||
describe('standalone-update', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('rollbackStandaloneUpdate', () => {
|
||||
it('returns no-old when .old directory does not exist', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result).toHaveProperty('reason', 'no-old');
|
||||
});
|
||||
|
||||
it('returns no-manifest when .old directory has no manifest.json', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.mkdirSync(oldDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result).toHaveProperty('reason', 'no-manifest');
|
||||
});
|
||||
|
||||
it('swaps current with .old directory on valid rollback', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.mkdirSync(oldDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
version: '0.17.0',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(oldDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
version: '0.16.2',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(oldDir, 'marker.txt'), 'old');
|
||||
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'),
|
||||
);
|
||||
expect(manifest.version).toBe('0.16.2');
|
||||
expect(
|
||||
fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'),
|
||||
).toBe('old');
|
||||
expect(fs.existsSync(oldDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('succeeds even with minimal manifest in .old', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.mkdirSync(oldDir);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),
|
||||
);
|
||||
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
|
||||
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureBinWrapper', () => {
|
||||
// Unix wrapper test relies on POSIX file permissions (mode bits) and
|
||||
// the SHELL env var, neither of which behave consistently on Windows.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'creates a Unix shell wrapper script',
|
||||
() => {
|
||||
const libDir = path.join(tempDir, '.local', 'lib');
|
||||
const standaloneDir = path.join(libDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir, { recursive: true });
|
||||
|
||||
// Isolate HOME so ensurePathInShellRc doesn't touch real shell rc
|
||||
const origHome = process.env['HOME'];
|
||||
const origShell = process.env['SHELL'];
|
||||
process.env['HOME'] = tempDir;
|
||||
process.env['SHELL'] = '/bin/zsh';
|
||||
try {
|
||||
ensureBinWrapper(standaloneDir, 'darwin-arm64');
|
||||
} finally {
|
||||
process.env['HOME'] = origHome;
|
||||
process.env['SHELL'] = origShell;
|
||||
}
|
||||
|
||||
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen');
|
||||
expect(fs.existsSync(wrapperPath)).toBe(true);
|
||||
const content = fs.readFileSync(wrapperPath, 'utf-8');
|
||||
expect(content).toContain('#!/bin/sh');
|
||||
expect(content).toContain(standaloneDir);
|
||||
const mode = fs.statSync(wrapperPath).mode;
|
||||
expect(mode & 0o111).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('creates a Windows cmd wrapper', () => {
|
||||
const libDir = path.join(tempDir, '.local', 'lib');
|
||||
const standaloneDir = path.join(libDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir, { recursive: true });
|
||||
|
||||
ensureBinWrapper(standaloneDir, 'win-x64');
|
||||
|
||||
const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd');
|
||||
expect(fs.existsSync(wrapperPath)).toBe(true);
|
||||
const content = fs.readFileSync(wrapperPath, 'utf-8');
|
||||
expect(content).toContain('@echo off');
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not overwrite existing wrapper',
|
||||
() => {
|
||||
const libDir = path.join(tempDir, '.local', 'lib');
|
||||
const standaloneDir = path.join(libDir, 'qwen-code');
|
||||
const binDir = path.join(tempDir, '.local', 'bin');
|
||||
fs.mkdirSync(standaloneDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const origHome = process.env['HOME'];
|
||||
const origShell = process.env['SHELL'];
|
||||
process.env['HOME'] = tempDir;
|
||||
process.env['SHELL'] = '/bin/zsh';
|
||||
|
||||
const wrapperPath = path.join(binDir, 'qwen');
|
||||
fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 });
|
||||
|
||||
try {
|
||||
ensureBinWrapper(standaloneDir, 'linux-x64');
|
||||
expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe(
|
||||
'existing-content',
|
||||
);
|
||||
} finally {
|
||||
process.env['HOME'] = origHome;
|
||||
process.env['SHELL'] = origShell;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('performStandaloneUpdate', () => {
|
||||
it('rejects invalid version format', async () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, 'not-a-version'),
|
||||
).rejects.toThrow('Invalid version format');
|
||||
});
|
||||
|
||||
it('rejects directory without manifest as non-managed install', async () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
// No manifest.json — could be user data
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.0.0'),
|
||||
).rejects.toThrow('not a Qwen Code standalone install');
|
||||
});
|
||||
|
||||
it('rejects unknown target in manifest', async () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'freebsd-mips',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.0.0'),
|
||||
).rejects.toThrow('Unknown target');
|
||||
});
|
||||
|
||||
it('fails gracefully when another update is in progress', async () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const parentDir = path.dirname(standaloneDir);
|
||||
fs.mkdirSync(standaloneDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
name: '@qwen-code/qwen-code',
|
||||
target: 'darwin-arm64',
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate held lock from a live process (current PID)
|
||||
const lockPath = path.join(parentDir, '.qwen-update.lock');
|
||||
fs.writeFileSync(lockPath, String(process.pid));
|
||||
|
||||
await expect(
|
||||
performStandaloneUpdate(standaloneDir, '1.0.0'),
|
||||
).rejects.toThrow('Another update is already in progress');
|
||||
|
||||
// Clean up lock
|
||||
fs.unlinkSync(lockPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollbackStandaloneUpdate — concurrent lock protection', () => {
|
||||
it('returns error when an active update holds the lock', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
const lockPath = path.join(tempDir, '.qwen-update.lock');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.mkdirSync(oldDir);
|
||||
fs.writeFileSync(path.join(standaloneDir, 'manifest.json'), '{}');
|
||||
fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');
|
||||
fs.writeFileSync(lockPath, String(process.pid));
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.detail).toContain('auto-update is currently in progress');
|
||||
}
|
||||
fs.unlinkSync(lockPath);
|
||||
});
|
||||
|
||||
it('proceeds when lock has dead PID', () => {
|
||||
const standaloneDir = path.join(tempDir, 'qwen-code');
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
const lockPath = path.join(tempDir, '.qwen-update.lock');
|
||||
fs.mkdirSync(standaloneDir);
|
||||
fs.mkdirSync(oldDir);
|
||||
fs.writeFileSync(
|
||||
path.join(standaloneDir, 'manifest.json'),
|
||||
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(oldDir, 'manifest.json'),
|
||||
JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.16.0' }),
|
||||
);
|
||||
fs.writeFileSync(lockPath, '999999999');
|
||||
const result = rollbackStandaloneUpdate(standaloneDir);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => {
|
||||
it('appends PATH export to zshrc when SHELL is zsh', () => {
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const zshrc = path.join(tempDir, '.zshrc');
|
||||
fs.writeFileSync(zshrc, '# existing config\n');
|
||||
|
||||
const origShell = process.env['SHELL'];
|
||||
const origHome = process.env['HOME'];
|
||||
process.env['SHELL'] = '/bin/zsh';
|
||||
process.env['HOME'] = tempDir;
|
||||
|
||||
try {
|
||||
ensurePathInShellRc(binDir);
|
||||
const content = fs.readFileSync(zshrc, 'utf-8');
|
||||
expect(content).toContain('# Added by Qwen Code standalone installer');
|
||||
expect(content).toContain(`export PATH="${binDir}:$PATH"`);
|
||||
} finally {
|
||||
process.env['SHELL'] = origShell;
|
||||
process.env['HOME'] = origHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips if marker already in rc file', () => {
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const zshrc = path.join(tempDir, '.zshrc');
|
||||
fs.writeFileSync(
|
||||
zshrc,
|
||||
`# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\n`,
|
||||
);
|
||||
|
||||
const origShell = process.env['SHELL'];
|
||||
const origHome = process.env['HOME'];
|
||||
process.env['SHELL'] = '/bin/zsh';
|
||||
process.env['HOME'] = tempDir;
|
||||
|
||||
try {
|
||||
ensurePathInShellRc(binDir);
|
||||
const content = fs.readFileSync(zshrc, 'utf-8');
|
||||
const matches = content.match(
|
||||
/# Added by Qwen Code standalone installer/g,
|
||||
);
|
||||
expect(matches).toHaveLength(1);
|
||||
} finally {
|
||||
process.env['SHELL'] = origShell;
|
||||
process.env['HOME'] = origHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('appends fish_add_path for fish shell', () => {
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const fishDir = path.join(tempDir, '.config', 'fish');
|
||||
const fishConfig = path.join(fishDir, 'config.fish');
|
||||
fs.mkdirSync(fishDir, { recursive: true });
|
||||
fs.writeFileSync(fishConfig, '# existing config\n');
|
||||
const origShell = process.env['SHELL'];
|
||||
const origHome = process.env['HOME'];
|
||||
process.env['SHELL'] = '/usr/bin/fish';
|
||||
process.env['HOME'] = tempDir;
|
||||
try {
|
||||
ensurePathInShellRc(binDir);
|
||||
const content = fs.readFileSync(fishConfig, 'utf-8');
|
||||
expect(content).toContain('fish_add_path');
|
||||
expect(content).toContain(binDir);
|
||||
} finally {
|
||||
process.env['SHELL'] = origShell;
|
||||
process.env['HOME'] = origHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects binDir with shell metacharacters', () => {
|
||||
const binDir = path.join(tempDir, 'bin$(evil)');
|
||||
const origShell = process.env['SHELL'];
|
||||
const origHome = process.env['HOME'];
|
||||
process.env['SHELL'] = '/bin/zsh';
|
||||
process.env['HOME'] = tempDir;
|
||||
try {
|
||||
expect(() => ensurePathInShellRc(binDir)).toThrow(
|
||||
'unsafe for shell embedding',
|
||||
);
|
||||
} finally {
|
||||
process.env['SHELL'] = origShell;
|
||||
process.env['HOME'] = origHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('does nothing for unknown shells', () => {
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const origShell = process.env['SHELL'];
|
||||
const origHome = process.env['HOME'];
|
||||
process.env['SHELL'] = '/bin/csh';
|
||||
process.env['HOME'] = tempDir;
|
||||
|
||||
try {
|
||||
ensurePathInShellRc(binDir);
|
||||
// No rc file should be created
|
||||
expect(
|
||||
fs.readdirSync(tempDir).filter((f) => f.startsWith('.')),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
process.env['SHELL'] = origShell;
|
||||
process.env['HOME'] = origHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
860
packages/cli/src/utils/standalone-update.ts
Normal file
860
packages/cli/src/utils/standalone-update.ts
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fetch } from 'undici';
|
||||
import * as tar from 'tar';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { verifySignature } from './standalone-update-verify.js';
|
||||
|
||||
const debugLogger = createDebugLogger('STANDALONE_UPDATE');
|
||||
|
||||
const OSS_BASE =
|
||||
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/releases/qwen-code';
|
||||
const GITHUB_BASE = 'https://github.com/QwenLM/qwen-code/releases/download';
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
const ARCHIVE_TIMEOUT_MS = 300_000; // 5 min — archives are 50–150 MB
|
||||
|
||||
const VALID_TARGETS = new Set([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64',
|
||||
'linux-x64',
|
||||
'win-x64',
|
||||
]);
|
||||
|
||||
const SEMVER_RE = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/;
|
||||
|
||||
type UndiciResponse = Awaited<ReturnType<typeof fetch>>;
|
||||
|
||||
function normalizeVersion(version: string): string {
|
||||
if (!SEMVER_RE.test(version)) {
|
||||
throw new Error(`Invalid version format: ${version}`);
|
||||
}
|
||||
return version.startsWith('v') ? version : `v${version}`;
|
||||
}
|
||||
|
||||
function validateTarget(target: string): void {
|
||||
if (!VALID_TARGETS.has(target)) {
|
||||
throw new Error(`Unknown target: ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
function archiveFilename(target: string): string {
|
||||
const ext = target.startsWith('win') ? 'zip' : 'tar.gz';
|
||||
return `qwen-code-${target}.${ext}`;
|
||||
}
|
||||
|
||||
function escapePS(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
async function tryFetch(
|
||||
url: string,
|
||||
timeoutMs = FETCH_TIMEOUT_MS,
|
||||
): Promise<
|
||||
| { response: UndiciResponse; error?: undefined }
|
||||
| { response?: undefined; error: Error }
|
||||
> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (res.ok) return { response: res };
|
||||
await res.body?.cancel().catch(() => {});
|
||||
return { error: new Error(`HTTP ${res.status} ${res.statusText}`) };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
debugLogger.debug(`Fetch failed for ${url}: ${error.message}`);
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWithFallback(
|
||||
versionPath: string,
|
||||
filename: string,
|
||||
timeoutMs = FETCH_TIMEOUT_MS,
|
||||
): Promise<UndiciResponse> {
|
||||
const ossUrl = `${OSS_BASE}/${versionPath}/${filename}`;
|
||||
const ossResult = await tryFetch(ossUrl, timeoutMs);
|
||||
if (ossResult.response) return ossResult.response;
|
||||
|
||||
const ghUrl = `${GITHUB_BASE}/${versionPath}/${filename}`;
|
||||
const ghResult = await tryFetch(ghUrl, timeoutMs);
|
||||
if (ghResult.response) return ghResult.response;
|
||||
|
||||
throw new Error(
|
||||
`Failed to download ${filename}: OSS (${ossResult.error?.message ?? 'unknown'}), GitHub (${ghResult.error?.message ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyChecksum(
|
||||
actualHash: string,
|
||||
filename: string,
|
||||
versionPath: string,
|
||||
): Promise<void> {
|
||||
const response = await downloadWithFallback(versionPath, 'SHA256SUMS');
|
||||
const text = await response.text();
|
||||
|
||||
// Ed25519 signature verification of SHA256SUMS.
|
||||
// NOTE: Currently uses a test key. Once release CI signs with the production
|
||||
// key and publishes SHA256SUMS.sig, set QWEN_REQUIRE_SIGNATURE=1 to enforce.
|
||||
// Until then, verification is best-effort (passes when .sig exists, warns when not).
|
||||
const requireSig = process.env['QWEN_REQUIRE_SIGNATURE'] === '1';
|
||||
let sigResponse: UndiciResponse | undefined;
|
||||
try {
|
||||
sigResponse = await downloadWithFallback(versionPath, 'SHA256SUMS.sig');
|
||||
} catch (err) {
|
||||
debugLogger.debug('SHA256SUMS.sig not available:', err);
|
||||
}
|
||||
if (sigResponse) {
|
||||
const sigContent = await sigResponse.text();
|
||||
verifySignature(text, sigContent.trim());
|
||||
debugLogger.info('SHA256SUMS signature verified.');
|
||||
} else if (requireSig) {
|
||||
throw new Error(
|
||||
'SHA256SUMS.sig not found and QWEN_REQUIRE_SIGNATURE=1 is set',
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
'SHA256SUMS.sig not available — update integrity relies on SHA256 checksum only. ' +
|
||||
'Set QWEN_REQUIRE_SIGNATURE=1 to enforce signature verification.',
|
||||
);
|
||||
}
|
||||
|
||||
const expectedLine = text.split('\n').find((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) return false;
|
||||
// Handle GNU coreutils binary-mode prefix: "hash *filename"
|
||||
const name = parts[parts.length - 1]!.replace(/^\*/, '');
|
||||
return name === filename;
|
||||
});
|
||||
if (!expectedLine) {
|
||||
throw new Error(`No checksum found for ${filename} in SHA256SUMS`);
|
||||
}
|
||||
const expectedHash = expectedLine.trim().split(/\s+/)[0]!;
|
||||
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum mismatch: expected ${expectedHash}, got ${actualHash}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024; // 512 MB
|
||||
|
||||
async function downloadToFile(
|
||||
versionPath: string,
|
||||
filename: string,
|
||||
destPath: string,
|
||||
): Promise<string> {
|
||||
const response = await downloadWithFallback(
|
||||
versionPath,
|
||||
filename,
|
||||
ARCHIVE_TIMEOUT_MS,
|
||||
);
|
||||
const body = response.body;
|
||||
if (!body) throw new Error('Empty response body');
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_DOWNLOAD_BYTES) {
|
||||
await body.cancel().catch(() => {});
|
||||
throw new Error(
|
||||
`Download too large: ${contentLength} bytes exceeds ${MAX_DOWNLOAD_BYTES} limit`,
|
||||
);
|
||||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
let bytesWritten = 0;
|
||||
const dest = fs.createWriteStream(destPath);
|
||||
const sizeGuard = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
bytesWritten += chunk.length;
|
||||
if (bytesWritten > MAX_DOWNLOAD_BYTES) {
|
||||
callback(
|
||||
new Error(`Download exceeded ${MAX_DOWNLOAD_BYTES} byte limit`),
|
||||
);
|
||||
} else {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
}
|
||||
},
|
||||
});
|
||||
await pipeline(Readable.fromWeb(body), sizeGuard, dest);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function validateExtractedPaths(resolvedDest: string): void {
|
||||
const entries = fs.readdirSync(resolvedDest, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(
|
||||
String(entry.parentPath || entry.path),
|
||||
entry.name,
|
||||
);
|
||||
const resolved = fs.realpathSync(fullPath);
|
||||
if (
|
||||
!resolved.startsWith(resolvedDest + path.sep) &&
|
||||
resolved !== resolvedDest
|
||||
) {
|
||||
fs.rmSync(resolvedDest, { recursive: true, force: true });
|
||||
throw new Error(
|
||||
`Path traversal detected in archive: ${entry.name} resolves to ${resolved}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
target: string,
|
||||
): Promise<void> {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (target.startsWith('win')) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ps = spawn(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Expand-Archive -Path '${escapePS(archivePath)}' -DestinationPath '${escapePS(destDir)}' -Force`,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
ps.on('close', (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(`Expand-Archive exited with code ${code}`)),
|
||||
);
|
||||
ps.on('error', reject);
|
||||
});
|
||||
const resolvedDest = fs.realpathSync(destDir);
|
||||
validateExtractedPaths(resolvedDest);
|
||||
} else {
|
||||
const resolvedDest = path.resolve(destDir);
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
preservePaths: false,
|
||||
filter: (p, entry) => {
|
||||
if (p.startsWith('/') || p.includes('..')) return false;
|
||||
if (
|
||||
'type' in entry &&
|
||||
entry.type === 'SymbolicLink' &&
|
||||
'linkpath' in entry
|
||||
) {
|
||||
const linkTarget = path.resolve(
|
||||
resolvedDest,
|
||||
path.dirname(p),
|
||||
String(entry.linkpath),
|
||||
);
|
||||
if (
|
||||
!linkTarget.startsWith(resolvedDest + path.sep) &&
|
||||
linkTarget !== resolvedDest
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
// Post-extraction defense-in-depth: detect chained symlink attacks that
|
||||
// bypass the string-level filter (e.g. symlink A → ".", then A/payload → "../../etc")
|
||||
validateExtractedPaths(fs.realpathSync(destDir));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command and captures stdout, stderr, and exit code.
|
||||
*/
|
||||
function spawnAndCapture(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const child = execFile(
|
||||
command,
|
||||
args,
|
||||
{ timeout: timeoutMs },
|
||||
(err, out, stderr) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (
|
||||
err &&
|
||||
(('killed' in err && err.killed) || ('signal' in err && err.signal))
|
||||
) {
|
||||
reject(new Error('Smoke test timed out'));
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
const exitCode =
|
||||
'code' in err && typeof err.code === 'number' ? err.code : 1;
|
||||
resolve({ exitCode, stdout: out || '', stderr: stderr || '' });
|
||||
return;
|
||||
}
|
||||
resolve({ exitCode: 0, stdout: out || '', stderr: stderr || '' });
|
||||
},
|
||||
);
|
||||
child.on('error', (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the new installation can actually run by invoking --version.
|
||||
* Prevents replacing a working install with a broken binary.
|
||||
*/
|
||||
async function smokeTest(newInstallDir: string, target: string): Promise<void> {
|
||||
const resolvedInstallDir = path.resolve(newInstallDir);
|
||||
const nodeBin = target.startsWith('win')
|
||||
? path.join(resolvedInstallDir, 'node', 'node.exe')
|
||||
: path.join(resolvedInstallDir, 'node', 'bin', 'node');
|
||||
const cliBin = path.join(resolvedInstallDir, 'lib', 'cli.js');
|
||||
|
||||
if (!fs.existsSync(nodeBin)) {
|
||||
throw new Error(`Smoke test failed: node binary not found at ${nodeBin}`);
|
||||
}
|
||||
if (!fs.existsSync(cliBin)) {
|
||||
throw new Error(`Smoke test failed: cli.js not found at ${cliBin}`);
|
||||
}
|
||||
|
||||
const { exitCode, stdout, stderr } = await spawnAndCapture(
|
||||
nodeBin,
|
||||
[cliBin, '--version'],
|
||||
10_000,
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
const detail = stderr.trim() ? `: ${stderr.trim()}` : '';
|
||||
throw new Error(
|
||||
`Smoke test failed: new binary exited with code ${exitCode}${detail}`,
|
||||
);
|
||||
}
|
||||
const version = stdout.trim();
|
||||
if (!SEMVER_RE.test(version)) {
|
||||
throw new Error(
|
||||
`Smoke test failed: unexpected version output "${version}"`,
|
||||
);
|
||||
}
|
||||
debugLogger.info(`Smoke test passed: ${version}`);
|
||||
}
|
||||
|
||||
function acquireLock(lockPath: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const pidStr = fs.readFileSync(lockPath, 'utf-8').trim();
|
||||
const pid = parseInt(pidStr, 10);
|
||||
if (Number.isNaN(pid) || !isProcessAlive(pid)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
try {
|
||||
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// lock is held by another live process
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
// Remove an empty standaloneDir left behind by a failed first-time migration
|
||||
// (no manifest.json means it was just mkdir'd, never populated). Prevents
|
||||
// permanently blocking future updates with "exists but is not a standalone install".
|
||||
function cleanupEmptyStandaloneDir(standaloneDir: string): void {
|
||||
const manifestPath = path.join(standaloneDir, 'manifest.json');
|
||||
if (fs.existsSync(standaloneDir) && !fs.existsSync(manifestPath)) {
|
||||
fs.rmSync(standaloneDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const UNSAFE_SHELL_CHARS = /["`$\\;\n\r]/;
|
||||
const UNSAFE_CMD_CHARS = /[&|<>^%!"`\n\r]/;
|
||||
|
||||
function assertSafeForShellEmbed(p: string, context: string): void {
|
||||
if (UNSAFE_SHELL_CHARS.test(p)) {
|
||||
throw new Error(
|
||||
`${context} contains characters unsafe for shell embedding: ${p}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function atomicReplace(
|
||||
standaloneDir: string,
|
||||
newDir: string,
|
||||
lockPath: string,
|
||||
): 'done' | 'deferred' {
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
const pendingDir = `${standaloneDir}.new`;
|
||||
|
||||
if (fs.existsSync(oldDir)) {
|
||||
fs.rmSync(oldDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
// On Windows, the running node.exe holds file locks. Stage the new dir
|
||||
// as a sibling, then spawn a helper script that waits for this process
|
||||
// to exit before completing the swap.
|
||||
// Validate paths BEFORE any filesystem mutations
|
||||
if (
|
||||
UNSAFE_CMD_CHARS.test(standaloneDir) ||
|
||||
UNSAFE_CMD_CHARS.test(oldDir) ||
|
||||
UNSAFE_CMD_CHARS.test(pendingDir)
|
||||
) {
|
||||
throw new Error(
|
||||
'Installation path contains characters unsafe for deferred update script',
|
||||
);
|
||||
}
|
||||
if (fs.existsSync(pendingDir)) {
|
||||
fs.rmSync(pendingDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(newDir, pendingDir);
|
||||
|
||||
const lockFile = lockPath;
|
||||
const logFile = path.join(path.dirname(standaloneDir), 'qwen-update.log');
|
||||
// Bat script runs detached after Node exits. It must:
|
||||
// 1. Wait for this Node process to release file locks (<= 30s).
|
||||
// 2. Run both moves with errorlevel checks; if move #2 fails, roll back
|
||||
// move #1 so the user is never left without a working install.
|
||||
// 3. Log success/failure to qwen-update.log for post-mortem (the bat
|
||||
// runs with stdio:ignore — the log is the only diagnostic surface).
|
||||
const script = [
|
||||
'@echo off',
|
||||
'set /a TRIES=0',
|
||||
':wait',
|
||||
'set /a TRIES+=1',
|
||||
'if %TRIES% GTR 30 goto proceed',
|
||||
`tasklist /FI "PID eq ${process.pid}" 2>nul | find "${process.pid}" >nul && (timeout /t 1 >nul & goto wait)`,
|
||||
':proceed',
|
||||
`echo [%DATE% %TIME%] starting swap >> "${logFile}"`,
|
||||
`move /Y "${standaloneDir}" "${oldDir}"`,
|
||||
'if errorlevel 1 goto move1_failed',
|
||||
`move /Y "${pendingDir}" "${standaloneDir}"`,
|
||||
'if errorlevel 1 goto move2_failed',
|
||||
`echo [%DATE% %TIME%] swap completed >> "${logFile}"`,
|
||||
'goto cleanup',
|
||||
':move1_failed',
|
||||
`echo [%DATE% %TIME%] ERROR: failed to rename install to .old (errorlevel %errorlevel%) >> "${logFile}"`,
|
||||
'goto cleanup',
|
||||
':move2_failed',
|
||||
`echo [%DATE% %TIME%] ERROR: failed to promote .new; rolling back >> "${logFile}"`,
|
||||
`move /Y "${oldDir}" "${standaloneDir}"`,
|
||||
'if errorlevel 1 (',
|
||||
` echo [%DATE% %TIME%] CRITICAL: rollback also failed; manual recovery: move "${oldDir}" "${standaloneDir}" >> "${logFile}"`,
|
||||
') else (',
|
||||
` echo [%DATE% %TIME%] rollback succeeded >> "${logFile}"`,
|
||||
')',
|
||||
':cleanup',
|
||||
`del /F /Q "${lockFile}" 2>nul`,
|
||||
`del "%~f0"`,
|
||||
].join('\r\n');
|
||||
const scriptPath = path.join(
|
||||
path.dirname(standaloneDir),
|
||||
'qwen-update.bat',
|
||||
);
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
spawn('cmd.exe', ['/c', scriptPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
}).unref();
|
||||
return 'deferred';
|
||||
} else {
|
||||
// Unix: rename is atomic on same filesystem. newDir is a sibling of
|
||||
// standaloneDir (same parent), so EXDEV won't happen.
|
||||
fs.renameSync(standaloneDir, oldDir);
|
||||
try {
|
||||
fs.renameSync(newDir, standaloneDir);
|
||||
} catch (promoteErr) {
|
||||
// Recovery rename can also fail (e.g. FS hiccup, oldDir grabbed by
|
||||
// another process). Surface BOTH errors with manual-recovery steps so
|
||||
// the user is never silently left with a missing install.
|
||||
try {
|
||||
fs.renameSync(oldDir, standaloneDir);
|
||||
} catch (rollbackErr) {
|
||||
const detail =
|
||||
`Standalone update failed AND rollback failed.\n` +
|
||||
`Original error: ${(promoteErr as Error).message}\n` +
|
||||
`Rollback error: ${(rollbackErr as Error).message}\n` +
|
||||
`Manual recovery: mv "${oldDir}" "${standaloneDir}"`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw promoteErr;
|
||||
}
|
||||
// Keep .old for rollback instead of deleting immediately
|
||||
return 'done';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures ~/.local/bin/qwen exists and points to the standalone install.
|
||||
* Required for npm→standalone migration so the new binary is on PATH.
|
||||
*/
|
||||
export function ensureBinWrapper(standaloneDir: string, target: string): void {
|
||||
const binDir = path.join(path.dirname(standaloneDir), '..', 'bin');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
if (target.startsWith('win')) {
|
||||
if (UNSAFE_CMD_CHARS.test(standaloneDir)) {
|
||||
throw new Error(
|
||||
'standaloneDir contains characters unsafe for cmd.exe wrapper',
|
||||
);
|
||||
}
|
||||
const wrapperPath = path.join(binDir, 'qwen.cmd');
|
||||
if (!fs.existsSync(wrapperPath)) {
|
||||
const content = `@echo off\r\ncall "${standaloneDir}\\bin\\qwen.cmd" %*\r\n`;
|
||||
fs.writeFileSync(wrapperPath, content);
|
||||
}
|
||||
} else {
|
||||
assertSafeForShellEmbed(standaloneDir, 'standaloneDir');
|
||||
const wrapperPath = path.join(binDir, 'qwen');
|
||||
if (!fs.existsSync(wrapperPath)) {
|
||||
const content = `#!/bin/sh\nexec "${standaloneDir}/bin/qwen" "$@"\n`;
|
||||
fs.writeFileSync(wrapperPath, content, { mode: 0o755 });
|
||||
}
|
||||
ensurePathInShellRc(binDir);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.warn('Failed to create bin wrapper:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends binDir to the user's shell rc file if not already present.
|
||||
* Mirrors the logic in install-qwen-standalone.sh maybe_update_shell_path.
|
||||
*/
|
||||
export function ensurePathInShellRc(binDir: string): void {
|
||||
assertSafeForShellEmbed(binDir, 'binDir');
|
||||
|
||||
const shell = process.env['SHELL'] || '';
|
||||
let rcFile: string | null = null;
|
||||
const home = process.env['HOME'] || os.homedir();
|
||||
|
||||
if (shell.endsWith('/zsh')) {
|
||||
rcFile = path.join(home, '.zshrc');
|
||||
} else if (shell.endsWith('/bash')) {
|
||||
const bashrc = path.join(home, '.bashrc');
|
||||
const profile = path.join(home, '.bash_profile');
|
||||
// macOS bash reads .bash_profile for login shells; Linux reads .bashrc.
|
||||
// Match install-qwen-standalone.sh's maybe_update_shell_path logic.
|
||||
if (os.platform() === 'darwin') {
|
||||
rcFile = fs.existsSync(profile) ? profile : bashrc;
|
||||
} else {
|
||||
rcFile = bashrc;
|
||||
}
|
||||
} else if (shell.endsWith('/fish')) {
|
||||
rcFile = path.join(home, '.config', 'fish', 'config.fish');
|
||||
}
|
||||
|
||||
if (!rcFile) return;
|
||||
|
||||
try {
|
||||
const content = fs.existsSync(rcFile)
|
||||
? fs.readFileSync(rcFile, 'utf-8')
|
||||
: '';
|
||||
// Use a marker to detect our managed PATH entry precisely,
|
||||
// avoiding false positives from comments or $PATH-appended entries
|
||||
const marker = '# Added by Qwen Code standalone installer';
|
||||
if (content.includes(marker)) return;
|
||||
|
||||
const exportLine = shell.endsWith('/fish')
|
||||
? `\n${marker}\nfish_add_path "${binDir}"\n`
|
||||
: `\n${marker}\nexport PATH="${binDir}:$PATH"\n`;
|
||||
fs.appendFileSync(rcFile, exportLine);
|
||||
debugLogger.info(`Added ${binDir} to ${rcFile}`);
|
||||
} catch (err) {
|
||||
debugLogger.debug('Failed to update shell rc:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the current platform target string for standalone archives.
|
||||
*/
|
||||
function detectTarget(): string {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
if (platform === 'darwin') {
|
||||
return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
||||
}
|
||||
if (platform === 'win32') return 'win-x64';
|
||||
if (platform === 'linux') {
|
||||
if (arch === 'arm64') return 'linux-arm64';
|
||||
if (arch === 'x64') return 'linux-x64';
|
||||
}
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
||||
}
|
||||
|
||||
export async function performStandaloneUpdate(
|
||||
standaloneDir: string,
|
||||
newVersion: string,
|
||||
): Promise<'done' | 'deferred'> {
|
||||
const versionPath = normalizeVersion(newVersion);
|
||||
|
||||
let target: string;
|
||||
let isFirstTimeMigration = false;
|
||||
const manifestPath = path.join(standaloneDir, 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifestRaw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as { target?: string };
|
||||
target = manifest.target ?? detectTarget();
|
||||
} else if (fs.existsSync(standaloneDir)) {
|
||||
// Directory exists but has no manifest — not a managed Qwen install.
|
||||
// Refuse to overwrite to avoid data loss.
|
||||
throw new Error(
|
||||
`${standaloneDir} exists but is not a Qwen Code standalone install. Remove it manually to proceed.`,
|
||||
);
|
||||
} else {
|
||||
// First-time migration from npm — directory will be created after lock
|
||||
target = detectTarget();
|
||||
isFirstTimeMigration = true;
|
||||
}
|
||||
validateTarget(target);
|
||||
|
||||
const filename = archiveFilename(target);
|
||||
const parentDir = path.dirname(standaloneDir);
|
||||
|
||||
// Ensure the parent directory exists so the lock file can be created.
|
||||
// On first-time migration, standaloneDir (and its parent) may not exist yet.
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
// Use a lockfile to prevent concurrent updates.
|
||||
// Acquire lock BEFORE creating standaloneDir to prevent a concurrent
|
||||
// process from seeing the empty directory and throwing a misleading error.
|
||||
const lockPath = path.join(parentDir, '.qwen-update.lock');
|
||||
if (!acquireLock(lockPath)) {
|
||||
throw new Error('Another update is already in progress');
|
||||
}
|
||||
|
||||
if (isFirstTimeMigration) {
|
||||
fs.mkdirSync(standaloneDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Download to a temp dir in os.tmpdir(), then extract to a sibling dir
|
||||
// of standaloneDir to avoid EXDEV (cross-device rename).
|
||||
// extractDir uses mkdtempSync (random suffix) to prevent symlink
|
||||
// pre-creation attacks on predictable directory names.
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-code-update-'));
|
||||
let extractDir: string;
|
||||
let updateResult: 'done' | 'deferred' | undefined;
|
||||
try {
|
||||
extractDir = fs.mkdtempSync(path.join(parentDir, '.qwen-code-update-'));
|
||||
} catch (err) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
releaseLock(lockPath);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const archivePath = path.join(tempDir, filename);
|
||||
debugLogger.info(`Downloading ${filename} (${versionPath})...`);
|
||||
const archiveHash = await downloadToFile(
|
||||
versionPath,
|
||||
filename,
|
||||
archivePath,
|
||||
);
|
||||
|
||||
debugLogger.info('Verifying checksum...');
|
||||
await verifyChecksum(archiveHash, filename, versionPath);
|
||||
|
||||
debugLogger.info('Extracting archive...');
|
||||
await extractArchive(archivePath, extractDir, target);
|
||||
|
||||
const newInstallDir = path.join(extractDir, 'qwen-code');
|
||||
if (!fs.existsSync(path.join(newInstallDir, 'manifest.json'))) {
|
||||
throw new Error(
|
||||
'Extracted archive does not contain expected qwen-code directory',
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.info('Running smoke test...');
|
||||
await smokeTest(newInstallDir, target);
|
||||
|
||||
debugLogger.info('Replacing installation...');
|
||||
updateResult = atomicReplace(standaloneDir, newInstallDir, lockPath);
|
||||
|
||||
// Write rollback metadata so /doctor rollback knows what version is preserved.
|
||||
// For first-time migrations, the .old dir is the empty seed directory —
|
||||
// remove it since there is no meaningful version to roll back to.
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
if (fs.existsSync(oldDir)) {
|
||||
if (isFirstTimeMigration) {
|
||||
fs.rmSync(oldDir, { recursive: true, force: true });
|
||||
} else {
|
||||
try {
|
||||
const oldManifestPath = path.join(oldDir, 'manifest.json');
|
||||
let oldVersion = 'unknown';
|
||||
if (fs.existsSync(oldManifestPath)) {
|
||||
const oldManifest = JSON.parse(
|
||||
fs.readFileSync(oldManifestPath, 'utf-8'),
|
||||
) as { version?: string };
|
||||
oldVersion = oldManifest.version || 'unknown';
|
||||
}
|
||||
const rollbackInfo = {
|
||||
preservedVersion: oldVersion,
|
||||
updatedTo: versionPath,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: 'auto-update',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(oldDir, '.qwen-rollback-info.json'),
|
||||
JSON.stringify(rollbackInfo, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// Non-critical — rollback still works without metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bin wrapper exists (critical for npm→standalone migration)
|
||||
ensureBinWrapper(standaloneDir, target);
|
||||
|
||||
debugLogger.info('Standalone update complete.');
|
||||
return updateResult;
|
||||
} catch (err) {
|
||||
const pendingDir = `${standaloneDir}.new`;
|
||||
if (fs.existsSync(pendingDir)) {
|
||||
fs.rmSync(pendingDir, { recursive: true, force: true });
|
||||
}
|
||||
cleanupEmptyStandaloneDir(standaloneDir);
|
||||
throw err;
|
||||
} finally {
|
||||
// Only keep the lock alive when the bat script was spawned (deferred).
|
||||
// On failure or on Unix, release immediately.
|
||||
if (updateResult !== 'deferred') {
|
||||
releaseLock(lockPath);
|
||||
}
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
try {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RollbackResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'no-old' | 'no-manifest' | 'rename-failed';
|
||||
detail: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rolls back a standalone installation to the previous version (.old directory).
|
||||
*/
|
||||
export function rollbackStandaloneUpdate(
|
||||
standaloneDir: string,
|
||||
): RollbackResult {
|
||||
const lockPath = path.join(path.dirname(standaloneDir), '.qwen-update.lock');
|
||||
try {
|
||||
const pidStr = fs.readFileSync(lockPath, 'utf-8').trim();
|
||||
const pid = parseInt(pidStr, 10);
|
||||
if (!Number.isNaN(pid) && isProcessAlive(pid)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'rename-failed',
|
||||
detail:
|
||||
'An auto-update is currently in progress. Wait for it to finish before rolling back.',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// No lock file — safe to proceed
|
||||
}
|
||||
|
||||
const oldDir = `${standaloneDir}.old`;
|
||||
|
||||
if (!fs.existsSync(oldDir)) {
|
||||
return { ok: false, reason: 'no-old', detail: `${oldDir} does not exist` };
|
||||
}
|
||||
|
||||
const oldManifest = path.join(oldDir, 'manifest.json');
|
||||
if (!fs.existsSync(oldManifest)) {
|
||||
debugLogger.error('Rollback failed: .old directory has no manifest.json');
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-manifest',
|
||||
detail: `${oldDir}/manifest.json missing — .old may be corrupt`,
|
||||
};
|
||||
}
|
||||
|
||||
const failedDir = `${standaloneDir}.failed`;
|
||||
try {
|
||||
if (fs.existsSync(failedDir)) {
|
||||
fs.rmSync(failedDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(standaloneDir, failedDir);
|
||||
fs.renameSync(oldDir, standaloneDir);
|
||||
debugLogger.info('Rollback successful.');
|
||||
try {
|
||||
fs.rmSync(failedDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
debugLogger.debug(`Leftover .failed dir at ${failedDir}, safe to delete`);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
debugLogger.error('Rollback failed:', err);
|
||||
// Attempt to restore current if we moved it
|
||||
if (!fs.existsSync(standaloneDir) && fs.existsSync(failedDir)) {
|
||||
try {
|
||||
fs.renameSync(failedDir, standaloneDir);
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'rename-failed',
|
||||
detail: `Filesystem error: ${(err as Error).message}. Current installation was restored automatically.`,
|
||||
};
|
||||
} catch {
|
||||
// Critical failure — both dirs are in bad state
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'rename-failed',
|
||||
detail: `Filesystem error: ${(err as Error).message}. Manual recovery: mv "${oldDir}" "${standaloneDir}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
42
scripts/sign-release.sh
Executable file
42
scripts/sign-release.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env bash
|
||||
# Signs SHA256SUMS with an Ed25519 private key for release integrity verification.
|
||||
#
|
||||
# Usage:
|
||||
# RELEASE_SIGNING_KEY_PEM=/path/to/key.pem ./scripts/sign-release.sh dist/releases/vX.Y.Z/SHA256SUMS
|
||||
#
|
||||
# Output:
|
||||
# Creates SHA256SUMS.sig alongside the input file (base64-encoded 64-byte signature).
|
||||
#
|
||||
# Key generation (one-time):
|
||||
# openssl genpkey -algorithm Ed25519 -out release-signing-key.pem
|
||||
# openssl pkey -in release-signing-key.pem -pubout -outform DER | base64
|
||||
# # Embed the base64 public key in standalone-update-verify.ts
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${RELEASE_SIGNING_KEY_PEM:-}" ]]; then
|
||||
echo "ERROR: RELEASE_SIGNING_KEY_PEM environment variable must point to the Ed25519 private key." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${RELEASE_SIGNING_KEY_PEM}" ]]; then
|
||||
echo "ERROR: Key file not found: ${RELEASE_SIGNING_KEY_PEM}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA256SUMS_FILE="${1:-}"
|
||||
if [[ -z "${SHA256SUMS_FILE}" || ! -f "${SHA256SUMS_FILE}" ]]; then
|
||||
echo "Usage: $0 <path/to/SHA256SUMS>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIG_FILE="${SHA256SUMS_FILE}.sig"
|
||||
|
||||
# openssl pkeyutl -rawin signs raw content with Ed25519, outputs 64-byte signature
|
||||
openssl pkeyutl -sign -rawin \
|
||||
-inkey "${RELEASE_SIGNING_KEY_PEM}" \
|
||||
-in "${SHA256SUMS_FILE}" \
|
||||
-out /dev/stdout 2>/dev/null | base64 > "${SIG_FILE}"
|
||||
|
||||
echo "Signed: ${SIG_FILE}"
|
||||
echo "Signature (base64): $(cat "${SIG_FILE}")"
|
||||
Loading…
Add table
Add a link
Reference in a new issue