diff --git a/apps/kimi-code/scripts/native/produce-manifest.mjs b/apps/kimi-code/scripts/native/produce-manifest.mjs index f04ee8a137..05b5d546fc 100644 --- a/apps/kimi-code/scripts/native/produce-manifest.mjs +++ b/apps/kimi-code/scripts/native/produce-manifest.mjs @@ -1,6 +1,6 @@ /** - * Aggregate per-platform native artifacts into a single `manifest.json` - * written into the same input directory. + * Aggregate per-platform zip archive `.sha256` files into a single + * `manifest.json` written into the same input directory. * * Usage: * node produce-manifest.mjs @@ -10,20 +10,14 @@ * * Output: * /manifest.json ← consumed by the staged updater - * (apps/kimi-code/src/cli/update/native-manifest.ts): each platform entry - * points at the BARE executable (kimi-code-) with the - * executable's sha256 — the updater downloads and stages it directly, so - * the archive must be extracted here, not referenced. + * (apps/kimi-code/src/cli/update/native-manifest.ts). The updater + * downloads the referenced archive and extracts it before staging (see + * native-stage.ts), so the manifest lists the ZIP with the zip's sha256. * */ -import { execFile } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; -import { promisify } from 'node:util'; - -const execFileP = promisify(execFile); const [, , inputDir, tag] = process.argv; if (!inputDir || !tag) { @@ -34,10 +28,6 @@ if (!inputDir || !tag) { // Tag 格式 `@mbuckaway/kimi-code@x.y.z-MB.n.m` 或 `vx.y.z` 或 `x.y.z`,都归一化到 semver 本体(保留预发布后缀) const version = tag.replace(/^@mbuckaway\/kimi-code@/, '').replace(/^v/, ''); -function sha256Hex(data) { - return createHash('sha256').update(data).digest('hex'); -} - const entries = await readdir(inputDir); const sumFiles = entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip\.sha256$/.test(f)); @@ -48,25 +38,16 @@ if (sumFiles.length === 0) { const platforms = {}; for (const sumFile of sumFiles.sort()) { - const zipName = basename(sumFile, '.sha256'); // kimi-code-.zip - const target = zipName.replace(/^kimi-code-/, '').replace(/\.zip$/, ''); - // Verify the zip arrived intact against its published checksum. - const expected = (await readFile(resolve(inputDir, sumFile), 'utf-8')).trim().split(/\s+/, 1)[0]; - const zipSha = sha256Hex(await readFile(resolve(inputDir, zipName))); - if (!expected || zipSha !== expected) { - console.error(`Checksum mismatch for ${zipName}: expected ${expected}, got ${zipSha}`); + const text = await readFile(resolve(inputDir, sumFile), 'utf-8'); + const [checksum] = text.trim().split(/\s+/, 1); + if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) { + console.error(`Invalid checksum in ${sumFile}: ${checksum}`); process.exit(1); } - // The staged updater downloads the BARE executable the manifest points at, - // so extract the zip and hash the binary inside. - const extractDir = resolve(inputDir, `.extract-${target}`); - await mkdir(extractDir, { recursive: true }); - await execFileP('unzip', ['-o', '-q', resolve(inputDir, zipName), '-d', extractDir]); - const checksum = sha256Hex( - await readFile(resolve(extractDir, target.startsWith('win32') ? 'kimi.exe' : 'kimi')), - ); - await rm(extractDir, { recursive: true, force: true }); - platforms[target] = { filename: `kimi-code-${target}`, checksum }; + const filename = basename(sumFile, '.sha256'); + // kimi-code-darwin-arm64.zip → darwin-arm64 + const target = filename.replace(/^kimi-code-/, '').replace(/\.zip$/, ''); + platforms[target] = { filename, checksum }; } const manifest = { version, tag, platforms }; diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index f85b86d7c8..18dae1e0aa 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -3,15 +3,18 @@ * without touching the running executable. The actual swap happens on the * next startup (see `native-swap.ts`). * - * The CDN serves the bare platform binary (e.g. `kimi-code-win32-x64.exe`), - * whose sha256 comes from the per-release manifest over HTTPS — a staged - * binary is byte-exact what the release pipeline produced. + * The update channel serves either the bare platform binary (upstream) or a + * per-platform zip archive (fork), whose sha256 comes from the per-release + * manifest over HTTPS. Zip payloads are extracted before staging so the + * staged file is always the byte-exact executable the pipeline produced. */ +import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink } from 'node:fs/promises'; import { basename, join } from 'node:path'; +import { promisify } from 'node:util'; import { valid } from 'semver'; import { z } from 'zod'; @@ -26,6 +29,40 @@ import { selectPlatformEntry, } from './native-manifest'; +const execFileP = promisify(execFile); + +/** + * The fork's release pipeline ships per-platform zips on the update channel, + * while upstream serves bare binaries. The staged file MUST be the actual + * executable (the startup swap smoke-tests it), so zip payloads are extracted + * into a sibling directory and the inner executable returned; bare files pass + * through unchanged. The caller removes `extractDir` after publishing. + */ +async function extractExecutableFromArchive( + archivePath: string, + stagingDir: string, + platform: NodeJS.Platform, +): Promise<{ exePath: string; extractDir: string | null }> { + // Zip archives start with the local-file-header magic (PK\x03\x04). + const file = await open(archivePath, 'r'); + const head = Buffer.alloc(4); + await file.read(head, 0, 4, 0); + await file.close(); + const isZip = head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04; + if (!isZip) return { exePath: archivePath, extractDir: null }; + const extractDir = join(stagingDir, `${basename(archivePath)}.x`); + await rm(extractDir, { recursive: true, force: true }); + await mkdir(extractDir, { recursive: true }); + if (platform === 'win32') { + // bsdtar reads zips on Windows without needing `unzip`. + await execFileP('tar', ['xf', archivePath, '-C', extractDir]); + } else { + await execFileP('unzip', ['-q', archivePath, '-d', extractDir]); + } + const exeName = platform === 'win32' ? 'kimi.exe' : 'kimi'; + return { exePath: join(extractDir, exeName), extractDir }; +} + const StagedNativeUpdateSchema = z .object({ version: z.string().min(1), @@ -446,7 +483,7 @@ export async function stageNativeUpdate( try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); - const size = await downloadAndHash( + await downloadAndHash( nativeBinaryUrl(options.version, entry.filename), partPath, entry.checksum, @@ -454,16 +491,34 @@ export async function stageNativeUpdate( options.onProgress, options.idleTimeoutMs, ); - // sha256 matched the manifest. Make the private .part file executable - // BEFORE publishing it: a concurrent swap may move the staged exe into - // the install path the instant it appears at its published name, so a - // post-publish chmod could land on a path that is already gone — leaving - // a non-executable installation behind. - await chmod(partPath, 0o755); - await rename(partPath, stagedExePath(options.exePath, staged)); + // The fork's update channel ships per-platform zips; upstream serves bare + // binaries. The staged file MUST be the actual executable (the startup + // swap smoke-tests it), so extract zip payloads before publishing; bare + // files are used as-is. + const { exePath: stagedFile, extractDir } = await extractExecutableFromArchive( + partPath, + stagingDir, + platform, + ); + // sha256 matched the manifest (of the downloaded archive/binary). Make + // the file executable BEFORE publishing it: a concurrent swap may move + // the staged exe into the install path the instant it appears at its + // published name, so a post-publish chmod could land on a path that is + // already gone — leaving a non-executable installation behind. + await chmod(stagedFile, 0o755); + const stagedStat = await stat(stagedFile); + const stagedHash = await hashFileSha256(stagedFile); + if (stagedHash === null) { + throw new Error(`failed to hash the staged executable: ${stagedFile}`); + } + await rename(stagedFile, stagedExePath(options.exePath, staged)); + if (extractDir !== null) { + await rm(extractDir, { recursive: true, force: true }); + } + await rm(partPath, { force: true }); - staged.sha256 = entry.checksum; - staged.exeSize = size; + staged.sha256 = stagedHash; + staged.exeSize = stagedStat.size; // Atomic write: staged.json only ever appears complete and consistent. await writeJsonFile( getNativeStagedStateFile(options.exePath), diff --git a/apps/kimi-code/test/cli/update/native-stage.test.ts b/apps/kimi-code/test/cli/update/native-stage.test.ts index 97641fc66b..66571ec107 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -3,6 +3,8 @@ import { mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ZipFile } from 'yazl'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nativeBinaryUrl, nativeManifestUrl } from '#/cli/update/native-manifest'; @@ -79,6 +81,18 @@ function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } +/** Build a single-entry zip (matching the fork release pipeline's layout). */ +async function buildZip(entryName: string, content: Buffer): Promise { + const zip = new ZipFile(); + zip.addBuffer(content, entryName, { mode: 0o100755 }); + zip.end(); + const chunks: Buffer[] = []; + for await (const chunk of zip.outputStream) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks); +} + /** Write a staging artifact old enough for the orphan sweep to reap it. */ async function agedOrphan(path: string, content: string | Buffer): Promise { await writeFile(path, content); @@ -90,16 +104,19 @@ interface MockCdnOptions { readonly version?: string; readonly payload: Buffer; readonly checksum?: string; + /** Manifest filename; defaults to the bare-binary name. */ + readonly filename?: string; } function mockCdnFetch(options: MockCdnOptions): typeof fetch { const version = options.version ?? VERSION; + const filename = options.filename ?? BINARY_FILENAME; const manifestBody = JSON.stringify({ version, tag: `v${version}`, platforms: { 'linux-x64': { - filename: BINARY_FILENAME, + filename, checksum: options.checksum ?? sha256Hex(options.payload), }, }, @@ -109,7 +126,7 @@ function mockCdnFetch(options: MockCdnOptions): typeof fetch { if (url === nativeManifestUrl(version)) { return { ok: true, status: 200, text: async () => manifestBody, body: null }; } - if (url === nativeBinaryUrl(version, BINARY_FILENAME)) { + if (url === nativeBinaryUrl(version, filename)) { return { ok: true, status: 200, @@ -172,6 +189,32 @@ describe('stageNativeUpdate', () => { expect(leftovers).toEqual([]); }); + it('extracts a zip payload and stages the inner executable', async () => { + // The fork's update channel ships per-platform zips; the staged file must + // be the extracted executable, not the archive. + const inner = Buffer.from('fake-zip-inner-executable'); + const zipPayload = await buildZip('kimi', inner); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl: mockCdnFetch({ payload: zipPayload, filename: 'kimi-code-linux-x64.zip' }), + }); + + expect(result.status).toBe('staged'); + // The staged file is the EXTRACTED executable, hashed and sized as such. + expect(result.staged.sha256).toBe(sha256Hex(inner)); + expect(result.staged.exeSize).toBe(inner.length); + const stagedOnDisk = await readFile(stagedExePath(exePath, result.staged)); + expect(stagedOnDisk.equals(inner)).toBe(true); + // Neither the .part archive nor the extraction directory remain. + const leftovers = (await readdir(getNativeStagingDir(exePath))).filter((entry) => + entry.endsWith('.part') || entry.endsWith('.x'), + ); + expect(leftovers).toEqual([]); + }); + it('marks the staged exe executable', async () => { const result = await stageNativeUpdate({ version: VERSION, diff --git a/fork/scripts/publish-update-channel.mjs b/fork/scripts/publish-update-channel.mjs index 405808ac74..46cc760e10 100644 --- a/fork/scripts/publish-update-channel.mjs +++ b/fork/scripts/publish-update-channel.mjs @@ -7,8 +7,8 @@ * install.sh native installer, copied verbatim from fork/install.sh * install.ps1 Windows installer, copied verbatim from fork/install.ps1 * sha256/.sha256 per-platform checksums, consumed by install.sh / install.ps1 - * binaries// per-release native manifest + BARE platform binaries, - * consumed by the staged updater (native-manifest.ts) + * binaries// per-release native manifest + platform zips, consumed by + * the staged updater (native-manifest.ts / native-stage.ts) * * Usage: * node fork/scripts/publish-update-channel.mjs @@ -18,13 +18,9 @@ * consumes). */ -import { execFile } from 'node:child_process'; -import { copyFile, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -const execFileP = promisify(execFile); const [, , version, artifactsDir, outDir] = process.argv; if (!version || !artifactsDir || !outDir) { @@ -73,11 +69,12 @@ for (const sumFile of sumFiles) { await copyFile(resolve(artifactsDir, sumFile), resolve(outDir, 'sha256', `${target}.sha256`)); } -// The staged updater (native-manifest.ts) downloads the BARE executable -// referenced by the manifest from /binaries// — publish the manifest -// plus the extracted platform binaries alongside the channel files, or -// upgrades 404 on the manifest / stage a zip as an executable. manifest.json -// is written into the artifacts dir by produce-manifest.mjs before this runs. +// The staged updater (native-manifest.ts / native-stage.ts) downloads the +// referenced archive and extracts it before staging — publish the manifest +// plus the platform zips under /binaries// alongside the channel +// files or upgrades 404. manifest.json is written into the artifacts dir by +// produce-manifest.mjs before this runs. (Bare binaries exceed GitHub's +// 100 MB per-file limit, so the zips are what the channel carries.) const binariesDir = resolve(outDir, 'binaries', version); await mkdir(binariesDir, { recursive: true }); const manifestSource = resolve(artifactsDir, 'manifest.json'); @@ -88,15 +85,7 @@ try { process.exit(1); } for (const zipFile of entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip$/.test(f))) { - const target = zipFile.replace(/^kimi-code-/, '').replace(/\.zip$/, ''); - const extractDir = resolve(outDir, `.tmp-${target}`); - await mkdir(extractDir, { recursive: true }); - await execFileP('unzip', ['-o', '-q', resolve(artifactsDir, zipFile), '-d', extractDir]); - await copyFile( - resolve(extractDir, target.startsWith('win32') ? 'kimi.exe' : 'kimi'), - resolve(binariesDir, `kimi-code-${target}`), - ); - await rm(extractDir, { recursive: true, force: true }); + await copyFile(resolve(artifactsDir, zipFile), resolve(binariesDir, zipFile)); } console.log(`Wrote update channel for ${version} (${sumFiles.length} platforms) to ${outDir}`);