Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/utils/autoUpdater.publicSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { getLatestVersionFromGcs } from './autoUpdater.js'

const originalAxiosGet = axios.get
const originalMacro = (globalThis as { MACRO?: unknown }).MACRO
const originalNativePackageUrl = process.env.NCODE_NATIVE_PACKAGE_URL

function setMacro(nativePackageUrl?: string): void {
;(globalThis as { MACRO?: { NATIVE_PACKAGE_URL?: string } }).MACRO = {
NATIVE_PACKAGE_URL: nativePackageUrl,
}
}

beforeEach(() => {
axios.get = originalAxiosGet
setMacro(undefined)
delete process.env.NCODE_NATIVE_PACKAGE_URL
})

afterEach(() => {
axios.get = originalAxiosGet
;(globalThis as { MACRO?: unknown }).MACRO = originalMacro
if (originalNativePackageUrl === undefined) {
delete process.env.NCODE_NATIVE_PACKAGE_URL
} else {
process.env.NCODE_NATIVE_PACKAGE_URL = originalNativePackageUrl
}
})

describe('package-manager updater public source', () => {
it('uses GitHub releases when no binary repo override is configured', async () => {
const calls: Array<unknown[]> = []
axios.get = (async (...args: unknown[]) => {
calls.push(args)
return {
data: [
{ draft: false, prerelease: true, tag_name: 'v2.0.0-beta.1' },
{ draft: false, prerelease: false, tag_name: 'v1.9.0' },
],
}
}) as typeof axios.get

await expect(getLatestVersionFromGcs('latest')).resolves.toBe('2.0.0-beta.1')
await expect(getLatestVersionFromGcs('stable')).resolves.toBe('1.9.0')

expect(calls[0]?.[0]).toBe(
'https://api.github.com/repos/Noumena-Network/code/releases',
)
})

it('uses NCODE_NATIVE_PACKAGE_URL as the explicit binary-repo override', async () => {
process.env.NCODE_NATIVE_PACKAGE_URL = 'https://storage.example.test/ncode'
const calls: Array<unknown[]> = []
axios.get = (async (...args: unknown[]) => {
calls.push(args)
return { data: '1.8.0\n' }
}) as typeof axios.get

await expect(getLatestVersionFromGcs('latest')).resolves.toBe('1.8.0')

expect(calls).toHaveLength(1)
expect(calls[0]?.[0]).toBe('https://storage.example.test/ncode/latest')
})
})
49 changes: 43 additions & 6 deletions src/utils/autoUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,17 @@ import {
import { jsonParse } from './slowOperations.js'
import { isInternalBuild } from 'src/capabilities/static.js'

const GCS_BUCKET_URL =
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'
const GITHUB_RELEASES_API_URL =
process.env.NCODE_GITHUB_RELEASES_API_URL ??
'https://api.github.com/repos/Noumena-Network/code/releases'

function getPublicBinaryUrl(): string | undefined {
return MACRO.NATIVE_PACKAGE_URL || process.env.NCODE_NATIVE_PACKAGE_URL
}

function githubReleaseVersionFromTag(tag: string): string {
return tag.startsWith('v') ? tag.slice(1) : tag
}

class AutoUpdaterError extends ClaudeError {}

Expand Down Expand Up @@ -385,14 +394,42 @@ export async function getNpmDistTags(): Promise<NpmDistTags> {
export async function getLatestVersionFromGcs(
channel: ReleaseChannel,
): Promise<string | null> {
const publicBinaryUrl = getPublicBinaryUrl()
if (publicBinaryUrl) {
try {
const response = await axios.get(`${publicBinaryUrl}/${channel}`, {
timeout: 5000,
responseType: 'text',
})
return response.data.trim()
} catch (error) {
logForDebugging(`Failed to fetch ${channel} from binary repo: ${error}`)
return null
}
}

try {
const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, {
const response = await axios.get(GITHUB_RELEASES_API_URL, {
timeout: 5000,
responseType: 'text',
responseType: 'json',
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
params: { per_page: 25 },
})
const release = (response.data as Array<{
draft?: boolean
prerelease?: boolean
tag_name?: string
}>).find(candidate => {
if (candidate.draft) return false
if (channel === 'stable' && candidate.prerelease) return false
return Boolean(candidate.tag_name)
})
return response.data.trim()
return release?.tag_name ? githubReleaseVersionFromTag(release.tag_name) : null
} catch (error) {
logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`)
logForDebugging(`Failed to fetch ${channel} from GitHub releases: ${error}`)
return null
}
}
Expand Down
176 changes: 176 additions & 0 deletions src/utils/nativeInstaller/download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createHash } from 'crypto'
import { mkdtemp, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { zipSync } from 'fflate'
import {
downloadVersionFromGithubRelease,
getLatestVersion,
getLatestVersionFromGithubReleases,
} from './download.js'
import { getBinaryName, getPlatform } from './installer.js'

const originalAxiosGet = axios.get
const originalMacro = (globalThis as { MACRO?: unknown }).MACRO
const originalNativePackageUrl = process.env.NCODE_NATIVE_PACKAGE_URL

function setMacro(nativePackageUrl?: string): void {
;(globalThis as { MACRO?: { NATIVE_PACKAGE_URL?: string } }).MACRO = {
NATIVE_PACKAGE_URL: nativePackageUrl,
}
}

beforeEach(() => {
axios.get = originalAxiosGet
setMacro(undefined)
delete process.env.NCODE_NATIVE_PACKAGE_URL
})

afterEach(() => {
axios.get = originalAxiosGet
;(globalThis as { MACRO?: unknown }).MACRO = originalMacro
if (originalNativePackageUrl === undefined) {
delete process.env.NCODE_NATIVE_PACKAGE_URL
} else {
process.env.NCODE_NATIVE_PACKAGE_URL = originalNativePackageUrl
}
})

describe('native installer public update sources', () => {
it('uses GitHub releases as the default latest/stable version source', async () => {
const getCalls: Array<unknown[]> = []
axios.get = (async (...args: unknown[]) => {
getCalls.push(args)
return {
data: [
{ draft: true, prerelease: false, tag_name: 'v9.9.9' },
{ draft: false, prerelease: true, tag_name: 'v1.3.0-beta.1' },
{ draft: false, prerelease: false, tag_name: 'v1.2.3' },
],
}
}) as typeof axios.get

await expect(getLatestVersionFromGithubReleases('latest')).resolves.toBe(
'1.3.0-beta.1',
)
await expect(getLatestVersionFromGithubReleases('stable')).resolves.toBe(
'1.2.3',
)

expect(getCalls).toHaveLength(2)
expect(getCalls[0]?.[0]).toBe(
'https://api.github.com/repos/Noumena-Network/code/releases',
)
expect(getCalls[0]?.[1]).toMatchObject({
responseType: 'json',
params: { per_page: 25 },
})
})

it('uses NCODE_NATIVE_PACKAGE_URL as an explicit binary-repo override', async () => {
process.env.NCODE_NATIVE_PACKAGE_URL = 'https://storage.example.test/ncode'
const getCalls: Array<unknown[]> = []
axios.get = (async (...args: unknown[]) => {
getCalls.push(args)
return { data: '1.2.4\n' }
}) as typeof axios.get

await expect(getLatestVersion('latest')).resolves.toBe('1.2.4')

expect(getCalls).toHaveLength(1)
expect(getCalls[0]?.[0]).toBe('https://storage.example.test/ncode/latest')
})

it('downloads, verifies, and extracts a GitHub release zip asset', async () => {
const version = '1.2.3'
const platform = getPlatform()
const binaryName = getBinaryName(platform)
const artifactBaseName = `ncode-${version}-${platform}`
const zipAssetName = `${artifactBaseName}.zip`
const binaryBytes = new TextEncoder().encode('binary-ok')
const zipBytes = Buffer.from(
zipSync({ [`${artifactBaseName}/${binaryName}`]: binaryBytes }),
)
const checksum = createHash('sha256').update(zipBytes).digest('hex')
const getCalls: Array<string> = []
axios.get = (async (url: string) => {
getCalls.push(url)
if (url.endsWith('/tags/v1.2.3')) {
return {
data: {
tag_name: 'v1.2.3',
assets: [
{
name: zipAssetName,
browser_download_url: 'https://github.example.test/asset.zip',
},
{
name: `${zipAssetName}.sha256`,
browser_download_url: 'https://github.example.test/asset.zip.sha256',
},
],
},
}
}
if (url === 'https://github.example.test/asset.zip.sha256') {
return { data: `${checksum} ${zipAssetName}\n` }
}
if (url === 'https://github.example.test/asset.zip') {
return { data: zipBytes }
}
throw new Error(`unexpected url ${url}`)
}) as typeof axios.get

const stagingPath = await mkdtemp(join(tmpdir(), 'ncode-gh-release-'))
await rm(stagingPath, { recursive: true, force: true })
try {
await downloadVersionFromGithubRelease(version, stagingPath)
await expect(readFile(join(stagingPath, binaryName), 'utf8')).resolves.toBe(
'binary-ok',
)
} finally {
await rm(stagingPath, { recursive: true, force: true })
}

expect(getCalls).toEqual([
'https://api.github.com/repos/Noumena-Network/code/releases/tags/v1.2.3',
'https://github.example.test/asset.zip.sha256',
'https://github.example.test/asset.zip',
])
})

it('rejects GitHub release zip checksum mismatches', async () => {
const version = '1.2.3'
const platform = getPlatform()
const zipAssetName = `ncode-${version}-${platform}.zip`
const zipBytes = Buffer.from(zipSync({ 'ncode': new TextEncoder().encode('bad') }))
axios.get = (async (url: string) => {
if (url.endsWith('/tags/v1.2.3')) {
return {
data: {
tag_name: 'v1.2.3',
assets: [
{ name: zipAssetName, browser_download_url: 'https://github.example.test/asset.zip' },
{ name: `${zipAssetName}.sha256`, browser_download_url: 'https://github.example.test/asset.zip.sha256' },
],
},
}
}
if (url.endsWith('.sha256')) return { data: `deadbeef ${zipAssetName}\n` }
if (url.endsWith('.zip')) return { data: zipBytes }
throw new Error(`unexpected url ${url}`)
}) as typeof axios.get

const stagingPath = await mkdtemp(join(tmpdir(), 'ncode-gh-release-bad-'))
await rm(stagingPath, { recursive: true, force: true })
try {
await expect(
downloadVersionFromGithubRelease(version, stagingPath),
).rejects.toThrow('Checksum mismatch')
} finally {
await rm(stagingPath, { recursive: true, force: true })
}
})
})
Loading
Loading