diff --git a/.changeset/zstd-native-artifacts.md b/.changeset/zstd-native-artifacts.md new file mode 100644 index 00000000000..d928721f6e7 --- /dev/null +++ b/.changeset/zstd-native-artifacts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Download compressed native update artifacts and decompress them while staging. diff --git a/apps/kimi-code/scripts/native/produce-manifest.mjs b/apps/kimi-code/scripts/native/produce-manifest.mjs index 7718f64b118..0a0a9140e00 100644 --- a/apps/kimi-code/scripts/native/produce-manifest.mjs +++ b/apps/kimi-code/scripts/native/produce-manifest.mjs @@ -1,20 +1,36 @@ /** - * Aggregate per-platform zip archive `.sha256` files into a single - * `manifest.json` written into the same input directory. + * Build the per-release native artifact set and `manifest.json` from the + * matrix runners' zip archives. * * Usage: * node produce-manifest.mjs * - * Input dir must contain files matching: kimi-code-.zip.sha256 - * (produced by package.mjs across the 6 native-build matrix runners). - * - * Output: - * /manifest.json ← consumed by install.sh / install.ps1 + * Input dir must contain files matching: kimi-code-.zip(.sha256) + * (produced by package.mjs across the 6 native-build matrix runners). The + * zip is the only form in which binaries leave the matrix runners, so this + * script extracts each bare executable and emits next to it: + * kimi-code-.zst zstd -19, consumed by the staged updater + * kimi-code-.tar.gz consumed by install.sh / install.ps1 + * .sha256 sidecars in ` ` format + * manifest.json platform entries pair the bare binary with + * its compressed variant (`checksum` is always + * the hash of what `compressed` inflates to) * + * Requires `unzip`, `zstd`, and `tar` on PATH (preinstalled on + * GitHub-hosted runners). */ -import { readFile, readdir, writeFile } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { fail, run } from './exec.mjs'; + +const execFileAsync = promisify(execFile); const [, , inputDir, tag] = process.argv; if (!inputDir || !tag) { @@ -25,26 +41,64 @@ if (!inputDir || !tag) { // Tag 格式 `@moonshot-ai/kimi-code@x.y.z` 或 `vx.y.z` 或 `x.y.z`,都归一化到 x.y.z const version = tag.replace(/^@moonshot-ai\/kimi-code@/, '').replace(/^v/, ''); +for (const tool of ['unzip', 'zstd', 'tar']) { + try { + await execFileAsync('sh', ['-c', `command -v ${tool}`]); + } catch { + fail(`produce-manifest.mjs requires \`${tool}\` on PATH (preinstalled on GitHub-hosted runners).`); + } +} + +async function sha256File(path) { + return await new Promise((resolveHash, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolveHash(hash.digest('hex'))); + }); +} + const entries = await readdir(inputDir); const sumFiles = entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip\.sha256$/.test(f)); - if (sumFiles.length === 0) { - console.error(`No kimi-code-.zip.sha256 files found in ${inputDir}`); - process.exit(1); + fail(`No kimi-code-.zip.sha256 files found in ${inputDir}`); } const platforms = {}; for (const sumFile of sumFiles.sort()) { - 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); + // kimi-code-darwin-arm64.zip.sha256 → darwin-arm64 + const target = basename(sumFile, '.sha256').replace(/^kimi-code-/, '').replace(/\.zip$/, ''); + const zipName = `kimi-code-${target}.zip`; + const exeName = target.startsWith('win32') ? 'kimi.exe' : 'kimi'; + // The CDN bare-binary layout carries the .exe suffix on Windows + // (src/constant/app.ts); the updater's fallback downloads this filename. + const binaryName = target.startsWith('win32') ? `kimi-code-${target}.exe` : `kimi-code-${target}`; + const artifactBase = `kimi-code-${target}`; + const zstName = `${artifactBase}.zst`; + const tarballName = `${artifactBase}.tar.gz`; + + const workDir = await mkdtemp(join(tmpdir(), `native-manifest-${target}-`)); + try { + await run('unzip', ['-o', resolve(inputDir, zipName), '-d', workDir]); + const exePath = join(workDir, exeName); + const binaryChecksum = await sha256File(exePath); + await run('zstd', ['-T0', '-19', '-q', '-f', '-o', resolve(inputDir, zstName), exePath]); + await run('tar', ['-C', workDir, '-czf', resolve(inputDir, tarballName), exeName]); + + const zstChecksum = await sha256File(resolve(inputDir, zstName)); + const tarballChecksum = await sha256File(resolve(inputDir, tarballName)); + await writeFile(resolve(inputDir, `${zstName}.sha256`), `${zstChecksum} ${zstName}\n`); + await writeFile(resolve(inputDir, `${tarballName}.sha256`), `${tarballChecksum} ${tarballName}\n`); + + platforms[target] = { + filename: binaryName, + checksum: binaryChecksum, + compressed: { filename: zstName, checksum: zstChecksum }, + }; + } finally { + await rm(workDir, { recursive: true, force: true }); } - 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-manifest.ts b/apps/kimi-code/src/cli/update/native-manifest.ts index 0b47fb393aa..30d4e146ca5 100644 --- a/apps/kimi-code/src/cli/update/native-manifest.ts +++ b/apps/kimi-code/src/cli/update/native-manifest.ts @@ -4,7 +4,8 @@ * Published alongside the release and consumed by the install scripts; the * staged updater reuses the same file so checksums and file names have a * single source of truth. Entries point at the bare platform binary - * (`kimi-code-[.exe]`), not an archive. + * (`kimi-code-[.exe]`), not an archive; an entry may additionally + * carry `compressed`, pointing at the zstd-compressed variant of that binary. */ import { valid } from 'semver'; @@ -17,6 +18,12 @@ const MANIFEST_FETCH_TIMEOUT_MS = 10_000; const PlatformEntrySchema = z.object({ filename: z.string().min(1), checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }), + compressed: z + .object({ + filename: z.string().min(1), + checksum: z.string().regex(/^[a-f0-9]{64}$/, { error: 'invalid sha256' }), + }) + .optional(), }); /** diff --git a/apps/kimi-code/src/cli/update/native-stage.ts b/apps/kimi-code/src/cli/update/native-stage.ts index f85b86d7c88..ed3ab405589 100644 --- a/apps/kimi-code/src/cli/update/native-stage.ts +++ b/apps/kimi-code/src/cli/update/native-stage.ts @@ -12,6 +12,7 @@ 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 { createZstdDecompress } from 'node:zlib'; import { valid } from 'semver'; import { z } from 'zod'; @@ -186,14 +187,16 @@ export async function hashFileSha256(filePath: string): Promise { /** * Whether a `.staging/` entry is an updater-owned artifact: a staged * executable (`kimi-[...][.exe]`) or a download - * intermediate (the same plus `.part`). Ownership derives from the - * semver/file-name contract (prerelease and build metadata included), so - * foreign files in the directory are never matched. + * intermediate (the same plus `.part`, optionally with a `.zst` infix). + * Ownership derives from the semver/file-name contract (prerelease and + * build metadata included), so foreign files in the directory are never + * matched. */ function isUpdaterOwnedStagingFile(entry: string): boolean { if (!entry.startsWith('kimi-')) return false; let name = entry.slice('kimi-'.length); if (name.endsWith('.part')) name = name.slice(0, -'.part'.length); + if (name.endsWith('.zst')) name = name.slice(0, -'.zst'.length); if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length); // Published artifacts may carry a unique per-worker infix after the // version (..., or the older ..) — try with and @@ -365,6 +368,45 @@ async function downloadAndHash( return size; } +/** + * Inflate a downloaded `.zst` artifact into `destPath`, hashing the plain + * bytes as they stream through; **throws** when the result does not match + * the manifest's bare-binary checksum. Returns the decompressed size. + */ +async function decompressAndHash( + zstPath: string, + destPath: string, + expectedSha256: string, +): Promise { + const hash = createHash('sha256'); + let size = 0; + const file = await open(destPath, 'w'); + try { + for await (const chunk of createReadStream(zstPath).pipe(createZstdDecompress())) { + hash.update(chunk as Buffer); + size += (chunk as Buffer).length; + // Same short-write loop as downloadAndHash: FileHandle.write may + // persist fewer bytes than requested, so loop until the chunk is + // fully on disk. + let offset = 0; + while (offset < (chunk as Buffer).length) { + const { bytesWritten } = await file.write(chunk as Buffer, offset); + if (bytesWritten === 0) { + throw new Error('failed to write the native binary to disk (disk full?)'); + } + offset += bytesWritten; + } + } + } finally { + await file.close(); + } + const digest = hash.digest('hex'); + if (digest !== expectedSha256) { + throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${digest}`); + } + return size; +} + /** * Download + verify `version` next to the running executable. * @@ -446,7 +488,32 @@ export async function stageNativeUpdate( try { const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl); const entry = selectPlatformEntry(manifest, platform, arch); - const size = await downloadAndHash( + // Prefer the zstd-compressed artifact when the manifest carries one and + // the runtime can inflate it (~4x smaller than the bare binary). Any + // failure in the compressed path falls back to the bare download below. + let size: number | undefined; + if (entry.compressed !== undefined && typeof createZstdDecompress === 'function') { + const zstPartPath = join(stagingDir, `${exeFileName}.zst.part`); + try { + await downloadAndHash( + nativeBinaryUrl(options.version, entry.compressed.filename), + zstPartPath, + entry.compressed.checksum, + fetchImpl, + options.onProgress, + options.idleTimeoutMs, + ); + size = await decompressAndHash(zstPartPath, partPath, entry.checksum); + await rm(zstPartPath, { force: true }); + } catch (error) { + console.warn( + `[update] compressed artifact unavailable, falling back to uncompressed download: ${error instanceof Error ? error.message : String(error)}`, + ); + await rm(zstPartPath, { force: true }).catch(() => {}); + await rm(partPath, { force: true }).catch(() => {}); + } + } + size ??= await downloadAndHash( nativeBinaryUrl(options.version, entry.filename), partPath, entry.checksum, diff --git a/apps/kimi-code/test/cli/update/native-manifest.test.ts b/apps/kimi-code/test/cli/update/native-manifest.test.ts index 3c4df548fd6..bc72d809f9e 100644 --- a/apps/kimi-code/test/cli/update/native-manifest.test.ts +++ b/apps/kimi-code/test/cli/update/native-manifest.test.ts @@ -59,6 +59,24 @@ describe('fetchNativeReleaseManifest', () => { expect(manifest.version).toBe(VERSION); }); + it('parses a platform entry carrying a compressed artifact pointer', async () => { + const body = JSON.stringify({ + version: VERSION, + platforms: { + 'linux-x64': { + filename: 'kimi-code-linux-x64', + checksum: 'a'.repeat(64), + compressed: { filename: 'kimi-code-linux-x64.zst', checksum: 'b'.repeat(64) }, + }, + }, + }); + const manifest = await fetchNativeReleaseManifest(VERSION, mockFetch({ ok: true, status: 200, body })); + expect(manifest.platforms['linux-x64']?.compressed).toEqual({ + filename: 'kimi-code-linux-x64.zst', + checksum: 'b'.repeat(64), + }); + }); + it('rejects a non-semver version argument before hitting the network', async () => { const f = mockFetch({ ok: true, status: 200, body: MANIFEST_BODY }); await expect(fetchNativeReleaseManifest('nope', f)).rejects.toThrow(/invalid semver/); 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 97641fc66bf..482eb504d17 100644 --- a/apps/kimi-code/test/cli/update/native-stage.test.ts +++ b/apps/kimi-code/test/cli/update/native-stage.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { zstdCompressSync } from 'node:zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -74,6 +75,7 @@ const VERSION = '0.7.0'; const PAYLOAD = Buffer.from('fake-sea-binary-payload'); // The CDN serves the bare platform binary; the manifest checksum is its sha256. const BINARY_FILENAME = 'kimi-code-linux-x64'; +const COMPRESSED_FILENAME = 'kimi-code-linux-x64.zst'; function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); @@ -90,18 +92,31 @@ interface MockCdnOptions { readonly version?: string; readonly payload: Buffer; readonly checksum?: string; + /** + * When set, the manifest entry advertises a compressed artifact; the .zst + * URL serves `payload` (null → 404, a CDN without the artifact yet). + */ + readonly compressed?: { readonly payload: Buffer | null; readonly checksum?: string }; } function mockCdnFetch(options: MockCdnOptions): typeof fetch { const version = options.version ?? VERSION; + const platformEntry: Record = { + filename: BINARY_FILENAME, + checksum: options.checksum ?? sha256Hex(options.payload), + }; + if (options.compressed !== undefined) { + platformEntry['compressed'] = { + filename: COMPRESSED_FILENAME, + checksum: + options.compressed.checksum ?? sha256Hex(options.compressed.payload ?? Buffer.alloc(0)), + }; + } const manifestBody = JSON.stringify({ version, tag: `v${version}`, platforms: { - 'linux-x64': { - filename: BINARY_FILENAME, - checksum: options.checksum ?? sha256Hex(options.payload), - }, + 'linux-x64': platformEntry, }, }); return vi.fn(async (input: string | URL) => { @@ -121,6 +136,19 @@ function mockCdnFetch(options: MockCdnOptions): typeof fetch { body: [options.payload], }; } + if (url === nativeBinaryUrl(version, COMPRESSED_FILENAME) && options.compressed?.payload) { + const payload = options.compressed.payload; + return { + ok: true, + status: 200, + text: async (): Promise => '', + headers: { + get: (name: string): string | null => + name === 'content-length' ? String(payload.length) : null, + }, + body: [payload], + }; + } return { ok: false, status: 404, text: async () => '', body: null }; }) as unknown as typeof fetch; } @@ -137,6 +165,7 @@ describe('stageNativeUpdate', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await rm(workDir, { recursive: true, force: true }); }); @@ -172,6 +201,106 @@ describe('stageNativeUpdate', () => { expect(leftovers).toEqual([]); }); + it('downloads the compressed artifact and stages the decompressed binary', async () => { + const fetchImpl = mockCdnFetch({ + payload: PAYLOAD, + compressed: { payload: zstdCompressSync(PAYLOAD) }, + }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + }); + + expect(result.status).toBe('staged'); + // The metadata records the BARE binary's checksum and size. + expect(result.staged.sha256).toBe(sha256Hex(PAYLOAD)); + expect(result.staged.exeSize).toBe(PAYLOAD.length); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + // The bare binary was never downloaded… + expect(fetchImpl).not.toHaveBeenCalledWith( + nativeBinaryUrl(VERSION, BINARY_FILENAME), + expect.anything(), + ); + // …and the .zst intermediate is gone once decompressed and published. + const leftovers = (await readdir(getNativeStagingDir(exePath))).filter((entry) => + entry.endsWith('.part'), + ); + expect(leftovers).toEqual([]); + }); + + it('falls back to the bare binary when the compressed artifact is missing', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchImpl = mockCdnFetch({ + payload: PAYLOAD, + compressed: { payload: null }, + }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + }); + + expect(result.status).toBe('staged'); + expect(fetchImpl).toHaveBeenCalledWith( + nativeBinaryUrl(VERSION, BINARY_FILENAME), + expect.anything(), + ); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + }); + + it('falls back to the bare binary when the compressed artifact fails verification', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchImpl = mockCdnFetch({ + payload: PAYLOAD, + // The .zst bytes do not hash to the advertised compressed checksum. + compressed: { payload: zstdCompressSync(PAYLOAD), checksum: 'f'.repeat(64) }, + }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + }); + + expect(result.status).toBe('staged'); + expect(fetchImpl).toHaveBeenCalledWith( + nativeBinaryUrl(VERSION, BINARY_FILENAME), + expect.anything(), + ); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + }); + + it('falls back to the bare binary when the decompressed content fails verification', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // The .zst verifies against its own checksum but inflates to something + // other than the bare binary the manifest checksum covers. + const fetchImpl = mockCdnFetch({ + payload: PAYLOAD, + compressed: { payload: zstdCompressSync(Buffer.from('other-content')) }, + }); + const result = await stageNativeUpdate({ + version: VERSION, + exePath, + platform: 'linux', + arch: 'x64', + fetchImpl, + }); + + expect(result.status).toBe('staged'); + expect(result.staged.sha256).toBe(sha256Hex(PAYLOAD)); + const exeBytes = await readFile(stagedExePath(exePath, result.staged)); + expect(exeBytes.equals(PAYLOAD)).toBe(true); + }); + it('marks the staged exe executable', async () => { const result = await stageNativeUpdate({ version: VERSION, diff --git a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts index 791fd14e0da..8b4586eab34 100644 --- a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts +++ b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts @@ -1,13 +1,15 @@ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { execFile } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; import { promisify } from 'node:util'; -import { inflateRawSync } from 'node:zlib'; +import { inflateRawSync, zstdDecompressSync } from 'node:zlib'; import { afterEach, describe, expect, it } from 'vitest'; +import { ZipFile } from 'yazl'; import { appRoot } from '../../../scripts/native/paths.mjs'; @@ -92,8 +94,17 @@ function findEndOfCentralDirectory(zip: Buffer): number { describe('native release artifacts', () => { afterEach(() => { rmSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true, force: true }); - rmSync(resolve(artifactsDir, `kimi-code-${target}.zip`), { force: true }); - rmSync(resolve(artifactsDir, `kimi-code-${target}.zip.sha256`), { force: true }); + for (const name of [ + `kimi-code-${target}.zip`, + `kimi-code-${target}.zip.sha256`, + `kimi-code-${target}.zst`, + `kimi-code-${target}.zst.sha256`, + `kimi-code-${target}.tar.gz`, + `kimi-code-${target}.tar.gz.sha256`, + 'manifest.json', + ]) { + rmSync(resolve(artifactsDir, name), { force: true }); + } }); it('packages the native binary as a zip archive and checksums the archive', async () => { @@ -117,34 +128,90 @@ describe('native release artifacts', () => { ); }); - it('produces a manifest from zip archive checksums', async () => { - const releaseDir = await mkdtemp(join(tmpdir(), 'kimi-manifest-zip-')); - const archiveBytes = Buffer.from('fake zip bytes'); - const checksum = sha256(archiveBytes); - await writeFile(join(releaseDir, 'kimi-code-darwin-arm64.zip'), archiveBytes); - await writeFile( - join(releaseDir, 'kimi-code-darwin-arm64.zip.sha256'), - `${checksum} kimi-code-darwin-arm64.zip\n`, - ); + it('produces compressed artifacts and a manifest paired with the bare binary', async () => { + const binaryContent = 'native binary payload\n'; + mkdirSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true }); + writeFileSync(fakeBinary, binaryContent, { mode: 0o755 }); + await execFileAsync(process.execPath, [packageScript], { + cwd: appRoot, + env: { ...process.env, KIMI_CODE_BUILD_TARGET: target }, + }); - await execFileAsync(process.execPath, [manifestScript, releaseDir, '@moonshot-ai/kimi-code@0.5.0']); + await execFileAsync(process.execPath, [manifestScript, artifactsDir, '@moonshot-ai/kimi-code@0.5.0']); + + const zstBytes = readFileSync(resolve(artifactsDir, `kimi-code-${target}.zst`)); + expect(zstdDecompressSync(zstBytes).toString('utf-8')).toBe(binaryContent); + const tarballPath = resolve(artifactsDir, `kimi-code-${target}.tar.gz`); + expect(existsSync(tarballPath)).toBe(true); + expect(readFileSync(`${tarballPath}.sha256`, 'utf-8')).toBe( + `${sha256(readFileSync(tarballPath))} kimi-code-${target}.tar.gz\n`, + ); const manifest = JSON.parse( - await readFile(join(releaseDir, 'manifest.json'), 'utf-8'), + readFileSync(resolve(artifactsDir, 'manifest.json'), 'utf-8'), ) as { version: string; tag: string; - platforms: Record; + platforms: Record< + string, + { filename: string; checksum: string; compressed: { filename: string; checksum: string } } + >; }; expect(manifest).toEqual({ version: '0.5.0', tag: '@moonshot-ai/kimi-code@0.5.0', platforms: { - 'darwin-arm64': { - filename: 'kimi-code-darwin-arm64.zip', - checksum, + [target]: { + filename: `kimi-code-${target}`, + checksum: sha256(Buffer.from(binaryContent)), + compressed: { filename: `kimi-code-${target}.zst`, checksum: sha256(zstBytes) }, }, }, }); }); + + it('keeps the .exe suffix in Windows manifest filenames', async () => { + const releaseDir = await mkdtemp(join(tmpdir(), 'kimi-manifest-win32-')); + try { + const binaryContent = Buffer.from('fake windows binary'); + const zip = new ZipFile(); + zip.addBuffer(binaryContent, 'kimi.exe'); + zip.end(); + await pipeline( + zip.outputStream, + createWriteStream(join(releaseDir, 'kimi-code-win32-x64.zip')), + ); + await writeFile( + join(releaseDir, 'kimi-code-win32-x64.zip.sha256'), + `${'b'.repeat(64)} kimi-code-win32-x64.zip\n`, + ); + + await execFileAsync(process.execPath, [manifestScript, releaseDir, 'v0.5.0']); + + const manifest = JSON.parse(await readFile(join(releaseDir, 'manifest.json'), 'utf-8')) as { + platforms: Record< + string, + { filename: string; checksum: string; compressed: { filename: string } } + >; + }; + const entry = manifest.platforms['win32-x64']; + if (entry === undefined) throw new Error('missing win32-x64 manifest entry'); + expect(entry.filename).toBe('kimi-code-win32-x64.exe'); + expect(entry.checksum).toBe(sha256(binaryContent)); + expect(entry.compressed.filename).toBe('kimi-code-win32-x64.zst'); + } finally { + rmSync(releaseDir, { recursive: true, force: true }); + } + }); + + it('fails when no zip sidecars exist', async () => { + const releaseDir = await mkdtemp(join(tmpdir(), 'kimi-manifest-empty-')); + try { + await expect( + execFileAsync(process.execPath, [manifestScript, releaseDir, 'v0.5.0']), + ).rejects.toThrow(); + } finally { + rmSync(releaseDir, { recursive: true, force: true }); + } + }); });