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
47 changes: 14 additions & 33 deletions apps/kimi-code/scripts/native/produce-manifest.mjs
Original file line number Diff line number Diff line change
@@ -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 <input-dir> <release-tag>
Expand All @@ -10,20 +10,14 @@
*
* Output:
* <input-dir>/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-<target>) 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) {
Expand All @@ -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));

Expand All @@ -48,25 +38,16 @@ if (sumFiles.length === 0) {

const platforms = {};
for (const sumFile of sumFiles.sort()) {
const zipName = basename(sumFile, '.sha256'); // kimi-code-<target>.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 };
Expand Down
81 changes: 68 additions & 13 deletions apps/kimi-code/src/cli/update/native-stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -446,24 +483,42 @@ 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,
fetchImpl,
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),
Expand Down
47 changes: 45 additions & 2 deletions apps/kimi-code/test/cli/update/native-stage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Buffer> {
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<void> {
await writeFile(path, content);
Expand All @@ -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),
},
},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 10 additions & 21 deletions fork/scripts/publish-update-channel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<target>.sha256 per-platform checksums, consumed by install.sh / install.ps1
* binaries/<version>/ per-release native manifest + BARE platform binaries,
* consumed by the staged updater (native-manifest.ts)
* binaries/<version>/ 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 <version> <native-artifacts-dir> <out-dir>
Expand All @@ -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) {
Expand Down Expand Up @@ -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/<version>/ — 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/<version>/ 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');
Expand All @@ -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}`);
Loading