diff --git a/.vscodeignore b/.vscodeignore index ab1e862..35e8ec9 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -16,3 +16,4 @@ node_modules mcp-permissions.js backup-files build +**/test/** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5bc2a..31a3da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the "claude-code-chat" extension will be documented in th Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [2.0.7] - 2026-04-24 + +### πŸš€ Features Added +- **In-process installer**: Installing Claude Code no longer shells out to PowerShell, `curl | bash`, or `npm install -g`. The extension now fetches the platform-specific native binary directly (npm registry first, with Anthropic's CDN as a fallback), verifies the download with sha512/sha256, and writes it into the extension's own storage. Zero PATH, sudo, execution-policy, Node-version, or shell-quoting dependencies β€” if the extension installed, installing Claude works. +- **Progress updates during install**: The install modal now reports "Looking up…", "Downloading… (X%)", "Verifying…", and "Installing…" as it runs, with an automatic retry message if it falls back to the CDN source. +- **Cleaner install analytics**: `Install success` now includes `source` (npm/cdn) and `version`; `Install failed` now includes a typed `errorCode` (NETWORK / INTEGRITY / WRITE / AGGREGATE / UNSUPPORTED_PLATFORM) so failure buckets are meaningful instead of just "the shell command failed". + +### πŸ› Bug Fixes +- **Paths with spaces on Windows**: Fixed an edge case where the main Claude spawn could fail when the executable path contained spaces (e.g. `C:\Users\Some User\…`). Absolute paths now bypass `cmd.exe` wrapping entirely. + +### πŸ”§ Technical Improvements +- New `src/claudeDownloader.ts` module: self-contained, no new runtime dependencies. Includes a minimal in-tree tar parser so we can stream-extract the one binary we need from the npm tarball without bundling a tar library. +- Removed the old PowerShell / curl / npm install paths and the associated `_getKnownInstallLocation` / `_checkClaudeAvailable` helpers β€” the download flow now owns the install location end-to-end. + ## [2.0.6] - 2026-04-23 ### πŸš€ Features Added diff --git a/package-lock.json b/package-lock.json index 25a657f..6004eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-chat", - "version": "2.0.6", + "version": "2.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-chat", - "version": "2.0.6", + "version": "2.0.7", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index fee9c4a..2e970ba 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-code-chat", "displayName": "Chat for Claude Code", "description": "Beautiful Claude Code Chat Interface for VS Code", - "version": "2.0.6", + "version": "2.0.7", "publisher": "AndrePimenta", "author": "Andre Pimenta", "repository": { @@ -215,7 +215,9 @@ "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run lint", "lint": "eslint src", - "test": "vscode-test" + "test": "vscode-test", + "test:downloader": "npm run compile && mocha --ui tdd \"out/test/downloader*.test.js\" --reporter spec --timeout 360000", + "test:downloader:unit": "npm run compile && mocha --ui tdd out/test/downloader.test.js --reporter spec" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/src/claudeDownloader.ts b/src/claudeDownloader.ts new file mode 100644 index 0000000..6f7d15a --- /dev/null +++ b/src/claudeDownloader.ts @@ -0,0 +1,662 @@ +// Self-contained downloader for the Claude Code native binary. +// Tries the npm registry tarball first (smaller over the wire thanks to gzip), +// falls back to Anthropic's CDN (downloads.claude.ai) on any npm failure. +// +// Replaces the previous shell-based install flows (curl|bash, irm|iex, npm -g) +// so users never see execution-policy, EACCES, missing-bash, or Node-version +// failure modes. Everything runs in-process using Node built-ins only. + +import * as https from 'https'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import * as os from 'os'; +import * as cp from 'child_process'; +import { URL } from 'url'; + +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; +const DEFAULT_CDN_BASE = 'https://downloads.claude.ai/claude-code-releases'; +const NPM_PACKAGE_PREFIX = '@anthropic-ai/claude-code-'; +const META_TIMEOUT_MS = 30_000; +const PROGRESS_THROTTLE_MS = 250; + +export type DownloaderErrorCode = + | 'UNSUPPORTED_PLATFORM' + | 'NETWORK' + | 'INTEGRITY' + | 'WRITE' + | 'CANCELLED' + | 'AGGREGATE'; + +export interface PlatformKey { + key: string; // 'darwin-arm64' | 'linux-x64-musl' | 'win32-x64' | ... + binaryName: string; // 'claude' | 'claude.exe' + tarEntry: string; // 'package/claude' | 'package/claude.exe' +} + +export interface DownloadProgress { + phase: 'resolving' | 'downloading' | 'verifying' | 'installing' | 'fallback'; + source?: 'npm' | 'cdn'; + loaded?: number; + total?: number; + message?: string; +} + +export interface DownloadOptions { + destDir: string; + onProgress?: (p: DownloadProgress) => void; + signal?: AbortSignal; + /** @internal β€” override the npm registry base (for tests). */ + npmRegistry?: string; + /** @internal β€” override the Anthropic CDN base (for tests). */ + cdnBase?: string; +} + +export interface DownloadResult { + binaryPath: string; + version: string; + source: 'npm' | 'cdn'; + bytesDownloaded: number; +} + +export class DownloaderError extends Error { + public readonly code: DownloaderErrorCode; + public readonly details?: Record; + public readonly cause?: unknown; + + constructor(code: DownloaderErrorCode, message: string, details?: Record, cause?: unknown) { + super(message); + this.name = 'DownloaderError'; + this.code = code; + this.details = details; + this.cause = cause; + } +} + +// Extract the OS-level error code (EACCES/EBUSY/ENOSPC/ENOTFOUND/etc.) from an +// arbitrary error, falling back to a short constant. We never inline err.message +// into DownloaderError.message because Node's fs errors interpolate the offending +// path β€” e.g. "EACCES: permission denied, open '/Users//Library/...'" β€” +// which would exfiltrate the user's home directory in analytics. +function _errCode(err: unknown, fallback: string): string { + if (err && typeof err === 'object') { + const c = (err as { code?: unknown }).code; + if (typeof c === 'string' && c) {return c;} + } + return fallback; +} + +// Invoke the caller's onProgress callback without letting a user throw crash +// the download stream. Throws inside a stream 'data' handler otherwise surface +// as uncaughtException on the extension host. +function _safeProgress(cb: ((p: DownloadProgress) => void) | undefined, p: DownloadProgress): void { + if (!cb) {return;} + try { + cb(p); + } catch { + // swallow β€” progress reporting is best-effort + } +} + +// ------------- Platform detection ------------- + +export function detectPlatform(): PlatformKey | null { + const platform = process.platform; + let arch = os.arch(); + + if (platform === 'darwin') { + // Rosetta 2: x64 Node on Apple Silicon should use the arm64 binary β€” + // the x64 build needs AVX which Rosetta doesn't emulate. + if (arch === 'x64') { + try { + const r = cp.spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { encoding: 'utf8' }); + if (r.stdout && r.stdout.trim() === '1') { + arch = 'arm64'; + } + } catch { + // sysctl missing β€” treat as non-Rosetta + } + } + if (arch !== 'x64' && arch !== 'arm64') {return null;} + return { key: `darwin-${arch}`, binaryName: 'claude', tarEntry: 'package/claude' }; + } + + if (platform === 'linux') { + if (arch !== 'x64' && arch !== 'arm64') {return null;} + const musl = _detectMusl(); + const key = `linux-${arch}${musl ? '-musl' : ''}`; + return { key, binaryName: 'claude', tarEntry: 'package/claude' }; + } + + if (platform === 'win32') { + if (arch !== 'x64' && arch !== 'arm64') {return null;} + return { key: `win32-${arch}`, binaryName: 'claude.exe', tarEntry: 'package/claude.exe' }; + } + + return null; +} + +function _detectMusl(): boolean { + try { + const report = (process as unknown as { report?: { getReport?: () => { header?: { glibcVersionRuntime?: string } } } }).report; + if (report && typeof report.getReport === 'function') { + const r = report.getReport(); + return !r.header?.glibcVersionRuntime; + } + } catch { + // fall through to file-presence check + } + try { + if (fs.existsSync('/lib/libc.musl-x86_64.so.1') || fs.existsSync('/lib/libc.musl-aarch64.so.1')) { + return true; + } + } catch { + // fall through + } + return false; +} + +// ------------- HTTP helpers ------------- + +function _checkAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DownloaderError('CANCELLED', 'Cancelled'); + } +} + +function _httpGet(urlStr: string, signal?: AbortSignal, redirectsRemaining = 5): Promise { + return new Promise((resolve, reject) => { + _checkAborted(signal); + const parsed = new URL(urlStr); + // Pick http or https by scheme so tests can target a local http server. + const getter = parsed.protocol === 'http:' ? http.get : https.get; + const req = getter(urlStr, { headers: { 'user-agent': 'claude-code-chat-vscode' } }, (res) => { + const status = res.statusCode ?? 0; + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + res.resume(); + if (redirectsRemaining <= 0) { + reject(new DownloaderError('NETWORK', 'Too many redirects', { host: parsed.host })); + return; + } + const next = new URL(res.headers.location, urlStr).toString(); + _httpGet(next, signal, redirectsRemaining - 1).then(resolve, reject); + return; + } + if (status < 200 || status >= 300) { + res.resume(); + reject(new DownloaderError('NETWORK', `HTTP ${status} from ${parsed.host}`, { status, host: parsed.host })); + return; + } + resolve(res); + }); + req.on('error', (err) => { + const code = _errCode(err, 'NETERR'); + reject(new DownloaderError('NETWORK', `Network error (${code}) from ${parsed.host}`, { host: parsed.host, code }, err)); + }); + const onAbort = () => { + req.destroy(); + reject(new DownloaderError('CANCELLED', 'Cancelled')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function _fetchBuffer(urlStr: string, signal?: AbortSignal): Promise { + const res = await _httpGet(urlStr, signal); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + res.destroy(); + reject(new DownloaderError('NETWORK', 'Metadata request timed out')); + }, META_TIMEOUT_MS); + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); }); + res.on('error', (err) => { clearTimeout(timer); reject(new DownloaderError('NETWORK', `Response error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)); }); + }); +} + +async function _fetchText(url: string, signal?: AbortSignal): Promise { + return (await _fetchBuffer(url, signal)).toString('utf8'); +} + +async function _fetchJson(url: string, signal?: AbortSignal): Promise { + const body = await _fetchText(url, signal); + try { + return JSON.parse(body) as T; + } catch (err) { + throw new DownloaderError('NETWORK', 'Invalid JSON in response', undefined, err); + } +} + +// ------------- Tar extraction (minimal, ustar-only) ------------- +// +// Extracts a single file by name from a gunzipped tar stream. Npm-published +// tarballs use plain ustar with short filenames, so we don't handle GNU long- +// link extensions, PAX headers, or sparse files. If the target entry isn't +// found by end of stream, we throw INTEGRITY β€” the tarball shape is wrong. + +function _parseOctal(buf: Buffer): number { + // Octal ASCII, null/space terminated. + let end = 0; + while (end < buf.length && buf[end] !== 0 && buf[end] !== 0x20) {end++;} + const s = buf.subarray(0, end).toString('ascii').trim(); + return s.length ? parseInt(s, 8) : 0; +} + +function _readTarHeader(block: Buffer): { name: string; size: number; isRegularFile: boolean } { + const name = block.subarray(0, 100).toString('utf8').replace(/\0+$/, ''); + const prefix = block.subarray(345, 500).toString('utf8').replace(/\0+$/, ''); + const rawSize = _parseOctal(block.subarray(124, 136)); + // Defensive: guard against NaN / negative / non-finite sizes from malformed + // tarballs before they poison our skip-byte arithmetic downstream. + const size = Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : -1; + const typeFlag = String.fromCharCode(block[156] || 0); + const isRegularFile = typeFlag === '0' || typeFlag === '\0'; + const fullName = prefix ? `${prefix}/${name}` : name; + return { name: fullName, size, isRegularFile }; +} + +interface TarExtractState { + found: boolean; + bytesWritten: number; + buffer: Buffer; + // When >0, we are in the middle of the target file's data, and this many + // bytes still need to be written to out. + remainingFileBytes: number; + // When >0, we are skipping past a non-target file's data+padding. + remainingSkipBytes: number; +} + +function _processTarChunk(state: TarExtractState, chunk: Buffer, entryName: string, out: fs.WriteStream): void { + state.buffer = state.buffer.length ? Buffer.concat([state.buffer, chunk]) : chunk; + + while (true) { + if (state.remainingFileBytes > 0) { + const take = Math.min(state.remainingFileBytes, state.buffer.length); + if (take === 0) {return;} + out.write(state.buffer.subarray(0, take)); + state.bytesWritten += take; + state.remainingFileBytes -= take; + state.buffer = state.buffer.subarray(take); + if (state.remainingFileBytes === 0) { + // After the file data, skip the 512-byte padding tail. + const padLen = (512 - (state.bytesWritten % 512)) % 512; + state.remainingSkipBytes = padLen; + } + continue; + } + + if (state.remainingSkipBytes > 0) { + const skip = Math.min(state.remainingSkipBytes, state.buffer.length); + if (skip === 0) {return;} + state.remainingSkipBytes -= skip; + state.buffer = state.buffer.subarray(skip); + continue; + } + + if (state.buffer.length < 512) {return;} + + const header = state.buffer.subarray(0, 512); + // End-of-archive is two consecutive zero-blocks. A single zero-block + // also terminates our scan safely. + if (header[0] === 0) {return;} + + const { name, size, isRegularFile } = _readTarHeader(header); + state.buffer = state.buffer.subarray(512); + + // Size < 0 means the header was malformed (NaN / negative octal). Bail so + // we don't poison the skip arithmetic β€” the outer INTEGRITY check will fire. + if (size < 0) {throw new DownloaderError('INTEGRITY', 'Malformed tar header (invalid size)');} + + if (name === entryName && isRegularFile) { + state.found = true; + state.remainingFileBytes = size; + state.bytesWritten = 0; + } else { + // Skip this file's data + padding. + const padded = Math.ceil(size / 512) * 512; + state.remainingSkipBytes = padded; + } + } +} + +// ------------- npm source ------------- + +interface NpmPackageMetadata { + 'dist-tags': { latest: string; [tag: string]: string }; + versions: Record; +} + +async function _downloadFromNpm(platform: PlatformKey, opts: DownloadOptions): Promise { + const onProgress = opts.onProgress; + const registry = opts.npmRegistry || DEFAULT_NPM_REGISTRY; + _safeProgress(onProgress, { phase: 'resolving', source: 'npm', message: 'Looking up latest version' }); + + const metaUrl = `${registry}/${NPM_PACKAGE_PREFIX}${platform.key}`; + const meta = await _fetchJson(metaUrl, opts.signal); + const version = meta['dist-tags']?.latest; + if (!version) {throw new DownloaderError('NETWORK', 'npm metadata missing dist-tags.latest');} + const versionMeta = meta.versions?.[version]; + if (!versionMeta?.dist?.tarball || !versionMeta.dist.integrity) { + throw new DownloaderError('NETWORK', 'npm metadata missing tarball or integrity'); + } + const tarballUrl = versionMeta.dist.tarball; + const integrity = versionMeta.dist.integrity; + const dashIdx = integrity.indexOf('-'); + if (dashIdx < 0) {throw new DownloaderError('INTEGRITY', 'Unrecognized integrity format');} + const algo = integrity.slice(0, dashIdx); + const expectedB64 = integrity.slice(dashIdx + 1); + if (!['sha256', 'sha384', 'sha512'].includes(algo)) { + throw new DownloaderError('INTEGRITY', `Unsupported hash algorithm: ${algo}`, { algo }); + } + + const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`); + const writeStream = fs.createWriteStream(tempPath); + const hash = crypto.createHash(algo); + const gunzip = zlib.createGunzip(); + + const state: TarExtractState = { + found: false, + bytesWritten: 0, + buffer: Buffer.alloc(0), + remainingFileBytes: 0, + remainingSkipBytes: 0, + }; + + _safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: 0 }); + + let res: http.IncomingMessage; + try { + res = await _httpGet(tarballUrl, opts.signal); + } catch (err) { + writeStream.destroy(); + await _safeUnlink(tempPath); + throw err; + } + + const total = Number(res.headers['content-length']) || undefined; + let bytesDownloaded = 0; + let lastProgressAt = 0; + + const extractPromise = new Promise((resolve, reject) => { + gunzip.on('data', (chunk: Buffer) => { + try { + _processTarChunk(state, chunk, platform.tarEntry, writeStream); + } catch (err) { + reject(err); + } + }); + gunzip.on('end', () => resolve()); + gunzip.on('error', (err) => reject(new DownloaderError('INTEGRITY', 'Tarball decompression failed', undefined, err))); + }); + + res.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + hash.update(chunk); + const now = Date.now(); + if (now - lastProgressAt > PROGRESS_THROTTLE_MS) { + lastProgressAt = now; + _safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: bytesDownloaded, total }); + } + }); + + const responseDone = new Promise((resolve, reject) => { + res.on('end', () => resolve()); + res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err))); + }); + + const writeDone = new Promise((resolve, reject) => { + writeStream.on('close', () => resolve()); + writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err))); + }); + + const onAbort = () => { + res.destroy(); + gunzip.destroy(); + writeStream.destroy(); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + + res.pipe(gunzip); + + try { + await Promise.all([responseDone, extractPromise]); + writeStream.end(); + await writeDone; + } catch (err) { + // Tear down both ends explicitly β€” leaving res piping after an extract + // failure would leak bandwidth and memory. + res.destroy(); + gunzip.destroy(); + writeStream.destroy(); + await _safeUnlink(tempPath); + if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');} + throw err; + } + + _safeProgress(onProgress, { phase: 'verifying', source: 'npm', loaded: bytesDownloaded, total }); + + if (!state.found) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', `Tarball missing expected entry ${platform.tarEntry}`, { platformKey: platform.key }); + } + + const computed = hash.digest('base64'); + if (computed !== expectedB64) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', 'npm tarball hash mismatch', { algo }); + } + + _safeProgress(onProgress, { phase: 'installing', source: 'npm' }); + const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName)); + return { binaryPath: finalPath, version, source: 'npm', bytesDownloaded }; +} + +// ------------- CDN source ------------- + +interface CdnManifest { + platforms: Record; +} + +async function _downloadFromCdn(platform: PlatformKey, opts: DownloadOptions): Promise { + const onProgress = opts.onProgress; + const base = opts.cdnBase || DEFAULT_CDN_BASE; + _safeProgress(onProgress, { phase: 'resolving', source: 'cdn', message: 'Looking up latest version' }); + + const versionRaw = (await _fetchText(`${base}/latest`, opts.signal)).trim(); + if (!/^\d+\.\d+\.\d+(-[\w.-]+)?$/.test(versionRaw)) { + throw new DownloaderError('NETWORK', 'CDN returned invalid version string'); + } + const version = versionRaw; + + const manifest = await _fetchJson(`${base}/${version}/manifest.json`, opts.signal); + const expectedHex = manifest.platforms?.[platform.key]?.checksum; + if (!expectedHex || !/^[a-f0-9]{64}$/i.test(expectedHex)) { + throw new DownloaderError('INTEGRITY', `CDN manifest missing checksum for ${platform.key}`, { platformKey: platform.key }); + } + + const binName = process.platform === 'win32' ? 'claude.exe' : 'claude'; + const binUrl = `${base}/${version}/${platform.key}/${binName}`; + const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`); + const writeStream = fs.createWriteStream(tempPath); + const hash = crypto.createHash('sha256'); + + _safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: 0 }); + + let res: http.IncomingMessage; + try { + res = await _httpGet(binUrl, opts.signal); + } catch (err) { + writeStream.destroy(); + await _safeUnlink(tempPath); + throw err; + } + + const total = Number(res.headers['content-length']) || undefined; + let bytesDownloaded = 0; + let lastProgressAt = 0; + + const onAbort = () => { + res.destroy(); + writeStream.destroy(); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + + const responseDone = new Promise((resolve, reject) => { + res.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + hash.update(chunk); + const now = Date.now(); + if (now - lastProgressAt > PROGRESS_THROTTLE_MS) { + lastProgressAt = now; + _safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: bytesDownloaded, total }); + } + }); + res.on('end', () => resolve()); + res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err))); + }); + + // Wait for 'close' (fd released), not just 'finish' (data flushed). Matters on + // Windows β€” rename() fails with EBUSY if the underlying handle is still open. + const writeDone = new Promise((resolve, reject) => { + writeStream.on('close', () => resolve()); + writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err))); + }); + + res.pipe(writeStream); + + try { + await responseDone; + writeStream.end(); + await writeDone; + } catch (err) { + res.destroy(); + writeStream.destroy(); + await _safeUnlink(tempPath); + if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');} + throw err; + } + + _safeProgress(onProgress, { phase: 'verifying', source: 'cdn', loaded: bytesDownloaded, total }); + + const computedHex = hash.digest('hex'); + if (computedHex.toLowerCase() !== expectedHex.toLowerCase()) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', 'CDN binary hash mismatch'); + } + + _safeProgress(onProgress, { phase: 'installing', source: 'cdn' }); + const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName)); + return { binaryPath: finalPath, version, source: 'cdn', bytesDownloaded }; +} + +// ------------- Finalize (chmod + atomic rename) ------------- + +async function _finalize(tempPath: string, finalPath: string): Promise { + if (process.platform !== 'win32') { + try { + fs.chmodSync(tempPath, 0o755); + } catch (err) { + await _safeUnlink(tempPath); + const code = _errCode(err, 'CHMODERR'); + throw new DownloaderError('WRITE', `chmod failed (${code})`, { code }, err); + } + } + + try { + fs.renameSync(tempPath, finalPath); + } catch (err) { + const code = _errCode(err, 'RENAMEERR'); + // EXDEV means temp and final are on different filesystems (shouldn't happen, + // but defensive). + if (code === 'EXDEV') { + try { + fs.copyFileSync(tempPath, finalPath); + await _safeUnlink(tempPath); + return finalPath; + } catch (copyErr) { + await _safeUnlink(tempPath); + const ccode = _errCode(copyErr, 'COPYERR'); + throw new DownloaderError('WRITE', `Copy to final path failed (${ccode})`, { code: ccode }, copyErr); + } + } + await _safeUnlink(tempPath); + // EBUSY/EPERM on Windows means the target is currently running β€” we can't replace it. + if (code === 'EBUSY' || code === 'EPERM') { + throw new DownloaderError('WRITE', 'Target binary is in use. Close any running claude sessions and try again.', { code }, err); + } + throw new DownloaderError('WRITE', `Rename failed (${code})`, { code }, err); + } + return finalPath; +} + +async function _safeUnlink(p: string): Promise { + try { + await fs.promises.unlink(p); + } catch { + // ignore β€” cleanup best-effort + } +} + +// ------------- Public orchestrator ------------- + +export async function downloadClaude(opts: DownloadOptions): Promise { + const platform = detectPlatform(); + if (!platform) { + throw new DownloaderError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${process.platform}/${os.arch()}`, { + platform: process.platform, + arch: os.arch(), + }); + } + + try { + fs.mkdirSync(opts.destDir, { recursive: true }); + } catch (err) { + const code = _errCode(err, 'MKDIRERR'); + // Never include the path β€” destDir is under the user's home directory and + // would leak the username if posted to analytics. + throw new DownloaderError('WRITE', `Could not create download directory (${code})`, { code }, err); + } + + let npmErr: unknown; + try { + return await _downloadFromNpm(platform, opts); + } catch (err) { + if (err instanceof DownloaderError && err.code === 'CANCELLED') {throw err;} + npmErr = err; + _safeProgress(opts.onProgress, { phase: 'fallback', source: 'cdn', message: 'npm source failed β€” retrying via CDN' }); + } + + try { + return await _downloadFromCdn(platform, opts); + } catch (cdnErr) { + if (cdnErr instanceof DownloaderError && cdnErr.code === 'CANCELLED') {throw cdnErr;} + const npmCode = npmErr instanceof DownloaderError ? npmErr.code : 'NETWORK'; + const cdnCode = cdnErr instanceof DownloaderError ? cdnErr.code : 'NETWORK'; + throw new DownloaderError( + 'AGGREGATE', + `Both sources failed (npm: ${npmCode}, cdn: ${cdnCode}).`, + { npmCode, cdnCode }, + [npmErr, cdnErr], + ); + } +} + +// ------------- Internal exports for tests ------------- +// These are NOT part of the public API β€” consumers should use downloadClaude +// and detectPlatform. They're exported here so the test suite can unit-test the +// tar parser, octal parsing, and error-code helpers without network I/O. + +/** @internal */ +export const __test__ = { + parseOctal: _parseOctal, + readTarHeader: _readTarHeader, + processTarChunk: _processTarChunk, + errCode: _errCode, + safeProgress: _safeProgress, +}; + +/** @internal */ +export type __TarExtractState__ = TarExtractState; diff --git a/src/extension.ts b/src/extension.ts index efd0f18..62700bd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,12 @@ import * as vscode from 'vscode'; import * as cp from 'child_process'; import * as util from 'util'; import * as path from 'path'; -import * as fs from 'fs'; +import * as os from 'os'; import getHtml from './ui'; import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router'; import { fetchAndResolveModels } from './model-updater'; import recommendedModels from './recommended-models.json'; +import { downloadClaude, detectPlatform, DownloaderError } from './claudeDownloader'; // OpenCredits environment configuration let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai'; @@ -1052,11 +1053,15 @@ class ClaudeChatProvider { // Not using WSL this._isWslProcess = false; - // Use native claude command (or custom executable if configured) + // Use native claude command (or custom executable if configured). + // shell:true is only needed on Windows when we don't have an absolute path β€” + // cmd.exe's resolver finds .cmd/.bat shims on PATH. With an absolute .exe + // path we skip shell wrapping to avoid cmd.exe mis-quoting paths with spaces + // (e.g. the default globalStorage location "...Application Support..."). const executable = customExecutablePath || 'claude'; claudeProcess = cp.spawn(executable, args, { signal: this._abortController.signal, - shell: process.platform === 'win32', + shell: process.platform === 'win32' && !customExecutablePath, detached: process.platform !== 'win32', cwd: cwd, stdio: ['pipe', 'pipe', 'pipe'], @@ -3622,82 +3627,80 @@ class ClaudeChatProvider { terminal.show(); } - private _runInstallCommand(method: string = 'installer'): void { - let command: string; - if (method === 'npm') { - command = 'npm install -g @anthropic-ai/claude-code'; - } else if (process.platform === 'win32') { - command = 'powershell.exe -Command "irm https://claude.ai/install.ps1 | iex"'; - } else { - command = 'curl -fsSL https://claude.ai/install.sh | bash'; - } - - // Track that user has attempted install at least once + private async _runInstallCommand(method: string = 'installer'): Promise { this._context.globalState.update('installAttempted', true); - cp.exec(command, { timeout: 600000 }, async (error) => { - if (error) { - this._postMessage({ - type: 'installComplete', - success: false, - error: 'Installation failed. Please run in terminal: ' + command, - method: method - }); - return; - } + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const wslEnabled = config.get('wsl.enabled', false); - const available = await this._checkClaudeAvailable(); - if (available) { - this._postMessage({ type: 'installComplete', success: true, method }); - return; - } + // WSL install needs to run inside the distro, not on the Windows host. + // The old shell-based flow didn't handle this either β€” not regressing, + // not fixing here. User should install claude inside their distro manually + // and set claudeCodeChat.wsl.claudePath. + if (wslEnabled) { + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: 'WSL mode: please install Claude inside your WSL distro, then set claudeCodeChat.wsl.claudePath.', + errorCode: 'UNSUPPORTED_PLATFORM' + }); + return; + } - const installLocation = this._getKnownInstallLocation(); - if (!fs.existsSync(installLocation)) { - this._postMessage({ - type: 'installComplete', - success: true, - method, - notOnPath: true, - installLocation - }); - return; - } + if (!detectPlatform()) { + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: `Unsupported platform: ${process.platform}/${os.arch()}. Install Claude manually from https://code.claude.com.`, + errorCode: 'UNSUPPORTED_PLATFORM' + }); + return; + } - const config = vscode.workspace.getConfiguration('claudeCodeChat'); - const existing = config.get('executable.path', ''); + const destDir = path.join(this._context.globalStorageUri.fsPath, 'bin'); + + try { + const result = await downloadClaude({ + destDir, + onProgress: (p) => this._postMessage({ type: 'installProgress', ...p }) + }); + + const existing = (config.get('executable.path', '') || '').trim(); if (!existing) { try { - await config.update('executable.path', installLocation, vscode.ConfigurationTarget.Global); + await config.update('executable.path', result.binaryPath, vscode.ConfigurationTarget.Global); } catch { - // fall through; UI will guide user + // fall through β€” UI will still reflect success and the spawn will find it on next launch } } + this._postMessage({ type: 'installComplete', success: true, method, - configuredPath: installLocation, - existingPathRespected: !!existing + configuredPath: existing ? undefined : result.binaryPath, + existingPathRespected: !!existing, + source: result.source, + version: result.version + }); + } catch (err) { + const d = err instanceof DownloaderError ? err : null; + const details = d?.details; + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: d?.message || 'Installation failed. Please try again.', + errorCode: d?.code, + // AGGREGATE errors carry the per-source failure codes; surface them so + // analytics can bucket "both npm+cdn failed with NETWORK" vs + // "npm INTEGRITY, cdn NETWORK" etc. + npmCode: typeof details?.npmCode === 'string' ? details.npmCode : undefined, + cdnCode: typeof details?.cdnCode === 'string' ? details.cdnCode : undefined }); - }); - } - - private _checkClaudeAvailable(): Promise { - const config = vscode.workspace.getConfiguration('claudeCodeChat'); - if (config.get('wsl.enabled', false)) { - return Promise.resolve(true); } - const probe = process.platform === 'win32' ? 'where claude' : 'command -v claude'; - return new Promise((resolve) => { - cp.exec(probe, { env: process.env }, (err) => resolve(!err)); - }); - } - - private _getKnownInstallLocation(): string { - const homeDir = process.env.HOME || process.env.USERPROFILE || ''; - const binary = process.platform === 'win32' ? 'claude.exe' : 'claude'; - return path.join(homeDir, '.local', 'bin', binary); } private _buildClaudeTerminalOptions(args: string[] = []): { shellPath: string; shellArgs: string[] } { diff --git a/src/script.ts b/src/script.ts index 8e4d628..900b8d7 100644 --- a/src/script.ts +++ b/src/script.ts @@ -2895,6 +2895,33 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt }); } + function handleInstallProgress(p) { + const progressEl = document.getElementById('installProgress'); + if (!progressEl || progressEl.style.display === 'none') return; + const textEl = progressEl.querySelector('.install-progress-text'); + if (!textEl) return; + + const loaded = typeof p.loaded === 'number' && isFinite(p.loaded) ? p.loaded : 0; + const total = typeof p.total === 'number' && isFinite(p.total) && p.total > 0 ? p.total : null; + + if (p.phase === 'resolving') { + textEl.textContent = 'Looking up Claude Code...'; + } else if (p.phase === 'downloading') { + if (total) { + const pct = Math.min(100, Math.max(0, Math.floor((loaded / total) * 100))); + textEl.textContent = 'Downloading Claude Code (' + pct + '%)'; + } else { + textEl.textContent = 'Downloading Claude Code (' + (loaded / 1048576).toFixed(1) + ' MB)'; + } + } else if (p.phase === 'verifying') { + textEl.textContent = 'Verifying download...'; + } else if (p.phase === 'installing') { + textEl.textContent = 'Installing...'; + } else if (p.phase === 'fallback') { + textEl.textContent = 'Retrying via alternate source...'; + } + } + function handleInstallComplete(success, error, extra) { document.getElementById('installProgress').style.display = 'none'; @@ -2906,30 +2933,40 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt if (ocOption) ocOption.style.display = opencreditsEnabled ? '' : 'none'; if (success) { + const baseProps = { source: extra && extra.source, version: extra && extra.version }; if (extra && extra.configuredPath) { - sendStats('Install auto configured path', { existingPathRespected: !!extra.existingPathRespected }); + sendStats('Install auto configured path', Object.assign({ existingPathRespected: !!extra.existingPathRespected }, baseProps)); successEl.querySelector('.install-success-text').textContent = 'Installed'; - const hint = extra.existingPathRespected - ? 'Claude was installed but not on your PATH. Your existing executable.path setting was left unchanged.' - : 'Configured automatically. Send a message to get started.'; - successEl.querySelector('.install-success-hint').textContent = hint; - } else if (extra && extra.notOnPath) { - sendStats('Install location not found'); + successEl.querySelector('.install-success-hint').textContent = 'Configured automatically. Send a message to get started.'; + } else if (extra && extra.existingPathRespected) { + sendStats('Install success', baseProps); successEl.querySelector('.install-success-text').textContent = 'Installed'; successEl.querySelector('.install-success-hint').textContent = - 'Claude was installed but could not be located. Set claudeCodeChat.executable.path manually to your claude binary.'; + 'Your existing executable.path setting was left unchanged. Send a message to get started.'; } else { - sendStats('Install success'); + sendStats('Install success', baseProps); successEl.querySelector('.install-success-text').textContent = 'Installed'; successEl.querySelector('.install-success-hint').textContent = 'Send a message to get started'; } } else { - sendStats('Install failed', { error: (error || 'Unknown error').substring(0, 200) }); - // Show error state + const errorCode = extra && extra.errorCode; + sendStats('Install failed', { + errorCode: errorCode, + npmCode: extra && extra.npmCode, + cdnCode: extra && extra.cdnCode, + error: (error || 'Unknown error').substring(0, 200) + }); successEl.querySelector('.install-success-icon').style.display = 'none'; - successEl.querySelector('.install-success-text').textContent = 'Installation failed'; - successEl.querySelector('.install-success-hint').textContent = error || 'Try installing manually from claude.ai/download'; successEl.querySelector('.install-options').style.display = 'none'; + if (errorCode === 'UNSUPPORTED_PLATFORM') { + successEl.querySelector('.install-success-text').textContent = 'Unsupported platform'; + successEl.querySelector('.install-success-hint').textContent = + error || 'Your platform is not supported. Install Claude manually from https://code.claude.com.'; + } else { + successEl.querySelector('.install-success-text').textContent = 'Installation failed'; + successEl.querySelector('.install-success-hint').textContent = + error || 'Try installing manually from claude.ai/download'; + } } } @@ -3748,15 +3785,28 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt case 'installComplete': handleInstallComplete(message.success, message.error, { configuredPath: message.configuredPath, - notOnPath: message.notOnPath, - installLocation: message.installLocation, - existingPathRespected: message.existingPathRespected + existingPathRespected: message.existingPathRespected, + source: message.source, + version: message.version, + errorCode: message.errorCode, + npmCode: message.npmCode, + cdnCode: message.cdnCode }); if (message.success) { updateStatus('Ready', 'success'); } break; + case 'installProgress': + handleInstallProgress({ + phase: message.phase, + source: message.source, + loaded: message.loaded, + total: message.total, + message: message.message + }); + break; + case 'showRestoreOption': showRestoreContainer(message.data); break; diff --git a/src/test/downloader.integration.test.ts b/src/test/downloader.integration.test.ts new file mode 100644 index 0000000..1f508d5 --- /dev/null +++ b/src/test/downloader.integration.test.ts @@ -0,0 +1,203 @@ +// End-to-end integration tests for the claude downloader. +// +// These hit the REAL npm registry and REAL Anthropic CDN and download the REAL +// native binary to a temp directory. They are slow (~60MB–213MB of transfer) +// and network-dependent. If the suite is ever run in a CI environment without +// egress, these will fail with NETWORK β€” mark them .skip() if you need to. +// +// We never EXECUTE the downloaded binary. We just verify: +// - the downloader returns a sensible result +// - the file exists at the expected path with mode 755 (on Unix) +// - the file starts with a platform-appropriate executable magic number +// - the integrity hash matched (implicit β€” the downloader would throw if not) + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectPlatform, downloadClaude, DownloaderError } from '../claudeDownloader'; + +const INTEGRATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes β€” 213MB on slow networks + +function mkTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-')); +} + +function rmRf(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* best effort */ } +} + +// Check the first few bytes of the binary match the expected executable format. +// We never parse further than the magic β€” just enough to confirm we wrote out an +// actual executable and not e.g. an HTML error page or a README. +function assertExecutableMagic(binaryPath: string): void { + const fd = fs.openSync(binaryPath, 'r'); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + + if (process.platform === 'darwin') { + // Mach-O magic numbers: MH_MAGIC_64 (0xFEEDFACF) or MH_CIGAM_64 (0xCFFAEDFE) + // or fat universal binary (0xCAFEBABE / 0xBEBAFECA). + const m = buf.readUInt32BE(0); + const lm = buf.readUInt32LE(0); + assert.ok( + m === 0xFEEDFACF || m === 0xCAFEBABE || lm === 0xFEEDFACF || lm === 0xCAFEBABE, + `Not a Mach-O binary β€” got magic 0x${m.toString(16)} (LE 0x${lm.toString(16)})`, + ); + } else if (process.platform === 'linux') { + // ELF: 0x7F 'E' 'L' 'F' + assert.strictEqual(buf[0], 0x7F); + assert.strictEqual(buf[1], 0x45); + assert.strictEqual(buf[2], 0x4C); + assert.strictEqual(buf[3], 0x46); + } else if (process.platform === 'win32') { + // PE: starts with MZ (DOS stub). + assert.strictEqual(buf[0], 0x4D); + assert.strictEqual(buf[1], 0x5A); + } +} + +suite('claudeDownloader: integration (real network)', function () { + // Skip entirely on unsupported platforms β€” integration only makes sense where + // a binary is published for us. + const platform = detectPlatform(); + if (!platform) { + test.skip('no supported binary for this platform', () => { /* skipped */ }); + return; + } + + this.timeout(INTEGRATION_TIMEOUT_MS); + + let tempDirs: string[] = []; + + teardown(() => { + for (const d of tempDirs) {rmRf(d);} + tempDirs = []; + }); + + test('downloads the real binary from npm and verifies integrity', async () => { + const dest = mkTempDir('claude-dl-npm'); + tempDirs.push(dest); + + const progressPhases: string[] = []; + const result = await downloadClaude({ + destDir: dest, + onProgress: (p) => { + if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);} + }, + }); + + // Happy path should go npm, no fallback. + assert.strictEqual(result.source, 'npm'); + assert.ok(/^\d+\.\d+\.\d+/.test(result.version), 'version looks unfamiliar: ' + result.version); + assert.ok(result.bytesDownloaded > 1_000_000, 'tarball was suspiciously small: ' + result.bytesDownloaded); + + // File is at the expected path with correct permissions. + const expectedPath = path.join(dest, platform.binaryName); + assert.strictEqual(result.binaryPath, expectedPath); + assert.ok(fs.existsSync(result.binaryPath)); + const stat = fs.statSync(result.binaryPath); + if (process.platform !== 'win32') { + + assert.strictEqual(stat.mode & 0o777, 0o755, 'expected chmod 755'); + } + assert.ok(stat.size > 50_000_000, 'extracted binary is suspiciously small: ' + stat.size); + + assertExecutableMagic(result.binaryPath); + + // Progress pipeline actually fired phase transitions. + assert.ok(progressPhases.includes('resolving'), 'missing resolving phase'); + assert.ok(progressPhases.includes('downloading'), 'missing downloading phase'); + assert.ok(progressPhases.includes('verifying'), 'missing verifying phase'); + assert.ok(progressPhases.includes('installing'), 'missing installing phase'); + assert.ok(!progressPhases.includes('fallback'), 'fallback phase fired unexpectedly'); + }); + + test('falls back to CDN when npm is unreachable', async () => { + const dest = mkTempDir('claude-dl-fallback'); + tempDirs.push(dest); + + const progressPhases: string[] = []; + const result = await downloadClaude({ + destDir: dest, + // Point npm at a loopback port that actively refuses connections so + // the npm path fails fast (ECONNREFUSED). CDN override is left at + // default so it hits the real Anthropic CDN. + npmRegistry: 'http://127.0.0.1:1', + onProgress: (p) => { + if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);} + }, + }); + + assert.strictEqual(result.source, 'cdn'); + assert.ok(/^\d+\.\d+\.\d+/.test(result.version)); + assert.ok(result.bytesDownloaded > 50_000_000, 'CDN serves uncompressed β‰₯50MB: ' + result.bytesDownloaded); + assert.ok(fs.existsSync(result.binaryPath)); + assertExecutableMagic(result.binaryPath); + assert.ok(progressPhases.includes('fallback'), 'expected fallback phase after npm failure'); + }); + + test('AGGREGATE error when both sources are unreachable', async () => { + const dest = mkTempDir('claude-dl-aggregate'); + tempDirs.push(dest); + + let caught: unknown; + try { + await downloadClaude({ + destDir: dest, + npmRegistry: 'http://127.0.0.1:1', + cdnBase: 'http://127.0.0.1:1', + }); + assert.fail('expected both-sources-fail to throw'); + } catch (err) { + caught = err; + } + + assert.ok(caught instanceof DownloaderError, 'expected DownloaderError'); + const e = caught as DownloaderError; + assert.strictEqual(e.code, 'AGGREGATE'); + assert.ok(e.details, 'AGGREGATE should carry details'); + assert.ok(typeof e.details!.npmCode === 'string', 'npmCode should be populated'); + assert.ok(typeof e.details!.cdnCode === 'string', 'cdnCode should be populated'); + // Should not leak any path from the local temp dir. + assert.ok(!e.message.includes(os.homedir()), 'error message leaks home dir'); + assert.ok(!e.message.includes(dest), 'error message leaks temp dir'); + + // Temp file should be cleaned up β€” nothing left in dest except the dir itself. + const entries = fs.readdirSync(dest); + assert.deepStrictEqual(entries, [], 'temp download files were not cleaned up'); + }); + + test('INTEGRITY error when CDN manifest is tampered (simulated via bogus CDN base)', async () => { + const dest = mkTempDir('claude-dl-bad-cdn'); + tempDirs.push(dest); + + // npm still works so we actually need to disable it to force CDN. + // Point CDN at a non-existent but reachable-looking host β€” expect + // NETWORK (DNS failure) bubbled through AGGREGATE, not INTEGRITY. + // This is really just confirming error classification is coherent when + // the CDN hostname resolves but returns nonsense β€” skip the exact + // INTEGRITY path since we'd need to stand up a mock server. This test + // doubles as a sanity check on the AGGREGATE error formatting. + let caught: unknown; + try { + await downloadClaude({ + destDir: dest, + npmRegistry: 'http://127.0.0.1:1', + cdnBase: 'http://127.0.0.1:1', + }); + assert.fail('expected failure'); + } catch (err) { + caught = err; + } + const e = caught as DownloaderError; + assert.strictEqual(e.code, 'AGGREGATE'); + assert.ok( + e.message.includes('npm:') && e.message.includes('cdn:'), + 'AGGREGATE message should name both sources', + ); + }); +}); diff --git a/src/test/downloader.test.ts b/src/test/downloader.test.ts new file mode 100644 index 0000000..0da8385 --- /dev/null +++ b/src/test/downloader.test.ts @@ -0,0 +1,312 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectPlatform, DownloaderError, __test__ } from '../claudeDownloader'; + +const { parseOctal, readTarHeader, processTarChunk, errCode, safeProgress } = __test__; + +// ---- Tar fixture builder ----------------------------------------------- +// Build a ustar header block + aligned data. We only populate the fields our +// parser reads: name (0..100), size (124..136), typeflag (156), prefix (345..500). + +function makeHeader(name: string, size: number, opts: { typeFlag?: string; prefix?: string } = {}): Buffer { + const block = Buffer.alloc(512, 0); + block.write(name, 0, 100, 'utf8'); + // Size: octal ASCII, null-terminated, 11 digits + NUL. + const oct = size.toString(8).padStart(11, '0'); + block.write(oct, 124, 11, 'ascii'); + block[135] = 0; + block[156] = (opts.typeFlag || '0').charCodeAt(0); + if (opts.prefix) {block.write(opts.prefix, 345, 155, 'utf8');} + return block; +} + +function makeMalformedSizeHeader(name: string): Buffer { + // Non-octal junk in the size field. + const block = Buffer.alloc(512, 0); + block.write(name, 0, 100, 'utf8'); + block.write('ZZZ', 124, 3, 'ascii'); + block[156] = '0'.charCodeAt(0); + return block; +} + +function paddedData(size: number, fill = 0x41 /* 'A' */): Buffer { + const padded = Math.ceil(size / 512) * 512; + const buf = Buffer.alloc(padded, 0); + for (let i = 0; i < size; i++) {buf[i] = fill;} + return buf; +} + +function buildTarball(entries: Array<{ name: string; data: Buffer; typeFlag?: string; prefix?: string }>): Buffer { + const parts: Buffer[] = []; + for (const e of entries) { + parts.push(makeHeader(e.name, e.data.length, { typeFlag: e.typeFlag, prefix: e.prefix })); + const padded = Math.ceil(e.data.length / 512) * 512; + const padBlock = Buffer.alloc(padded, 0); + e.data.copy(padBlock, 0); + parts.push(padBlock); + } + // End-of-archive: two zero-blocks. + parts.push(Buffer.alloc(1024, 0)); + return Buffer.concat(parts); +} + +function newWriteStream(): { stream: fs.WriteStream; path: string; read(): Buffer } { + const tmp = path.join(os.tmpdir(), 'downloader-test-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2)); + const stream = fs.createWriteStream(tmp); + return { + stream, + path: tmp, + read: () => fs.readFileSync(tmp), + }; +} + +async function flushWriteStream(s: fs.WriteStream): Promise { + return new Promise((resolve, reject) => { + s.end(() => resolve()); + s.on('error', reject); + }); +} + +suite('claudeDownloader: detectPlatform', () => { + test('returns a supported platform shape on the current host', () => { + const p = detectPlatform(); + if (!p) { + // Test suite only runs on supported hosts β€” skip if we land somewhere weird. + return; + } + assert.strictEqual(typeof p.key, 'string'); + assert.ok(p.key.length > 0); + assert.ok( + /^(darwin|linux|win32)-(x64|arm64)(-musl)?$/.test(p.key), + 'unexpected platform key: ' + p.key, + ); + assert.ok(p.binaryName === 'claude' || p.binaryName === 'claude.exe'); + assert.ok(p.tarEntry === 'package/claude' || p.tarEntry === 'package/claude.exe'); + // Windows β†’ .exe, others β†’ no extension + if (process.platform === 'win32') { + assert.strictEqual(p.binaryName, 'claude.exe'); + assert.strictEqual(p.tarEntry, 'package/claude.exe'); + } else { + assert.strictEqual(p.binaryName, 'claude'); + assert.strictEqual(p.tarEntry, 'package/claude'); + } + }); +}); + +suite('claudeDownloader: DownloaderError', () => { + test('exposes code, message, details, cause', () => { + const cause = new Error('underlying'); + const e = new DownloaderError('NETWORK', 'something failed', { status: 503, host: 'example.com' }, cause); + assert.strictEqual(e.code, 'NETWORK'); + assert.strictEqual(e.message, 'something failed'); + assert.deepStrictEqual(e.details, { status: 503, host: 'example.com' }); + assert.strictEqual(e.cause, cause); + assert.strictEqual(e.name, 'DownloaderError'); + assert.ok(e instanceof Error); + }); + + test('details and cause are optional', () => { + const e = new DownloaderError('CANCELLED', 'stop'); + assert.strictEqual(e.details, undefined); + assert.strictEqual(e.cause, undefined); + }); +}); + +suite('claudeDownloader: parseOctal', () => { + test('parses standard octal size', () => { + const buf = Buffer.alloc(12, 0); + buf.write('00000001024', 0, 'ascii'); // 1024 in octal + assert.strictEqual(parseOctal(buf), 0o1024); + }); + + test('parses octal with trailing NUL terminator', () => { + const buf = Buffer.alloc(12, 0); + buf.write('0000100', 0, 'ascii'); + assert.strictEqual(parseOctal(buf), 0o100); + }); + + test('parses octal with trailing space terminator', () => { + const buf = Buffer.from('0000100 \0\0\0\0\0'); + assert.strictEqual(parseOctal(buf), 0o100); + }); + + test('returns 0 for empty buffer', () => { + assert.strictEqual(parseOctal(Buffer.alloc(12, 0)), 0); + }); + + test('returns NaN for non-octal garbage', () => { + const buf = Buffer.from('ZZZ\0\0\0\0\0\0\0\0\0'); + const result = parseOctal(buf); + assert.ok(Number.isNaN(result), 'expected NaN for non-octal input, got ' + result); + }); +}); + +suite('claudeDownloader: readTarHeader', () => { + test('reads a well-formed header', () => { + const hdr = makeHeader('package/claude', 1024); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.name, 'package/claude'); + assert.strictEqual(parsed.size, 1024); + assert.strictEqual(parsed.isRegularFile, true); + }); + + test('combines prefix + name for long paths', () => { + const hdr = makeHeader('claude', 512, { prefix: 'package' }); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.name, 'package/claude'); + }); + + test('flags non-regular entries (directory)', () => { + const hdr = makeHeader('package/', 0, { typeFlag: '5' }); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.isRegularFile, false); + }); + + test('returns size=-1 when size field is garbage (NaN guard)', () => { + const hdr = makeMalformedSizeHeader('package/claude'); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.size, -1, 'malformed size must be clamped to -1'); + }); +}); + +suite('claudeDownloader: processTarChunk', () => { + test('extracts a single matching entry', async () => { + const data = paddedData(2000, 0x42 /* 'B' */); + const binary = data.subarray(0, 2000); + const tar = buildTarball([{ name: 'package/claude', data: binary }]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + const out = ws.read(); + assert.strictEqual(state.found, true); + assert.strictEqual(out.length, 2000); + assert.deepStrictEqual(out, binary); + fs.unlinkSync(ws.path); + }); + + test('skips non-matching entries and still extracts target', async () => { + const decoy = Buffer.from('ignore me'); + const binary = paddedData(1500, 0x43 /* 'C' */).subarray(0, 1500); + const tar = buildTarball([ + { name: 'package/README.md', data: decoy }, + { name: 'package/claude', data: binary }, + { name: 'package/LICENSE', data: Buffer.from('also ignore') }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); + + test('handles headers split across multiple chunks', async () => { + const binary = paddedData(3000, 0x44 /* 'D' */).subarray(0, 3000); + const tar = buildTarball([ + { name: 'package/README.md', data: Buffer.from('meh') }, + { name: 'package/claude', data: binary }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + // Drip-feed 137-byte chunks β€” guaranteed to bisect every header + data block. + const chunkSize = 137; + for (let i = 0; i < tar.length; i += chunkSize) { + processTarChunk(state, tar.subarray(i, Math.min(i + chunkSize, tar.length)), 'package/claude', ws.stream); + } + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); + + test('sets found=false when target entry is absent', async () => { + const tar = buildTarball([ + { name: 'package/README.md', data: Buffer.from('nope') }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, false); + assert.strictEqual(ws.read().length, 0); + fs.unlinkSync(ws.path); + }); + + test('throws INTEGRITY on malformed size in header', () => { + const bad = makeMalformedSizeHeader('package/evil'); + const endBlocks = Buffer.alloc(1024, 0); + const tar = Buffer.concat([bad, endBlocks]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + assert.throws( + () => processTarChunk(state, tar, 'package/claude', ws.stream), + (err) => err instanceof DownloaderError && err.code === 'INTEGRITY', + ); + ws.stream.destroy(); + try { fs.unlinkSync(ws.path); } catch { /* best effort */ } + }); + + test('stops cleanly at end-of-archive zero block', async () => { + const binary = paddedData(800, 0x45 /* 'E' */).subarray(0, 800); + const tar = buildTarball([{ name: 'package/claude', data: binary }]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); +}); + +suite('claudeDownloader: errCode helper', () => { + test('extracts .code when present', () => { + const err = Object.assign(new Error('x'), { code: 'EACCES' }); + assert.strictEqual(errCode(err, 'FALLBACK'), 'EACCES'); + }); + + test('returns fallback when code is absent', () => { + assert.strictEqual(errCode(new Error('x'), 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode(null, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode(undefined, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode('just a string', 'FALLBACK'), 'FALLBACK'); + }); + + test('returns fallback when code is a non-string', () => { + assert.strictEqual(errCode({ code: 123 }, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode({ code: '' }, 'FALLBACK'), 'FALLBACK'); + }); +}); + +suite('claudeDownloader: safeProgress helper', () => { + test('invokes the callback with the progress payload', () => { + const calls: unknown[] = []; + safeProgress((p) => calls.push(p), { phase: 'resolving' }); + assert.deepStrictEqual(calls, [{ phase: 'resolving' }]); + }); + + test('swallows callback throws without propagating', () => { + assert.doesNotThrow(() => { + safeProgress(() => { throw new Error('boom'); }, { phase: 'downloading' }); + }); + }); + + test('handles undefined callback', () => { + assert.doesNotThrow(() => safeProgress(undefined, { phase: 'verifying' })); + }); +});