chore: initialize recovered claude workspace
This commit is contained in:
523
src/utils/nativeInstaller/download.ts
Normal file
523
src/utils/nativeInstaller/download.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Download functionality for native installer
|
||||
*
|
||||
* Handles downloading Claude binaries from various sources:
|
||||
* - Artifactory NPM packages
|
||||
* - GCS bucket
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import axios from 'axios'
|
||||
import { createHash } from 'crypto'
|
||||
import { chmod, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import type { ReleaseChannel } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { toError } from '../errors.js'
|
||||
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
import { logError } from '../log.js'
|
||||
import { sleep } from '../sleep.js'
|
||||
import { jsonStringify, writeFileSync_DEPRECATED } from '../slowOperations.js'
|
||||
import { getBinaryName, getPlatform } from './installer.js'
|
||||
|
||||
const GCS_BUCKET_URL =
|
||||
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'
|
||||
export const ARTIFACTORY_REGISTRY_URL =
|
||||
'https://artifactory.infra.ant.dev/artifactory/api/npm/npm-all/'
|
||||
|
||||
export async function getLatestVersionFromArtifactory(
|
||||
tag: string = 'latest',
|
||||
): Promise<string> {
|
||||
const startTime = Date.now()
|
||||
const { stdout, code, stderr } = await execFileNoThrowWithCwd(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${MACRO.NATIVE_PACKAGE_URL}@${tag}`,
|
||||
'version',
|
||||
'--prefer-online',
|
||||
'--registry',
|
||||
ARTIFACTORY_REGISTRY_URL,
|
||||
],
|
||||
{
|
||||
timeout: 30000,
|
||||
preserveOutputOnError: true,
|
||||
},
|
||||
)
|
||||
|
||||
const latencyMs = Date.now() - startTime
|
||||
|
||||
if (code !== 0) {
|
||||
logEvent('tengu_version_check_failure', {
|
||||
latency_ms: latencyMs,
|
||||
source_npm: true,
|
||||
exit_code: code,
|
||||
})
|
||||
const error = new Error(`npm view failed with code ${code}: ${stderr}`)
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
logEvent('tengu_version_check_success', {
|
||||
latency_ms: latencyMs,
|
||||
source_npm: true,
|
||||
})
|
||||
logForDebugging(
|
||||
`npm view ${MACRO.NATIVE_PACKAGE_URL}@${tag} version: ${stdout}`,
|
||||
)
|
||||
const latestVersion = stdout.trim()
|
||||
return latestVersion
|
||||
}
|
||||
|
||||
export async function getLatestVersionFromBinaryRepo(
|
||||
channel: ReleaseChannel = 'latest',
|
||||
baseUrl: string,
|
||||
authConfig?: { auth: { username: string; password: string } },
|
||||
): Promise<string> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const response = await axios.get(`${baseUrl}/${channel}`, {
|
||||
timeout: 30000,
|
||||
responseType: 'text',
|
||||
...authConfig,
|
||||
})
|
||||
const latencyMs = Date.now() - startTime
|
||||
logEvent('tengu_version_check_success', {
|
||||
latency_ms: latencyMs,
|
||||
})
|
||||
return response.data.trim()
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
let httpStatus: number | undefined
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
httpStatus = error.response.status
|
||||
}
|
||||
|
||||
logEvent('tengu_version_check_failure', {
|
||||
latency_ms: latencyMs,
|
||||
http_status: httpStatus,
|
||||
is_timeout: errorMessage.includes('timeout'),
|
||||
})
|
||||
const fetchError = new Error(
|
||||
`Failed to fetch version from ${baseUrl}/${channel}: ${errorMessage}`,
|
||||
)
|
||||
logError(fetchError)
|
||||
throw fetchError
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(
|
||||
channelOrVersion: string,
|
||||
): Promise<string> {
|
||||
// Direct version - match internal format too (e.g. 1.0.30-dev.shaf4937ce)
|
||||
if (/^v?\d+\.\d+\.\d+(-\S+)?$/.test(channelOrVersion)) {
|
||||
const normalized = channelOrVersion.startsWith('v')
|
||||
? channelOrVersion.slice(1)
|
||||
: channelOrVersion
|
||||
// 99.99.x is reserved for CI smoke-test fixtures on real GCS.
|
||||
// feature() is false in all shipped builds — DCE collapses this to an
|
||||
// unconditional throw. Only `bun --feature=ALLOW_TEST_VERSIONS` (the
|
||||
// smoke test's source-level invocation) bypasses.
|
||||
if (/^99\.99\./.test(normalized) && !feature('ALLOW_TEST_VERSIONS')) {
|
||||
throw new Error(
|
||||
`Version ${normalized} is not available for installation. Use 'stable' or 'latest'.`,
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ReleaseChannel validation
|
||||
const channel = channelOrVersion as ReleaseChannel
|
||||
if (channel !== 'stable' && channel !== 'latest') {
|
||||
throw new Error(
|
||||
`Invalid channel: ${channelOrVersion}. Use 'stable' or 'latest'`,
|
||||
)
|
||||
}
|
||||
|
||||
// Route to appropriate source
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Use Artifactory for ant users
|
||||
const npmTag = channel === 'stable' ? 'stable' : 'latest'
|
||||
return getLatestVersionFromArtifactory(npmTag)
|
||||
}
|
||||
|
||||
// Use GCS for external users
|
||||
return getLatestVersionFromBinaryRepo(channel, GCS_BUCKET_URL)
|
||||
}
|
||||
|
||||
export async function downloadVersionFromArtifactory(
|
||||
version: string,
|
||||
stagingPath: string,
|
||||
) {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
// If we get here, we own the lock and can delete a partial download
|
||||
await fs.rm(stagingPath, { recursive: true, force: true })
|
||||
|
||||
// Get the platform-specific package name
|
||||
const platform = getPlatform()
|
||||
const platformPackageName = `${MACRO.NATIVE_PACKAGE_URL}-${platform}`
|
||||
|
||||
// Fetch integrity hash for the platform-specific package
|
||||
logForDebugging(
|
||||
`Fetching integrity hash for ${platformPackageName}@${version}`,
|
||||
)
|
||||
const {
|
||||
stdout: integrityOutput,
|
||||
code,
|
||||
stderr,
|
||||
} = await execFileNoThrowWithCwd(
|
||||
'npm',
|
||||
[
|
||||
'view',
|
||||
`${platformPackageName}@${version}`,
|
||||
'dist.integrity',
|
||||
'--registry',
|
||||
ARTIFACTORY_REGISTRY_URL,
|
||||
],
|
||||
{
|
||||
timeout: 30000,
|
||||
preserveOutputOnError: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (code !== 0) {
|
||||
throw new Error(`npm view integrity failed with code ${code}: ${stderr}`)
|
||||
}
|
||||
|
||||
const integrity = integrityOutput.trim()
|
||||
if (!integrity) {
|
||||
throw new Error(
|
||||
`Failed to fetch integrity hash for ${platformPackageName}@${version}`,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(`Got integrity hash for ${platform}: ${integrity}`)
|
||||
|
||||
// Create isolated npm project in staging
|
||||
await fs.mkdir(stagingPath)
|
||||
|
||||
const packageJson = {
|
||||
name: 'claude-native-installer',
|
||||
version: '0.0.1',
|
||||
dependencies: {
|
||||
[MACRO.NATIVE_PACKAGE_URL!]: version,
|
||||
},
|
||||
}
|
||||
|
||||
// Create package-lock.json with integrity verification for platform-specific package
|
||||
const packageLock = {
|
||||
name: 'claude-native-installer',
|
||||
version: '0.0.1',
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: {
|
||||
'': {
|
||||
name: 'claude-native-installer',
|
||||
version: '0.0.1',
|
||||
dependencies: {
|
||||
[MACRO.NATIVE_PACKAGE_URL!]: version,
|
||||
},
|
||||
},
|
||||
[`node_modules/${MACRO.NATIVE_PACKAGE_URL}`]: {
|
||||
version: version,
|
||||
optionalDependencies: {
|
||||
[platformPackageName]: version,
|
||||
},
|
||||
},
|
||||
[`node_modules/${platformPackageName}`]: {
|
||||
version: version,
|
||||
integrity: integrity,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync_DEPRECATED(
|
||||
join(stagingPath, 'package.json'),
|
||||
jsonStringify(packageJson, null, 2),
|
||||
{ encoding: 'utf8', flush: true },
|
||||
)
|
||||
|
||||
writeFileSync_DEPRECATED(
|
||||
join(stagingPath, 'package-lock.json'),
|
||||
jsonStringify(packageLock, null, 2),
|
||||
{ encoding: 'utf8', flush: true },
|
||||
)
|
||||
|
||||
// Install with npm - it will verify integrity from package-lock.json
|
||||
// Use --prefer-online to force fresh metadata checks, helping with Artifactory replication delays
|
||||
const result = await execFileNoThrowWithCwd(
|
||||
'npm',
|
||||
['ci', '--prefer-online', '--registry', ARTIFACTORY_REGISTRY_URL],
|
||||
{
|
||||
timeout: 60000,
|
||||
preserveOutputOnError: true,
|
||||
cwd: stagingPath,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`npm ci failed with code ${result.code}: ${result.stderr}`)
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Successfully downloaded and verified ${MACRO.NATIVE_PACKAGE_URL}@${version}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Stall timeout: abort if no bytes received for this duration
|
||||
const DEFAULT_STALL_TIMEOUT_MS = 60000 // 60 seconds
|
||||
const MAX_DOWNLOAD_RETRIES = 3
|
||||
|
||||
function getStallTimeoutMs(): number {
|
||||
return (
|
||||
Number(process.env.CLAUDE_CODE_STALL_TIMEOUT_MS_FOR_TESTING) ||
|
||||
DEFAULT_STALL_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
class StallTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('Download stalled: no data received for 60 seconds')
|
||||
this.name = 'StallTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common logic for downloading and verifying a binary.
|
||||
* Includes stall detection (aborts if no bytes for 60s) and retry logic.
|
||||
*/
|
||||
async function downloadAndVerifyBinary(
|
||||
binaryUrl: string,
|
||||
expectedChecksum: string,
|
||||
binaryPath: string,
|
||||
requestConfig: Record<string, unknown> = {},
|
||||
) {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_DOWNLOAD_RETRIES; attempt++) {
|
||||
const controller = new AbortController()
|
||||
let stallTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer()
|
||||
stallTimer = setTimeout(c => c.abort(), getStallTimeoutMs(), controller)
|
||||
}
|
||||
|
||||
try {
|
||||
// Start the stall timer before the request
|
||||
resetStallTimer()
|
||||
|
||||
const response = await axios.get(binaryUrl, {
|
||||
timeout: 5 * 60000, // 5 minute total timeout
|
||||
responseType: 'arraybuffer',
|
||||
signal: controller.signal,
|
||||
onDownloadProgress: () => {
|
||||
// Reset stall timer on each chunk of data received
|
||||
resetStallTimer()
|
||||
},
|
||||
...requestConfig,
|
||||
})
|
||||
|
||||
clearStallTimer()
|
||||
|
||||
// Verify checksum
|
||||
const hash = createHash('sha256')
|
||||
hash.update(response.data)
|
||||
const actualChecksum = hash.digest('hex')
|
||||
|
||||
if (actualChecksum !== expectedChecksum) {
|
||||
throw new Error(
|
||||
`Checksum mismatch: expected ${expectedChecksum}, got ${actualChecksum}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Write binary to disk
|
||||
await writeFile(binaryPath, Buffer.from(response.data))
|
||||
await chmod(binaryPath, 0o755)
|
||||
|
||||
// Success - return early
|
||||
return
|
||||
} catch (error) {
|
||||
clearStallTimer()
|
||||
|
||||
// Check if this was a stall timeout (axios wraps abort signals in CanceledError)
|
||||
const isStallTimeout = axios.isCancel(error)
|
||||
|
||||
if (isStallTimeout) {
|
||||
lastError = new StallTimeoutError()
|
||||
} else {
|
||||
lastError = toError(error)
|
||||
}
|
||||
|
||||
// Only retry on stall timeouts
|
||||
if (isStallTimeout && attempt < MAX_DOWNLOAD_RETRIES) {
|
||||
logForDebugging(
|
||||
`Download stalled on attempt ${attempt}/${MAX_DOWNLOAD_RETRIES}, retrying...`,
|
||||
)
|
||||
// Brief pause before retry to let network recover
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't retry other errors (HTTP errors, checksum mismatches, etc.)
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but just in case
|
||||
throw lastError ?? new Error('Download failed after all retries')
|
||||
}
|
||||
|
||||
export async function downloadVersionFromBinaryRepo(
|
||||
version: string,
|
||||
stagingPath: string,
|
||||
baseUrl: string,
|
||||
authConfig?: {
|
||||
auth?: { username: string; password: string }
|
||||
headers?: Record<string, string>
|
||||
},
|
||||
) {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
// If we get here, we own the lock and can delete a partial download
|
||||
await fs.rm(stagingPath, { recursive: true, force: true })
|
||||
|
||||
// Get platform
|
||||
const platform = getPlatform()
|
||||
const startTime = Date.now()
|
||||
|
||||
// Log download attempt start
|
||||
logEvent('tengu_binary_download_attempt', {})
|
||||
|
||||
// Fetch manifest to get checksum
|
||||
let manifest
|
||||
try {
|
||||
const manifestResponse = await axios.get(
|
||||
`${baseUrl}/${version}/manifest.json`,
|
||||
{
|
||||
timeout: 10000,
|
||||
responseType: 'json',
|
||||
...authConfig,
|
||||
},
|
||||
)
|
||||
manifest = manifestResponse.data
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
let httpStatus: number | undefined
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
httpStatus = error.response.status
|
||||
}
|
||||
|
||||
logEvent('tengu_binary_manifest_fetch_failure', {
|
||||
latency_ms: latencyMs,
|
||||
http_status: httpStatus,
|
||||
is_timeout: errorMessage.includes('timeout'),
|
||||
})
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to fetch manifest from ${baseUrl}/${version}/manifest.json: ${errorMessage}`,
|
||||
),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const platformInfo = manifest.platforms[platform]
|
||||
|
||||
if (!platformInfo) {
|
||||
logEvent('tengu_binary_platform_not_found', {})
|
||||
throw new Error(
|
||||
`Platform ${platform} not found in manifest for version ${version}`,
|
||||
)
|
||||
}
|
||||
|
||||
const expectedChecksum = platformInfo.checksum
|
||||
|
||||
// Both GCS and generic bucket use identical layout: ${baseUrl}/${version}/${platform}/${binaryName}
|
||||
const binaryName = getBinaryName(platform)
|
||||
const binaryUrl = `${baseUrl}/${version}/${platform}/${binaryName}`
|
||||
|
||||
// Write to staging
|
||||
await fs.mkdir(stagingPath)
|
||||
const binaryPath = join(stagingPath, binaryName)
|
||||
|
||||
try {
|
||||
await downloadAndVerifyBinary(
|
||||
binaryUrl,
|
||||
expectedChecksum,
|
||||
binaryPath,
|
||||
authConfig || {},
|
||||
)
|
||||
const latencyMs = Date.now() - startTime
|
||||
logEvent('tengu_binary_download_success', {
|
||||
latency_ms: latencyMs,
|
||||
})
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
let httpStatus: number | undefined
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
httpStatus = error.response.status
|
||||
}
|
||||
|
||||
logEvent('tengu_binary_download_failure', {
|
||||
latency_ms: latencyMs,
|
||||
http_status: httpStatus,
|
||||
is_timeout: errorMessage.includes('timeout'),
|
||||
is_checksum_mismatch: errorMessage.includes('Checksum mismatch'),
|
||||
})
|
||||
logError(
|
||||
new Error(`Failed to download binary from ${binaryUrl}: ${errorMessage}`),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadVersion(
|
||||
version: string,
|
||||
stagingPath: string,
|
||||
): Promise<'npm' | 'binary'> {
|
||||
// Test-fixture versions route to the private sentinel bucket. DCE'd in all
|
||||
// shipped builds — the string 'claude-code-ci-sentinel' and the gcloud call
|
||||
// never exist in compiled binaries. Same gcloud-token pattern as
|
||||
// remoteSkillLoader.ts:175-195.
|
||||
if (feature('ALLOW_TEST_VERSIONS') && /^99\.99\./.test(version)) {
|
||||
const { stdout } = await execFileNoThrowWithCwd('gcloud', [
|
||||
'auth',
|
||||
'print-access-token',
|
||||
])
|
||||
await downloadVersionFromBinaryRepo(
|
||||
version,
|
||||
stagingPath,
|
||||
'https://storage.googleapis.com/claude-code-ci-sentinel',
|
||||
{ headers: { Authorization: `Bearer ${stdout.trim()}` } },
|
||||
)
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Use Artifactory for ant users
|
||||
await downloadVersionFromArtifactory(version, stagingPath)
|
||||
return 'npm'
|
||||
}
|
||||
|
||||
// Use GCS for external users
|
||||
await downloadVersionFromBinaryRepo(version, stagingPath, GCS_BUCKET_URL)
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export { StallTimeoutError, MAX_DOWNLOAD_RETRIES }
|
||||
export const STALL_TIMEOUT_MS = DEFAULT_STALL_TIMEOUT_MS
|
||||
export const _downloadAndVerifyBinaryForTesting = downloadAndVerifyBinary
|
||||
Reference in New Issue
Block a user