From 5629f91dca2125d6dacf7578beca7d4a1cc3cccb Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 11 Jun 2026 20:41:10 +0800 Subject: [PATCH] feat(native,server): embed web assets and enrich lifecycle output - collect and embed server web assets into the native SEA binary\n- add web-assets manifest and collection scripts for native builds\n- make swagger/swagger-ui registration conditional in server start\n- enrich lifecycle commands with service URL, running state, log path, and notes\n- add --no-open flag to control auto-opening web UI after install\n- introduce ServiceUnavailableError and ProgramServiceManager\n- update service managers (launchd, systemd, schtasks) with new status fields --- .changeset/embed-native-web-assets.md | 6 + apps/kimi-code/scripts/native/02-sea-blob.mjs | 10 + .../kimi-code/scripts/native/check-bundle.mjs | 2 + apps/kimi-code/scripts/native/manifest.mjs | 9 + apps/kimi-code/scripts/native/web-assets.mjs | 118 +++++++++++ .../kimi-code/src/cli/sub/server/lifecycle.ts | 80 +++++++- apps/kimi-code/src/cli/sub/server/run.ts | 9 +- apps/kimi-code/src/native/web-assets.ts | 183 +++++++++++++++++ apps/kimi-code/test/cli/server/server.test.ts | 185 +++++++++++++++++- apps/kimi-code/test/native/web-assets.test.ts | 113 +++++++++++ .../test/scripts/native/web-assets.test.ts | 89 +++++++++ apps/kimi-code/tsdown.native.config.ts | 9 +- packages/server/src/index.ts | 2 +- packages/server/src/start.ts | 116 +++++++---- packages/server/src/svc/index.ts | 1 + packages/server/src/svc/launchd.ts | 27 ++- packages/server/src/svc/program.ts | 10 + packages/server/src/svc/schtasks.ts | 3 +- packages/server/src/svc/systemd.ts | 16 +- packages/server/src/svc/types.ts | 15 ++ packages/server/test/start.test.ts | 36 ++++ packages/server/test/svc/launchd.test.ts | 10 +- packages/server/test/svc/systemd.test.ts | 41 +++- packages/server/test/swagger.e2e.test.ts | 1 + packages/services/src/prompt/promptService.ts | 12 +- packages/services/test/prompt-service.test.ts | 30 +++ 26 files changed, 1061 insertions(+), 72 deletions(-) create mode 100644 .changeset/embed-native-web-assets.md create mode 100644 apps/kimi-code/scripts/native/web-assets.mjs create mode 100644 apps/kimi-code/src/native/web-assets.ts create mode 100644 apps/kimi-code/test/native/web-assets.test.ts create mode 100644 apps/kimi-code/test/scripts/native/web-assets.test.ts create mode 100644 packages/server/src/svc/program.ts diff --git a/.changeset/embed-native-web-assets.md b/.changeset/embed-native-web-assets.md new file mode 100644 index 000000000..1eb18b428 --- /dev/null +++ b/.changeset/embed-native-web-assets.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Embed server web assets in the native binary and make installed server lifecycle output show the web URL and service state. diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 8bac05a84..434a7861c 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -16,6 +16,7 @@ import { nativeSeaConfigPath, targetTriple, } from './paths.mjs'; +import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; async function ensureBundleExists() { try { @@ -31,13 +32,19 @@ async function writeSeaConfig(target) { appRoot, target, }); + const web = await collectWebAssets({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(webManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); + await writeFile(webManifestPath, web.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, + [webAssetManifestKey(target)]: webManifestPath, ...assets, + ...web.assets, }; const config = { main: nativeJsBundlePath(), @@ -55,6 +62,9 @@ async function writeSeaConfig(target) { for (const line of nativeAssetSummary(manifest)) { console.log(`- ${line}`); } + console.log( + `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 1521d6716..6920ac597 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -24,6 +24,7 @@ const optionalRuntimeRequires = new Set([ ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); const handledNativeRuntimeRequires = new Set(['koffi']); +const disabledProductionDynamicImports = new Set(['@fastify/swagger', '@fastify/swagger-ui']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; @@ -64,6 +65,7 @@ for (const line of executableLines()) { errors.push(`relative dynamic import remains: ${specifier}`); continue; } + if (disabledProductionDynamicImports.has(specifier)) continue; if (!isAllowedSpecifier(specifier)) { errors.push(`external dynamic import remains: ${specifier}`); } diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 910738943..30d5e9da3 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,4 +1,5 @@ export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const WEB_ASSET_MANIFEST_VERSION = 1; export function buildManifestKey(target) { return `native/${target}/manifest.json`; @@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) { export function buildAssetKey(target, packageRoot, relativePath) { return `native/${target}/${packageRoot}/${relativePath}`; } + +export function buildWebManifestKey(target) { + return `web/${target}/manifest.json`; +} + +export function buildWebAssetKey(target, relativePath) { + return `web/${target}/dist-web/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs new file mode 100644 index 000000000..8f8a893c5 --- /dev/null +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { + WEB_ASSET_MANIFEST_VERSION, + buildWebAssetKey, + buildWebManifestKey, +} from './manifest.mjs'; + +export { WEB_ASSET_MANIFEST_VERSION }; + +const WEB_ASSETS_DIR = 'dist-web'; + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) { + const requiredPath = join(assetRoot, requiredFile); + try { + const info = await stat(requiredPath); + if (!info.isFile()) { + throw new Error(`${requiredFile} is not a file`); + } + } catch { + throw new Error(message); + } +} + +export function webAssetManifestKey(target) { + return buildWebManifestKey(target); +} + +export function webAssetKey(target, relativePath) { + return buildWebAssetKey(target, relativePath); +} + +async function collectAssetRoot({ + appRoot, + target, + root, + requiredFile, + missingMessage, + assetKey, +}) { + const assetRoot = resolve(appRoot, ...root.split('/')); + await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); + + const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b)); + const manifestFiles = []; + const assets = {}; + + for (const file of files) { + if (!existsSync(file)) continue; + const bytes = await readFile(file); + const relativePath = toPosixPath(relative(assetRoot, file)); + const key = assetKey(target, relativePath); + manifestFiles.push({ + assetKey: key, + relativePath, + sha256: sha256(bytes), + }); + assets[key] = file; + } + + const manifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target, + root, + files: manifestFiles, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} + +export async function collectWebAssets({ appRoot, target }) { + const buildCommand = + 'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: WEB_ASSETS_DIR, + requiredFile: 'index.html', + missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`, + assetKey: webAssetKey, + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts index 6edafd077..27a847e99 100644 --- a/apps/kimi-code/src/cli/sub/server/lifecycle.ts +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -11,6 +11,7 @@ import type { Command } from 'commander'; import { + ServiceUnavailableError, ServiceUnsupportedError, resolveServiceManager, type InstallArgs, @@ -18,12 +19,15 @@ import { type ServiceStatus, } from '@moonshot-ai/server'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + import { DEFAULT_LOG_LEVEL, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, parseLogLevel, parsePort, + serverOrigin, VALID_LOG_LEVELS, } from './shared'; @@ -32,6 +36,7 @@ export interface InstallCliOptions { port?: string; logLevel?: string; force?: boolean; + open?: boolean; json?: boolean; } @@ -41,12 +46,14 @@ export interface JsonCliOptions { export interface LifecycleCommandDeps { resolveManager(): ServiceManager; + openUrl(url: string): void; stdout: Pick; stderr: Pick; } const DEFAULT_DEPS: LifecycleCommandDeps = { resolveManager: resolveServiceManager, + openUrl: defaultOpenUrl, stdout: process.stdout, stderr: process.stderr, }; @@ -64,6 +71,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps DEFAULT_LOG_LEVEL, ) .option('--force', 'Reinstall and overwrite if already installed', false) + .option('--no-open', 'Do not open the web UI after install.', true) .option('--json', 'Output JSON', false) .action(async (opts: InstallCliOptions) => { await runLifecycle(deps, opts.json === true, async (mgr) => { @@ -74,7 +82,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps force: opts.force === true, }; const result = await mgr.install(args); - return { + const status = await readStatus(mgr); + const enriched = withStatusDetails({ ok: true, action: 'install', status: result.status, @@ -82,7 +91,11 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps unitPath: result.unitPath, taskName: result.taskName, message: result.message, - }; + }, status, args); + if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { + deps.openUrl(enriched.url); + } + return enriched; }); }); @@ -104,7 +117,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps .action(async (opts: JsonCliOptions) => { await runLifecycle(deps, opts.json === true, async (mgr) => { const result = await mgr.start(); - return { ok: result.ok, action: 'start', message: result.message }; + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); }); }); @@ -126,7 +140,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps .action(async (opts: JsonCliOptions) => { await runLifecycle(deps, opts.json === true, async (mgr) => { const result = await mgr.restart(); - return { ok: result.ok, action: 'restart', message: result.message }; + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); }); }); @@ -137,7 +152,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps .action(async (opts: JsonCliOptions) => { await runLifecycle(deps, opts.json === true, async (mgr) => { const status: ServiceStatus = await mgr.status(); - return { ok: true, action: 'status', ...status }; + return withStatusDetails({ ok: true, action: 'status', ...status }, status); }); }); } @@ -156,10 +171,10 @@ async function runLifecycle( } deps.stdout.write(formatHuman(result)); } catch (error) { - if (error instanceof ServiceUnsupportedError) { + if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { const payload = { ok: false, - action: 'unsupported', + action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', platform: error.platform, message: error.message, }; @@ -187,5 +202,54 @@ function formatHuman(result: Record): string { const action = typeof rawAction === 'string' ? rawAction : 'action'; const rawMessage = result['message']; const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; - return `${action}${message}\n`; + const lines = [`${action}${message}`]; + + const url = result['url']; + if (typeof url === 'string') lines.push(`URL: ${url}`); + + const running = result['running']; + if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); + + const logPath = result['logPath']; + if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); + + const notes = result['notes']; + if (Array.isArray(notes)) { + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); + } + } + + return `${lines.join('\n')}\n`; +} + +async function readStatus(mgr: ServiceManager): Promise { + try { + return await mgr.status(); + } catch { + return undefined; + } +} + +function withStatusDetails( + result: Record, + status: ServiceStatus | undefined, + fallback?: { host: string; port: number }, +): Record & { url?: string; running?: boolean } { + const host = status?.host ?? fallback?.host; + const port = status?.port ?? fallback?.port; + const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; + return { + ...result, + url, + running: status?.running, + host, + port, + logPath: status?.logPath, + notes: status?.notes, + }; +} + +function formatServiceUrl(host: string, port: number): string { + return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port); } diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 5867759a3..44ce9cfb8 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import { ServerLockedError, startServer } from '@moonshot-ai/server'; +import { getNativeWebAssetsDir } from '#/native/web-assets'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; @@ -141,7 +142,13 @@ export async function startServerForeground( } function serverWebAssetsDir(): string { - return join(getHostPackageRoot(), WEB_ASSETS_DIR); + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } function formatAlreadyRunning(port: number, pid: number): string { diff --git a/apps/kimi-code/src/native/web-assets.ts b/apps/kimi-code/src/native/web-assets.ts new file mode 100644 index 000000000..6fb0845e5 --- /dev/null +++ b/apps/kimi-code/src/native/web-assets.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildWebManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface WebAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface WebAssetManifest { + readonly version: typeof WEB_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly root: 'dist-web'; + readonly files: readonly WebAssetFile[]; +} + +export type WebAssetSource = NativeAssetSource; + +export interface WebAssetOptions { + readonly source?: WebAssetSource | null; + readonly manifest?: WebAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawWebAssetManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 296c7bb07..7af22148e 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -14,6 +14,7 @@ import { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; import { registerServerCommand } from '#/cli/sub/server'; +import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; function makeProgram(): Command { // `commander` exitOverride avoids killing the test runner when --help/error fires. @@ -54,7 +55,7 @@ describe('kimi server', () => { expect(longs).toContain('--open'); }); - it('`server install` exposes --host, --port, --log-level, --force, --json', () => { + it('`server install` exposes --host, --port, --log-level, --force, --no-open, --json', () => { const program = makeProgram(); const install = program.commands .find((c) => c.name() === 'server') @@ -65,6 +66,7 @@ describe('kimi server', () => { expect(longs).toContain('--port'); expect(longs).toContain('--log-level'); expect(longs).toContain('--force'); + expect(longs).toContain('--no-open'); expect(longs).toContain('--json'); }); @@ -95,6 +97,175 @@ describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported }); }); +describe('`kimi server` lifecycle handles unavailable service managers', () => { + it('prints a friendly JSON error and exits 2', async () => { + const { ServiceUnavailableError } = await import('@moonshot-ai/server'); + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => { + throw new ServiceUnavailableError( + 'linux', + 'systemd --user is not available in this environment.', + ); + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ platform: 'linux', installed: false, running: false }), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await expect( + program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), + ).rejects.toThrow('process.exit(2)'); + + exit.mockRestore(); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + action: 'unavailable', + platform: 'linux', + message: expect.stringContaining('server run --host 0.0.0.0'), + }); + }); +}); + +describe('`kimi server` lifecycle output', () => { + it('install passes --force/--port, prints the URL, and opens it when running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + let installArgs: unknown; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async (args) => { + installArgs = args; + return { + status: 'replaced', + message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', + plistPath: '/tmp/kimi.plist', + }; + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: true, + host: '127.0.0.1', + port: 9999, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await program.parseAsync([ + 'node', + 'kimi', + 'server', + 'install', + '--force', + '--port', + '9999', + ]); + + expect(stderr).toBe(''); + expect(installArgs).toMatchObject({ port: 9999, force: true }); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Status: running'); + expect(stdout).toContain('Log: /tmp/server.log'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('start prints URL and diagnostics when launchd did not keep the service running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => ({ status: 'installed', message: 'unused' }), + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: false, + host: '127.0.0.1', + port: 7878, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }); + + await program.parseAsync(['node', 'kimi', 'server', 'start']); + + expect(stdout).toContain('URL: http://127.0.0.1:7878'); + expect(stdout).toContain('Status: not running'); + expect(stdout).toContain('launchd state: spawn scheduled'); + expect(stdout).toContain('last exit code: 78 EX_CONFIG'); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + describe('`kimi server` does not register a legacy `daemon` command', () => { it('hard-deletes the old name', () => { const program = makeProgram(); @@ -120,5 +291,17 @@ describe('shared parsers stay strict', () => { }); }); +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + // Silence vi import for cases where the file is built before tests reference vi. void vi; diff --git a/apps/kimi-code/test/native/web-assets.test.ts b/apps/kimi-code/test/native/web-assets.test.ts new file mode 100644 index 000000000..abe3801fe --- /dev/null +++ b/apps/kimi-code/test/native/web-assets.test.ts @@ -0,0 +1,113 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getNativeWebAssetsDir, + getWebAssetCacheRoot, + WEB_ASSET_MANIFEST_VERSION, + type WebAssetManifest, + type WebAssetSource, +} from '#/native/web-assets'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeWebAssets(files: Record): { + manifest: WebAssetManifest; + source: WebAssetSource; +} { + const manifest: WebAssetManifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `web/test-target/dist-web/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map([ + ['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `web/test-target/dist-web/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +describe('web assets', () => { + it('extracts embedded web assets into a dist-web cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '
\n', + 'assets/app.js': 'console.log("ok");\n', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' })); + expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('
\n'); + expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe( + 'console.log("ok");\n', + ); + expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + writeFileSync(join(webDir ?? '', 'index.html'), 'broken'); + + const repairedDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(webDir); + expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA web asset source is available', () => { + expect(getNativeWebAssetsDir({ source: null })).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/web-assets.test.ts b/apps/kimi-code/test/scripts/native/web-assets.test.ts new file mode 100644 index 000000000..2b5bfa920 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/web-assets.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectWebAssets, + webAssetManifestKey, + WEB_ASSET_MANIFEST_VERSION, +} from '../../../scripts/native/web-assets.mjs'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('collectWebAssets', () => { + it('collects dist-web files into deterministic SEA asset keys', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-')); + try { + mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '
\n'); + writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n'); + + const { manifest, manifestJson, assets } = await collectWebAssets({ + appRoot, + target: 'test-target', + }); + + expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json'); + expect(manifest).toEqual({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: [ + { + assetKey: 'web/test-target/dist-web/assets/app.js', + relativePath: 'assets/app.js', + sha256: sha256('console.log("ok");\n'), + }, + { + assetKey: 'web/test-target/dist-web/index.html', + relativePath: 'index.html', + sha256: sha256('
\n'), + }, + ], + }); + expect(JSON.parse(manifestJson) as unknown).toEqual(manifest); + expect(assets).toEqual({ + 'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'), + 'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'), + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when dist-web has not been built', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-')); + try { + await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow( + /Kimi web build output was not found/, + ); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('keeps manifest JSON parseable and stable', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-')); + try { + mkdirSync(join(appRoot, 'dist-web'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), ''); + + const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' }); + + expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe(''); + expect(manifestJson.endsWith('\n')).toBe(true); + expect(JSON.parse(manifestJson)).toMatchObject({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index bf4e16fe9..ea0a939fb 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -17,10 +17,15 @@ const builtins = new Set([ ...builtinModules.map((name) => `node:${name}`), ]); const optionalNativeDependencies = new Set(['cpu-features']); +const nativeExternalDependencies = new Set([ + ...optionalNativeDependencies, + '@fastify/swagger', + '@fastify/swagger-ui', +]); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; - if (optionalNativeDependencies.has(id)) return false; + if (nativeExternalDependencies.has(id)) return false; return true; } @@ -53,7 +58,7 @@ export default defineConfig({ }, deps: { alwaysBundle: shouldAlwaysBundle, - neverBundle: [...optionalNativeDependencies], + neverBundle: [...nativeExternalDependencies], onlyBundle: false, }, outputOptions: { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index da524c10c..779c968af 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,7 +11,7 @@ export type { export { acquireLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock'; export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock'; -export { resolveServiceManager, ServiceUnsupportedError } from './svc'; +export { resolveServiceManager, ServiceUnavailableError, ServiceUnsupportedError } from './svc'; export type { InstallArgs, InstallResult, diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index e4e49987d..efdf37aca 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -39,10 +39,12 @@ import { } from '@moonshot-ai/services'; import { ErrorCode } from '@moonshot-ai/protocol'; import Fastify from 'fastify'; -import swagger from '@fastify/swagger'; -import swaggerUi from '@fastify/swagger-ui'; import { promises as fspPromises } from 'node:fs'; -import { sep as nodePathSep, relative as nodePathRelativeNative } from 'node:path'; +import { + join as nodePathJoin, + sep as nodePathSep, + relative as nodePathRelativeNative, +} from 'node:path'; import { installErrorHandler } from './error-handler'; import { transformOpenApiDocument } from './openapi/transforms'; @@ -83,6 +85,10 @@ export interface ServerStartOptions { webAssetsDir?: string; + swagger?: boolean; + + swaggerUiAssetsDir?: string; + serviceOverrides?: ReadonlyArray, unknown]>; } @@ -123,39 +129,69 @@ export async function startServer(opts: ServerStartOptions): Promise { + const { default: swagger } = await import('@fastify/swagger'); + await app.register(swagger, { + openapi: { + info: { + title: 'Kimi Code Server API', + description: + 'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.', + version: serverVersion, + }, + tags: [ + { name: 'meta', description: 'Server metadata' }, + { name: 'auth', description: 'Auth readiness & login state' }, + { name: 'models', description: 'Configured model aliases' }, + { name: 'providers', description: 'Configured providers' }, + { name: 'sessions', description: 'Session lifecycle' }, + { name: 'workspaces', description: 'Workspace registry + folder picker' }, + { name: 'messages', description: 'Message history' }, + { name: 'prompts', description: 'Prompt submission & abort' }, + { name: 'approvals', description: 'Approval resolution' }, + { name: 'questions', description: 'Question resolution & dismiss' }, + { name: 'tools', description: 'Tool & MCP server management' }, + { name: 'tasks', description: 'Background tasks' }, + { name: 'terminals', description: 'PTY terminal sessions' }, + { name: 'fs', description: 'Filesystem operations' }, + { name: 'files', description: 'File upload & download' }, + ], }, - tags: [ - { name: 'meta', description: 'Server metadata' }, - { name: 'auth', description: 'Auth readiness & login state' }, - { name: 'models', description: 'Configured model aliases' }, - { name: 'providers', description: 'Configured providers' }, - { name: 'sessions', description: 'Session lifecycle' }, - { name: 'workspaces', description: 'Workspace registry + folder picker' }, - { name: 'messages', description: 'Message history' }, - { name: 'prompts', description: 'Prompt submission & abort' }, - { name: 'approvals', description: 'Approval resolution' }, - { name: 'questions', description: 'Question resolution & dismiss' }, - { name: 'tools', description: 'Tool & MCP server management' }, - { name: 'tasks', description: 'Background tasks' }, - { name: 'terminals', description: 'PTY terminal sessions' }, - { name: 'fs', description: 'Filesystem operations' }, - { name: 'files', description: 'File upload & download' }, - ], - }, - transformObject: (documentObject) => { - if (!('openapiObject' in documentObject)) { - return documentObject.swaggerObject; - } - return transformOpenApiDocument(documentObject.openapiObject as Record); - }, - }); + transformObject: (documentObject) => { + if (!('openapiObject' in documentObject)) { + return documentObject.swaggerObject; + } + return transformOpenApiDocument(documentObject.openapiObject as Record); + }, + }); + } + + async function registerSwaggerUi(assetsDir: string | undefined): Promise { + const { default: swaggerUi } = await import('@fastify/swagger-ui'); + const logo = + assetsDir === undefined + ? undefined + : { + type: 'image/svg+xml', + content: await fspPromises.readFile(nodePathJoin(assetsDir, 'logo.svg')), + }; + + await app.register(swaggerUi, { + routePrefix: '/documentation', + baseDir: assetsDir, + logo, + uiConfig: { + docExpansion: 'list', + deepLinking: true, + }, + }); + } + + if (swaggerEnabled) { + await registerSwagger(); + } const envService: IEnvironmentService = { _serviceBrand: undefined, @@ -179,13 +215,9 @@ export async function startServer(opts: ServerStartOptions): Promise execFileUtf8('launchctl', args, options), - resolveProgram: () => process.argv[1] ?? 'kimi', + resolveProgram: () => resolveSupervisorProgram(), plistPath: defaultLaunchAgentPlistPath, logPath: defaultSupervisorLogPath, guiDomain: () => defaultGuiDomain(), }; +export { resolveSupervisorProgram }; + /** Construct the launchd backend. Pass `deps` overrides in tests. */ export function createLaunchdManager( overrides: Partial = {}, @@ -217,14 +220,18 @@ export function createLaunchdManager( }; } const info = parseLaunchctlPrint(print.stdout); + const notes = + info.state !== undefined + ? [`launchd state: ${info.state}`] + : ['launchd state: unknown']; + if (info.lastExitCode !== undefined) { + notes.push(`last exit code: ${info.lastExitCode}`); + } return { ...base, running: info.state === 'running' || info.pid !== undefined, ...(info.pid !== undefined ? { pid: info.pid } : {}), - notes: - info.state !== undefined - ? [`launchd state: ${info.state}`] - : ['launchd state: unknown'], + notes, }; } @@ -232,8 +239,12 @@ export function createLaunchdManager( } /** Pure parser for `launchctl print /