mirror of
https://github.com/andrepimenta/claude-code-chat.git
synced 2026-06-01 09:35:27 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10fa40c406 | ||
|
|
e73ab3bf0a | ||
|
|
6858f6a6c6 |
@@ -16,3 +16,4 @@ node_modules
|
||||
mcp-permissions.js
|
||||
backup-files
|
||||
build
|
||||
**/test/**
|
||||
14
CHANGELOG.md
14
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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="2.0.6"
|
||||
VERSION="2.0.8"
|
||||
OUTPUT_NAME="vsix-claude-code-chat-${VERSION}.vsix"
|
||||
|
||||
echo "Building Open VSIX version ${VERSION}..."
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-chat",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-code-chat",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.8",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.10",
|
||||
|
||||
@@ -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.8",
|
||||
"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",
|
||||
|
||||
662
src/claudeDownloader.ts
Normal file
662
src/claudeDownloader.ts
Normal file
@@ -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<string, string | number>;
|
||||
public readonly cause?: unknown;
|
||||
|
||||
constructor(code: DownloaderErrorCode, message: string, details?: Record<string, string | number>, 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/<name>/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<http.IncomingMessage> {
|
||||
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<Buffer> {
|
||||
const res = await _httpGet(urlStr, signal);
|
||||
return new Promise<Buffer>((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<string> {
|
||||
return (await _fetchBuffer(url, signal)).toString('utf8');
|
||||
}
|
||||
|
||||
async function _fetchJson<T = unknown>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
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<string, { dist: { tarball: string; integrity: string } }>;
|
||||
}
|
||||
|
||||
async function _downloadFromNpm(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
|
||||
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<NpmPackageMetadata>(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<void>((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<void>((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<void>((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<string, { checksum: string }>;
|
||||
}
|
||||
|
||||
async function _downloadFromCdn(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
|
||||
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<CdnManifest>(`${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<void>((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<void>((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<string> {
|
||||
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<void> {
|
||||
try {
|
||||
await fs.promises.unlink(p);
|
||||
} catch {
|
||||
// ignore — cleanup best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Public orchestrator -------------
|
||||
|
||||
export async function downloadClaude(opts: DownloadOptions): Promise<DownloadResult> {
|
||||
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;
|
||||
134
src/extension.ts
134
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';
|
||||
@@ -997,7 +998,8 @@ class ClaudeChatProvider {
|
||||
...process.env,
|
||||
FORCE_COLOR: '0',
|
||||
NO_COLOR: '1',
|
||||
...customEnvVars // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.)
|
||||
...customEnvVars, // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.)
|
||||
CLAUDE_CODE_ENTRYPOINT: 'claude-vscode'
|
||||
};
|
||||
|
||||
// OpenCredits: clear Anthropic-specific vars so Claude CLI uses env vars directly
|
||||
@@ -1030,6 +1032,7 @@ class ClaudeChatProvider {
|
||||
wslEnvOverrides['DISABLE_TELEMETRY'] = 'true';
|
||||
wslEnvOverrides['DISABLE_COST_WARNINGS'] = 'true';
|
||||
}
|
||||
wslEnvOverrides['CLAUDE_CODE_ENTRYPOINT'] = 'claude-vscode';
|
||||
const envExports = Object.entries(wslEnvOverrides)
|
||||
.map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`)
|
||||
.join(' && ');
|
||||
@@ -1052,11 +1055,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'],
|
||||
@@ -1571,6 +1578,19 @@ class ClaudeChatProvider {
|
||||
this._totalCost += jsonData.total_cost_usd;
|
||||
}
|
||||
|
||||
// Lifetime success counter — survives reloads, scoped to the
|
||||
// extension globalState. Used for milestone analytics (1, 50, 100, 200, …).
|
||||
try {
|
||||
const prev = this._context.globalState.get<number>('lifetimeMessageSuccessCount', 0) || 0;
|
||||
const next = prev + 1;
|
||||
this._context.globalState.update('lifetimeMessageSuccessCount', next);
|
||||
if (next === 1 || next === 50 || (next > 50 && next % 100 === 0)) {
|
||||
this._postMessage({ type: 'messageMilestone', count: next });
|
||||
}
|
||||
} catch {
|
||||
// best-effort — analytics shouldn't break the response path
|
||||
}
|
||||
|
||||
|
||||
// Send updated totals to webview
|
||||
this._postMessage({
|
||||
@@ -3336,7 +3356,7 @@ class ClaudeChatProvider {
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(): string {
|
||||
return getHtml(vscode.env?.isTelemetryEnabled, OPENCREDITS_API_URL, OPENCREDITS_WEB_URL, OPENCREDITS_PUBLISHABLE_KEY, vscode.env?.appName);
|
||||
return getHtml(vscode.env?.isTelemetryEnabled, OPENCREDITS_API_URL, OPENCREDITS_WEB_URL, OPENCREDITS_PUBLISHABLE_KEY, vscode.env?.appName, this._context?.extension?.packageJSON?.version);
|
||||
}
|
||||
|
||||
private _sendCurrentSettings(): void {
|
||||
@@ -3622,82 +3642,90 @@ 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<void> {
|
||||
this._context.globalState.update('installAttempted', true);
|
||||
|
||||
cp.exec(command, { timeout: 600000 }, async (error) => {
|
||||
if (error) {
|
||||
const config = vscode.workspace.getConfiguration('claudeCodeChat');
|
||||
const wslEnabled = config.get<boolean>('wsl.enabled', false);
|
||||
const platform = process.platform;
|
||||
const arch = os.arch();
|
||||
|
||||
// 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,
|
||||
error: 'Installation failed. Please run in terminal: ' + command,
|
||||
method: method
|
||||
method,
|
||||
error: 'WSL mode: please install Claude inside your WSL distro, then set claudeCodeChat.wsl.claudePath.',
|
||||
errorCode: 'WSL_NOT_SUPPORTED',
|
||||
platform,
|
||||
arch
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const available = await this._checkClaudeAvailable();
|
||||
if (available) {
|
||||
this._postMessage({ type: 'installComplete', success: true, method });
|
||||
return;
|
||||
}
|
||||
|
||||
const installLocation = this._getKnownInstallLocation();
|
||||
if (!fs.existsSync(installLocation)) {
|
||||
if (!detectPlatform()) {
|
||||
this._postMessage({
|
||||
type: 'installComplete',
|
||||
success: true,
|
||||
success: false,
|
||||
method,
|
||||
notOnPath: true,
|
||||
installLocation
|
||||
error: `Unsupported platform: ${platform}/${arch}. Install Claude manually from https://code.claude.com.`,
|
||||
errorCode: 'UNSUPPORTED_PLATFORM',
|
||||
platform,
|
||||
arch
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('claudeCodeChat');
|
||||
const existing = config.get<string>('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<string>('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,
|
||||
platform,
|
||||
arch
|
||||
});
|
||||
} 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,
|
||||
platform,
|
||||
arch
|
||||
});
|
||||
}
|
||||
|
||||
private _checkClaudeAvailable(): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration('claudeCodeChat');
|
||||
if (config.get<boolean>('wsl.enabled', false)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const probe = process.platform === 'win32' ? 'where claude' : 'command -v claude';
|
||||
return new Promise<boolean>((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[] } {
|
||||
|
||||
102
src/script.ts
102
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,50 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
|
||||
if (ocOption) ocOption.style.display = opencreditsEnabled ? '' : 'none';
|
||||
|
||||
if (success) {
|
||||
const existingPathRespected = !!(extra && extra.existingPathRespected);
|
||||
const autoConfigured = !!(extra && extra.configuredPath);
|
||||
sendStats('Install success', {
|
||||
source: extra && extra.source,
|
||||
version: extra && extra.version,
|
||||
platform: extra && extra.platform,
|
||||
arch: extra && extra.arch,
|
||||
existingPathRespected: existingPathRespected,
|
||||
autoConfigured: autoConfigured
|
||||
});
|
||||
successEl.querySelector('.install-success-text').textContent = 'Installed';
|
||||
if (extra && extra.configuredPath) {
|
||||
sendStats('Install auto configured path', { existingPathRespected: !!extra.existingPathRespected });
|
||||
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-text').textContent = 'Installed';
|
||||
successEl.querySelector('.install-success-hint').textContent = 'Configured automatically. Send a message to get started.';
|
||||
} else if (existingPathRespected) {
|
||||
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');
|
||||
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,
|
||||
platform: extra && extra.platform,
|
||||
arch: extra && extra.arch,
|
||||
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 if (errorCode === 'WSL_NOT_SUPPORTED') {
|
||||
successEl.querySelector('.install-success-text').textContent = 'WSL mode';
|
||||
successEl.querySelector('.install-success-hint').textContent =
|
||||
error || 'Install Claude inside your WSL distro and set claudeCodeChat.wsl.claudePath.';
|
||||
} 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 +3795,32 @@ 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 'messageMilestone':
|
||||
sendStats('Message milestone', { count: message.count });
|
||||
break;
|
||||
|
||||
case 'showRestoreOption':
|
||||
showRestoreContainer(message.data);
|
||||
break;
|
||||
|
||||
203
src/test/downloader.integration.test.ts
Normal file
203
src/test/downloader.integration.test.ts
Normal file
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
312
src/test/downloader.test.ts
Normal file
312
src/test/downloader.test.ts
Normal file
@@ -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<void> {
|
||||
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' }));
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import getSkillsHtml from './skills-ui'
|
||||
import getPluginsHtml from './plugins-ui'
|
||||
|
||||
|
||||
const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown') => `<!DOCTYPE html>
|
||||
const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown', extensionVersion: string = 'unknown') => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -1085,7 +1085,7 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
|
||||
2. Do I need to display a cookie notice to users?
|
||||
No, Umami does not use any cookies in the tracking code.
|
||||
-->
|
||||
${isTelemetryEnabled ? '<script defer src="https://product.opencredits.ai/script.js" data-website-id="0159e9b1-4a98-4b49-943a-32db3e743b95" data-tag="' + editorName + '"></script>' : '<!-- Analytics disabled due to VS Code telemetry settings -->'}
|
||||
${isTelemetryEnabled ? '<script defer src="https://product.opencredits.ai/script.js" data-website-id="0159e9b1-4a98-4b49-943a-32db3e743b95" data-tag="' + editorName + '@' + extensionVersion + '"></script>' : '<!-- Analytics disabled due to VS Code telemetry settings -->'}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user