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
6 changes: 2 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# .github/workflows/release.yml
name: Release

on:
Expand All @@ -21,9 +20,8 @@ jobs:
node-version: '22'
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- run: npm install -g npm@latest
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm build
- run: pnpm publish --provenance --no-git-checks --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish --provenance --access public
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"description": "Claude Code 中国大陆下载与配置工具",
"type": "module",
"bin": { "ccc": "./dist/cli.js" },
"bin": { "ccc": "dist/cli.js" },
"files": ["dist", "README.md", "LICENSE"],
"engines": { "node": ">=18" },
"scripts": {
Expand Down
1 change: 0 additions & 1 deletion src/core/download.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// src/core/download.test.ts
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { type Server, createServer } from 'node:http';
Expand Down
2 changes: 1 addition & 1 deletion src/core/download.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// src/core/download.ts
import type { Hash } from 'node:crypto';
import { once } from 'node:events';
import { createWriteStream } from 'node:fs';
Expand Down Expand Up @@ -47,6 +46,7 @@ export async function downloadFile(
}
} catch (err) {
file.destroy();
await reader.cancel().catch(() => {});
throw err;
} finally {
bar?.stop();
Expand Down
1 change: 0 additions & 1 deletion src/core/installer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// src/core/installer.test.ts
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { type Server, createServer } from 'node:http';
Expand Down
29 changes: 12 additions & 17 deletions src/core/installer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// src/core/installer.ts
import { createHash } from 'node:crypto';
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { chmod, mkdir, rename, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import pc from 'picocolors';
import { UnsupportedPlatformError } from '../utils/errors.js';
Expand All @@ -18,6 +16,12 @@ export interface InstallOptions {
showProgress?: boolean;
}

async function fetchChecksumText(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}

export async function install(opts: InstallOptions): Promise<string> {
const binDir = join(opts.stateDir, 'bin');
const destPath = join(binDir, 'claude');
Expand All @@ -26,9 +30,7 @@ export async function install(opts: InstallOptions): Promise<string> {
try {
await stat(destPath);
return destPath;
} catch {
// does not exist — continue
}
} catch {}
}

if (!isSupportedPlatform(opts.platform)) {
Expand All @@ -47,16 +49,12 @@ export async function install(opts: InstallOptions): Promise<string> {
const checksumURL = `${base}/checksums-sha256.txt`;
const binaryURL = `${base}/${platStr}/claude`;
const assetName = buildAssetName(opts.platform, version);

const tmpDir = await mkdtemp(join(tmpdir(), 'claude-code-cn-'));
const checksumPath = join(tmpDir, 'checksums-sha256.txt');
const tmpBinaryPath = `${destPath}.tmp`;

let checksumAvailable = true;
let checksumText: string | null = null;
try {
await downloadFile(checksumURL, checksumPath, null, { showProgress: false });
checksumText = await fetchChecksumText(checksumURL);
} catch (err) {
checksumAvailable = false;
process.stderr.write(
pc.yellow(`warning: 无法下载 checksums-sha256.txt, 将跳过校验 (${(err as Error).message})\n`),
);
Expand All @@ -70,10 +68,9 @@ export async function install(opts: InstallOptions): Promise<string> {
label: '下载中',
});

if (checksumAvailable) {
if (checksumText) {
process.stdout.write('正在校验 SHA-256...\n');
const data = await readFile(checksumPath, 'utf8');
const expected = parseChecksumFile(data, assetName);
const expected = parseChecksumFile(checksumText, assetName);
const actual = hash.digest('hex');
if (actual !== expected) {
throw new Error(`SHA-256 不匹配\n expected: ${expected}\n got: ${actual}`);
Expand All @@ -86,8 +83,6 @@ export async function install(opts: InstallOptions): Promise<string> {
} catch (err) {
await rm(tmpBinaryPath, { force: true });
throw err;
} finally {
await rm(tmpDir, { recursive: true, force: true });
}

return destPath;
Expand Down
11 changes: 0 additions & 11 deletions src/core/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,9 @@ import {
buildAssetName,
isMuslWith,
isSupportedPlatform,
mapArch,
platformString,
} from './platform.js';

describe('mapArch', () => {
it.each([
['x64', 'x64'],
['arm64', 'arm64'],
['ia32', 'ia32'],
])('maps %s -> %s', (input, want) => {
expect(mapArch(input)).toBe(want);
});
});

describe('isMuslWith', () => {
it('true when glob returns matches', () => {
expect(isMuslWith(() => ['/lib/ld-musl-x86_64.so.1'])).toBe(true);
Expand Down
28 changes: 8 additions & 20 deletions src/core/platform.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,33 @@
import { readdirSync } from 'node:fs';

export type SupportedOS = 'darwin' | 'linux';
export type Arch = string;

export interface Platform {
os: string;
arch: string;
variant: '' | 'musl';
}

export function mapArch(arch: string): string {
// Node 'x64' already matches the asset naming; keep this as a hook for future translations.
return arch;
}

// Glob abstraction kept simple: caller supplies a function that returns matches
// for a pattern. The default scans /lib for ld-musl-*.so* without pulling in
// a glob library. Node 22's fs.glob is intentionally avoided for ≥18 compatibility.
export function isMuslWith(globFn: (pattern: string) => string[]): boolean {
export function isMuslWith(globFn: () => string[]): boolean {
try {
return globFn('/lib/ld-musl-*.so*').length > 0;
return globFn().length > 0;
} catch {
return false;
}
}

function defaultGlob(pattern: string): string[] {
// Only the specific pattern '/lib/ld-musl-*.so*' is needed.
if (pattern !== '/lib/ld-musl-*.so*') return [];
const names = readdirSync('/lib');
function scanMuslLoaders(): string[] {
const re = /^ld-musl-.*\.so/;
return names.filter((n) => re.test(n)).map((n) => `/lib/${n}`);
return readdirSync('/lib')
.filter((n) => re.test(n))
.map((n) => `/lib/${n}`);
}

export function isMusl(): boolean {
return isMuslWith(defaultGlob);
return isMuslWith(scanMuslLoaders);
}

export function detectPlatform(): Platform {
const os = process.platform;
const arch = mapArch(process.arch);
const arch = process.arch;
const variant: '' | 'musl' = os === 'linux' && isMusl() ? 'musl' : '';
return { os, arch, variant };
}
Expand Down
13 changes: 8 additions & 5 deletions src/providers/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { confirm, input, select } from '@inquirer/prompts';
import { InterruptedError } from '../utils/errors.js';
import { mergeJSONFile } from '../utils/json-merge.js';
import { PROVIDER_ENV_KEYS, type ProviderEnv } from './env-keys.js';
import { PROVIDER_SPECS, type ProviderSpec } from './specs.js';
import { PROVIDER_SPECS } from './specs.js';

export interface ConfigureOptions {
settingsPath: string;
Expand Down Expand Up @@ -40,21 +40,24 @@ export async function configureProvider(
choices: PROVIDER_SPECS.map((s) => ({ name: s.name, value: s.name })),
}),
);
const spec = PROVIDER_SPECS.find((s) => s.name === providerName) as ProviderSpec;
const spec = PROVIDER_SPECS.find((s) => s.name === providerName);
if (!spec) throw new Error(`未知 Provider: ${providerName}`);

let baseURL = '';
if (spec.baseURLPrompt) {
baseURL = await ask(() => input({ message: spec.baseURLPrompt as string }));
const message = spec.baseURLPrompt;
baseURL = await ask(() => input({ message }));
}

const apiKey = await ask(() => input({ message: spec.keyPrompt }));

let secondArg = '';
if (spec.modelOptions && spec.modelOptions.length > 0) {
const modelOptions = spec.modelOptions;
if (modelOptions && modelOptions.length > 0) {
secondArg = await ask(() =>
select({
message: '请选择模型',
choices: (spec.modelOptions as string[]).map((m) => ({ name: m, value: m })),
choices: modelOptions.map((m) => ({ name: m, value: m })),
default: spec.modelDefault,
}),
);
Expand Down
14 changes: 7 additions & 7 deletions src/providers/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,31 @@ export const PROVIDER_SPECS: readonly ProviderSpec[] = [
name: 'KimiCode',
keyPrompt: '请输入 KimiCode API Key',
needClaudeJSON: false,
buildEnv: (k) => kimiCodeEnv(k),
buildEnv: kimiCodeEnv,
},
{
name: 'Moonshot (Kimi)',
keyPrompt: '请输入 Moonshot API Key',
needClaudeJSON: false,
buildEnv: (k) => moonshotEnv(k),
buildEnv: moonshotEnv,
},
{
name: 'DeepSeek',
keyPrompt: '请输入 DeepSeek API Key',
needClaudeJSON: false,
buildEnv: (k) => deepseekEnv(k),
buildEnv: deepseekEnv,
},
{
name: 'Zhipu (GLM)',
keyPrompt: '请输入 智谱 GLM API Key',
needClaudeJSON: true,
buildEnv: (k) => glmEnv(k),
buildEnv: glmEnv,
},
{
name: 'MiniMax',
keyPrompt: '请输入 MiniMax API Key',
needClaudeJSON: true,
buildEnv: (k) => minimaxEnv(k),
buildEnv: minimaxEnv,
},
{
name: 'Alibaba Cloud (Qwen)',
Expand Down Expand Up @@ -100,13 +100,13 @@ export const PROVIDER_SPECS: readonly ProviderSpec[] = [
keyPrompt: '请输入 小米 Mimo Token',
baseURLPrompt: '请输入 小米 Mimo Base URL',
needClaudeJSON: true,
buildEnv: (k, baseURL) => mimoEnv(k, baseURL),
buildEnv: mimoEnv,
},
{
name: 'Custom provider',
keyPrompt: '请输入 自定义 Provider Token',
baseURLPrompt: '请输入 自定义 Provider Base URL',
needClaudeJSON: false,
buildEnv: (k, baseURL) => customEnv(k, baseURL),
buildEnv: customEnv,
},
];
4 changes: 1 addition & 3 deletions src/utils/json-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ export async function mergeJSONFile(
} catch {
process.stderr.write(`warning: ${path} 不是合法 JSON, 将覆盖\n`);
}
} catch {
// file missing — start fresh
}
} catch {}
apply(existing);
const out = `${JSON.stringify(existing, null, 2)}\n`;
await writeFile(path, out, 'utf8');
Expand Down
Loading