mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix(agent-core-v2): restore glob rg bootstrap
This commit is contained in:
parent
69df799810
commit
9dd8ab35a0
6 changed files with 739 additions and 62 deletions
|
|
@ -80,6 +80,7 @@
|
|||
"retry": "0.13.1",
|
||||
"smol-toml": "^1.6.1",
|
||||
"socks": "^2.8.9",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^3.0.1",
|
||||
"undici": "^7.27.1",
|
||||
"yauzl": "^3.3.0",
|
||||
|
|
@ -95,6 +96,7 @@
|
|||
"@types/react-dom": "^19.1.2",
|
||||
"@types/retry": "0.12.0",
|
||||
"@types/sinon": "^21.0.1",
|
||||
"@types/tar": "^7.0.87",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@types/yazl": "^2.4.6",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
Find files by glob pattern, sorted by modification time (most recent first).
|
||||
|
||||
Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out.
|
||||
Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out. Matches are files only — directories themselves are never listed; to find a directory, glob for a file inside it (e.g. `**/fixtures/**`).
|
||||
|
||||
Good patterns:
|
||||
- `*.ts` — all files matching an extension, at any depth below the search root (a bare pattern without `/` matches recursively)
|
||||
|
|
|
|||
|
|
@ -82,13 +82,13 @@ export const GlobInputSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Absolute path to the directory to search in. Defaults to the current working directory.',
|
||||
'Directory to search. Accepts an absolute path, or a path relative to the current working directory. Defaults to the current working directory.',
|
||||
),
|
||||
include_ignored: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. Defaults to false.',
|
||||
'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. VCS metadata directories (`.git` and similar) are always skipped, even when this is true. Defaults to false.',
|
||||
),
|
||||
include_dirs: z
|
||||
.boolean()
|
||||
|
|
|
|||
|
|
@ -1,61 +1,62 @@
|
|||
/**
|
||||
* `fileTools` domain — shared ripgrep (`rg`) binary locator.
|
||||
*
|
||||
* Single place that decides which `rg` the Glob and Grep paths run. The lookup
|
||||
* mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation)
|
||||
* but is driven through a caller-supplied {@link RgProbe} so it works against
|
||||
* whatever execution environment the caller has — Glob/Grep probe through the
|
||||
* host `IHostProcessService`. Both run `rg --version` and treat exit code 0 as
|
||||
* "available".
|
||||
*
|
||||
* Lookup order (first hit wins):
|
||||
* 1. System `rg` on the execution-environment PATH (`rg --version`).
|
||||
* 2. Persistent cache at `<KIMI_CODE_HOME|~/.kimi-code>/bin/rg` — where a
|
||||
* previously bootstrapped or manually dropped static binary lives. Only
|
||||
* attempted when `allowCachedFallback` is set (Glob); Grep keeps its own
|
||||
* pure-node fallback and opts out so its "rg missing → node fallback"
|
||||
* path stays deterministic.
|
||||
*
|
||||
* If nothing resolves, {@link ensureRgPath} throws and callers surface
|
||||
* {@link rgUnavailableMessage} instead of a naked `spawn rg ENOENT`.
|
||||
*
|
||||
* Ported from `session/sessionFs/rgLocator` onto the os tools: the probe is now
|
||||
* backed by `IHostProcessService` instead of the session process runner.
|
||||
* Resolves the `rg` command used by Glob and Grep through a caller-supplied
|
||||
* process probe, preferring the execution-environment PATH, then the vendor
|
||||
* hook, then the app cache, and finally bootstrapping a pinned ripgrep
|
||||
* archive into `<KIMI_CODE_HOME|~/.kimi-code>/bin` when the caller permits it.
|
||||
* Keeps callers that own a no-download fallback path opt-in-compatible.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream, existsSync } from 'node:fs';
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
/** Where the resolved `rg` came from. Used for fallback telemetry. */
|
||||
export type RgResolutionSource = 'system-path' | 'share-bin-cached';
|
||||
import { extract as extractTar } from 'tar';
|
||||
import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl';
|
||||
import { basename, join } from 'pathe';
|
||||
|
||||
import { abortable } from '#/_base/utils/abort';
|
||||
|
||||
const RG_VERSION = '15.0.0';
|
||||
const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg';
|
||||
const DOWNLOAD_TIMEOUT_MS = 600_000;
|
||||
const RG_ARCHIVE_SHA256: Record<string, string> = {
|
||||
'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz':
|
||||
'98bb2e61e7277ba0ea72d2ae2592497fd8d2940934a16b122448d302a6637e3b',
|
||||
'ripgrep-15.0.0-aarch64-pc-windows-msvc.zip':
|
||||
'572709c8770cb7f9385d725cb06d2bcd9537ec24d4dd17b1be1d65a876f8b591',
|
||||
'ripgrep-15.0.0-aarch64-unknown-linux-gnu.tar.gz':
|
||||
'15f8cc2fab12d88491c54d49f38589922a9d6a7353c29b0a0856727bcdf80754',
|
||||
'ripgrep-15.0.0-x86_64-apple-darwin.tar.gz':
|
||||
'44128c733d127ddbda461e01225a68b5f9997cfe7635242a797f645ca674a71a',
|
||||
'ripgrep-15.0.0-x86_64-pc-windows-msvc.zip':
|
||||
'21a98bf42c4da97ca543c010e764cc6dec8b9b7538d05f8d21874016385e0860',
|
||||
'ripgrep-15.0.0-x86_64-unknown-linux-musl.tar.gz':
|
||||
'253ad0fd5fef0d64cba56c70dccdacc1916d4ed70ad057cc525fcdb0c3bbd2a7',
|
||||
};
|
||||
|
||||
export type RgResolutionSource =
|
||||
| 'system-path'
|
||||
| 'vendor'
|
||||
| 'share-bin-cached'
|
||||
| 'share-bin-downloaded';
|
||||
|
||||
export interface RgResolution {
|
||||
/** Command or absolute path to pass as argv[0] when spawning `rg`. */
|
||||
readonly path: string;
|
||||
readonly source: RgResolutionSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal probe surface the locator runs against. Lets the same locator run
|
||||
* over Glob's and Grep's `IHostProcessService` without depending on it
|
||||
* directly.
|
||||
*/
|
||||
export interface RgProbe {
|
||||
/** Run `argv` and resolve with the process exit code. */
|
||||
exec(args: readonly string[]): Promise<{ readonly exitCode: number }>;
|
||||
}
|
||||
|
||||
export interface EnsureRgPathOptions {
|
||||
/**
|
||||
* Cancels this caller's wait. Checked between probe steps; an aborted signal
|
||||
* makes {@link ensureRgPath} throw an `AbortError`.
|
||||
*/
|
||||
readonly signal?: AbortSignal;
|
||||
/**
|
||||
* When true, fall back to the cached binary at `<share>/bin/rg` if `rg` is
|
||||
* not on PATH. Defaults to false so callers with their own fallback (Grep's
|
||||
* node walker) keep deterministic behavior.
|
||||
*/
|
||||
readonly shareDir?: string | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly allowCachedFallback?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +70,6 @@ function getShareDir(): string {
|
|||
return join(homedir(), '.kimi-code');
|
||||
}
|
||||
|
||||
/** Absolute path of the cached `rg` binary, if one has been installed. */
|
||||
export function getShareBinRgPath(): string {
|
||||
return join(getShareDir(), 'bin', rgBinaryName());
|
||||
}
|
||||
|
|
@ -80,44 +80,245 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable `rg`. Probes `rg --version` through `probe`; on a non-zero
|
||||
* exit (and only when `allowCachedFallback` is set) tries the cached binary
|
||||
* before giving up. Throws when no working `rg` can be found.
|
||||
*/
|
||||
export async function ensureRgPath(
|
||||
probe: RgProbe,
|
||||
options: EnsureRgPathOptions = {},
|
||||
): Promise<RgResolution> {
|
||||
throwIfAborted(options.signal);
|
||||
const shareDir = options.shareDir ?? getShareDir();
|
||||
const resolution = resolveRgPath(probe, shareDir, options);
|
||||
return options.signal === undefined ? resolution : abortable(resolution, options.signal);
|
||||
}
|
||||
|
||||
async function resolveRgPath(
|
||||
probe: RgProbe,
|
||||
shareDir: string,
|
||||
options: EnsureRgPathOptions,
|
||||
): Promise<RgResolution> {
|
||||
const existing = await findExistingRg(probe, shareDir, options.allowCachedFallback === true);
|
||||
if (existing) return existing;
|
||||
throwIfAborted(options.signal);
|
||||
if (options.allowCachedFallback === true) {
|
||||
return downloadRgWithLock(probe, shareDir);
|
||||
}
|
||||
throw new Error('ripgrep (rg) is not available on PATH');
|
||||
}
|
||||
|
||||
export async function findExistingRg(
|
||||
probe: RgProbe,
|
||||
shareDir: string = getShareDir(),
|
||||
allowCachedFallback = true,
|
||||
): Promise<RgResolution | undefined> {
|
||||
const system = await probe.exec(['rg', '--version']).catch(() => ({ exitCode: -1 }));
|
||||
if (system.exitCode === 0) {
|
||||
return { path: 'rg', source: 'system-path' };
|
||||
}
|
||||
|
||||
if (options.allowCachedFallback === true) {
|
||||
throwIfAborted(options.signal);
|
||||
const cached = getShareBinRgPath();
|
||||
const cachedRun = await probe.exec([cached, '--version']).catch(() => ({ exitCode: -1 }));
|
||||
if (cachedRun.exitCode === 0) {
|
||||
return { path: cached, source: 'share-bin-cached' };
|
||||
if (allowCachedFallback) {
|
||||
const vendorPath = getVendorRgPath(rgBinaryName());
|
||||
if (vendorPath !== undefined && (await isUsableRg(probe, vendorPath))) {
|
||||
return { path: vendorPath, source: 'vendor' };
|
||||
}
|
||||
const cachePath = join(shareDir, 'bin', rgBinaryName());
|
||||
if (await isUsableRg(probe, cachePath)) {
|
||||
return { path: cachePath, source: 'share-bin-cached' };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('ripgrep (rg) is not available on PATH');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let downloadPromise: Promise<RgResolution> | undefined;
|
||||
async function downloadRgWithLock(probe: RgProbe, shareDir: string): Promise<RgResolution> {
|
||||
if (downloadPromise !== undefined) return downloadPromise;
|
||||
downloadPromise = (async () => {
|
||||
try {
|
||||
const existing = await findExistingRg(probe, shareDir, true);
|
||||
if (existing) return existing;
|
||||
const binPath = await downloadAndInstallRg(shareDir);
|
||||
return { path: binPath, source: 'share-bin-downloaded' };
|
||||
} finally {
|
||||
downloadPromise = undefined;
|
||||
}
|
||||
})();
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
function getVendorRgPath(_binName: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function isUsableRg(probe: RgProbe, path: string): Promise<boolean> {
|
||||
const run = await probe.exec([path, '--version']).catch(() => ({ exitCode: -1 }));
|
||||
return run.exitCode === 0;
|
||||
}
|
||||
|
||||
export function detectTarget(): string | undefined {
|
||||
const arch = process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? 'aarch64' : undefined;
|
||||
if (arch === undefined) return undefined;
|
||||
|
||||
if (process.platform === 'darwin') return `${arch}-apple-darwin`;
|
||||
if (process.platform === 'linux') {
|
||||
return arch === 'x86_64' ? 'x86_64-unknown-linux-musl' : 'aarch64-unknown-linux-gnu';
|
||||
}
|
||||
if (process.platform === 'win32') return `${arch}-pc-windows-msvc`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function downloadAndInstallRg(shareDir: string): Promise<string> {
|
||||
const target = detectTarget();
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
`Unsupported platform/arch for ripgrep download: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = target.includes('windows');
|
||||
const archiveExt = isWindows ? 'zip' : 'tar.gz';
|
||||
const archiveName = `ripgrep-${RG_VERSION}-${target}.${archiveExt}`;
|
||||
const expectedSha256 = RG_ARCHIVE_SHA256[archiveName];
|
||||
if (expectedSha256 === undefined) {
|
||||
throw new Error(`No pinned SHA-256 is configured for ripgrep archive ${archiveName}`);
|
||||
}
|
||||
const url = `${RG_BASE_URL}/${archiveName}`;
|
||||
|
||||
const binDir = join(shareDir, 'bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const destination = join(binDir, rgBinaryName());
|
||||
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'kimi-rg-'));
|
||||
try {
|
||||
const archivePath = join(tmp, archiveName);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, DOWNLOAD_TIMEOUT_MS);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (!resp.ok || resp.body === null) {
|
||||
throw new Error(`Failed to download ripgrep: HTTP ${String(resp.status)} ${resp.statusText}`);
|
||||
}
|
||||
const write = createWriteStream(archivePath);
|
||||
await pipeline(Readable.fromWeb(resp.body as never), write);
|
||||
await verifyArchiveChecksum(archivePath, archiveName, expectedSha256);
|
||||
|
||||
if (isWindows) {
|
||||
await extractRgFromZip(archivePath, destination);
|
||||
} else {
|
||||
const extractDir = join(tmp, 'extract');
|
||||
await mkdir(extractDir, { recursive: true });
|
||||
await extractTar({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
gzip: true,
|
||||
filter: (entryPath: string) => entryPath.endsWith(`/${rgBinaryName()}`),
|
||||
});
|
||||
const extracted = join(extractDir, `ripgrep-${RG_VERSION}-${target}`, rgBinaryName());
|
||||
if (!existsSync(extracted)) {
|
||||
throw new Error(
|
||||
`Ripgrep archive did not contain expected binary at ${extracted}. ` +
|
||||
'CDN content may have changed.',
|
||||
);
|
||||
}
|
||||
const installDir = await mkdtemp(join(binDir, '.rg-install-'));
|
||||
const staged = join(installDir, rgBinaryName());
|
||||
try {
|
||||
await copyFile(extracted, staged);
|
||||
await chmod(staged, 0o755);
|
||||
await rename(staged, destination);
|
||||
} finally {
|
||||
await rm(installDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
return destination;
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyArchiveChecksum(
|
||||
archivePath: string,
|
||||
archiveName: string,
|
||||
expectedSha256: string,
|
||||
): Promise<void> {
|
||||
const actualSha256 = createHash('sha256')
|
||||
.update(await readFile(archivePath))
|
||||
.digest('hex');
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new Error(
|
||||
`Ripgrep archive checksum mismatch for ${archiveName}: expected ${expectedSha256}, ` +
|
||||
`got ${actualSha256}. CDN content may have changed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractRgFromZip(archivePath: string, destination: string): Promise<void> {
|
||||
const buf = await readFile(archivePath);
|
||||
const binName = rgBinaryName();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
yauzlFromBuffer(buf, { lazyEntries: true }, (openErr, zipfile) => {
|
||||
if (openErr !== null || zipfile === undefined) {
|
||||
reject(new Error(`Failed to open ripgrep archive: ${openErr?.message ?? 'unknown error'}`));
|
||||
return;
|
||||
}
|
||||
let found = false;
|
||||
const onEntry = (entry: Entry): void => {
|
||||
if (basename(entry.fileName) !== binName) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
found = true;
|
||||
zipfile.openReadStream(entry, (streamErr, stream) => {
|
||||
if (streamErr !== null) {
|
||||
reject(
|
||||
new Error(`Failed to read ${entry.fileName} from archive: ${streamErr.message}`),
|
||||
);
|
||||
zipfile.close();
|
||||
return;
|
||||
}
|
||||
const out = createWriteStream(destination);
|
||||
void (async () => {
|
||||
try {
|
||||
await pipeline(stream, out);
|
||||
zipfile.close();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
zipfile.close();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
})();
|
||||
});
|
||||
};
|
||||
zipfile.on('entry', onEntry);
|
||||
zipfile.on('end', () => {
|
||||
if (!found) {
|
||||
reject(
|
||||
new Error(
|
||||
`Ripgrep archive did not contain expected binary '${binName}'. ` +
|
||||
'CDN content may have changed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
zipfile.on('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
zipfile.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing message when {@link ensureRgPath} throws. Kept in one place so
|
||||
* the Glob / Grep plumbing surfaces the same actionable hint.
|
||||
*/
|
||||
export function rgUnavailableMessage(cause: unknown): string {
|
||||
const detail =
|
||||
cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : 'unknown error';
|
||||
const shareBin = getShareBinRgPath();
|
||||
return (
|
||||
`ripgrep (rg) is not available.\n` +
|
||||
`ripgrep (rg) is not available and the automatic bootstrap failed.\n` +
|
||||
`\n` +
|
||||
`Error: ${detail}\n` +
|
||||
`\n` +
|
||||
|
|
|
|||
468
packages/agent-core-v2/test/fileTools/rgLocator.test.ts
Normal file
468
packages/agent-core-v2/test/fileTools/rgLocator.test.ts
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
/**
|
||||
* Covers: rg-locator (ripgrep hybrid binary resolution).
|
||||
*
|
||||
* Pure-lookup pins (no real CDN download):
|
||||
* - `findExistingRg` returns undefined when PATH + share-bin are both empty
|
||||
* - Resolves from `<shareDir>/bin/rg` when that binary exists
|
||||
* - Prefers system PATH over share-dir cache when both are available
|
||||
* - `rgUnavailableMessage` surfaces the underlying cause + install hints
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { extract as extractTar } from 'tar';
|
||||
import { ZipFile } from 'yazl';
|
||||
import { join } from 'pathe';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
detectTarget,
|
||||
ensureRgPath,
|
||||
extractRgFromZip,
|
||||
findExistingRg,
|
||||
rgUnavailableMessage,
|
||||
verifyArchiveChecksum,
|
||||
type RgProbe,
|
||||
} from '#/os/backends/node-local/tools/rgLocator';
|
||||
|
||||
vi.mock('tar', () => ({ extract: vi.fn() }));
|
||||
|
||||
function probeWith(
|
||||
resolveExitCode: (args: readonly string[]) => number,
|
||||
): RgProbe & { exec: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
exec: vi.fn(async (args: readonly string[]) => ({ exitCode: resolveExitCode(args) })),
|
||||
};
|
||||
}
|
||||
|
||||
function noRgProbe(): RgProbe & { exec: ReturnType<typeof vi.fn> } {
|
||||
return probeWith(() => -1);
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('findExistingRg', () => {
|
||||
let fakeShare: string;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(tmpdir(), `kimi-rg-${String(Date.now())}-${String(Math.random()).slice(2)}`);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeShare, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns undefined when no rg anywhere', async () => {
|
||||
const result = await findExistingRg(noRgProbe(), fakeShare);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves from share-dir when cached', async () => {
|
||||
const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg');
|
||||
const probe = probeWith((args) => (args[0] === cached ? 0 : -1));
|
||||
const result = await findExistingRg(probe, fakeShare);
|
||||
|
||||
expect(result).toEqual({ path: cached, source: 'share-bin-cached' });
|
||||
});
|
||||
|
||||
it('prefers system PATH over share-dir when both are available', async () => {
|
||||
const probe = probeWith((args) => (args[0] === 'rg' ? 0 : -1));
|
||||
const result = await findExistingRg(probe, fakeShare);
|
||||
|
||||
expect(result).toEqual({ path: 'rg', source: 'system-path' });
|
||||
expect(probe.exec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectTarget', () => {
|
||||
let savedArch: string;
|
||||
let savedPlatform: string;
|
||||
beforeEach(() => {
|
||||
savedArch = process.arch;
|
||||
savedPlatform = process.platform;
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'arch', { value: savedArch });
|
||||
Object.defineProperty(process, 'platform', { value: savedPlatform });
|
||||
});
|
||||
|
||||
function setPlatform(arch: string, platform: string): void {
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
}
|
||||
|
||||
it('darwin arm64 -> aarch64-apple-darwin', () => {
|
||||
setPlatform('arm64', 'darwin');
|
||||
expect(detectTarget()).toBe('aarch64-apple-darwin');
|
||||
});
|
||||
it('darwin x64 -> x86_64-apple-darwin', () => {
|
||||
setPlatform('x64', 'darwin');
|
||||
expect(detectTarget()).toBe('x86_64-apple-darwin');
|
||||
});
|
||||
it('linux x64 -> x86_64-unknown-linux-musl', () => {
|
||||
setPlatform('x64', 'linux');
|
||||
expect(detectTarget()).toBe('x86_64-unknown-linux-musl');
|
||||
});
|
||||
it('linux arm64 -> aarch64-unknown-linux-gnu', () => {
|
||||
setPlatform('arm64', 'linux');
|
||||
expect(detectTarget()).toBe('aarch64-unknown-linux-gnu');
|
||||
});
|
||||
it('win32 x64 -> x86_64-pc-windows-msvc', () => {
|
||||
setPlatform('x64', 'win32');
|
||||
expect(detectTarget()).toBe('x86_64-pc-windows-msvc');
|
||||
});
|
||||
it('unsupported arch -> undefined', () => {
|
||||
setPlatform('mips', 'linux');
|
||||
expect(detectTarget()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgUnavailableMessage', () => {
|
||||
it('surfaces the underlying cause and install hints', () => {
|
||||
const msg = rgUnavailableMessage(new Error('fetch failed'));
|
||||
expect(msg).toContain('automatic bootstrap failed');
|
||||
expect(msg).toContain('fetch failed');
|
||||
expect(msg).toContain('brew install ripgrep');
|
||||
expect(msg).toContain('https://github.com/BurntSushi/ripgrep');
|
||||
});
|
||||
|
||||
it('handles non-Error causes (string, unknown)', () => {
|
||||
const a = rgUnavailableMessage('boom');
|
||||
expect(a).toContain('boom');
|
||||
const b = rgUnavailableMessage(42);
|
||||
expect(b).toContain('unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyArchiveChecksum', () => {
|
||||
let fakeDir: string;
|
||||
beforeEach(() => {
|
||||
fakeDir = join(tmpdir(), `kimi-rg-sha-${String(Date.now())}-${String(Math.random()).slice(2)}`);
|
||||
mkdirSync(fakeDir, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a file whose SHA-256 matches the expected digest', async () => {
|
||||
const archivePath = join(fakeDir, 'archive.tar.gz');
|
||||
const payload = Buffer.from('trusted archive bytes', 'utf8');
|
||||
writeFileSync(archivePath, payload);
|
||||
const expectedSha256 = createHash('sha256').update(payload).digest('hex');
|
||||
|
||||
await expect(
|
||||
verifyArchiveChecksum(archivePath, 'archive.tar.gz', expectedSha256),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a file whose SHA-256 differs from the expected digest', async () => {
|
||||
const archivePath = join(fakeDir, 'archive.tar.gz');
|
||||
writeFileSync(archivePath, 'tampered archive bytes');
|
||||
|
||||
await expect(
|
||||
verifyArchiveChecksum(archivePath, 'archive.tar.gz', '0'.repeat(64)),
|
||||
).rejects.toThrow(/checksum mismatch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRgPath download branch', () => {
|
||||
let fakeShare: string;
|
||||
let savedFetch: typeof globalThis.fetch | undefined;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(
|
||||
tmpdir(),
|
||||
`kimi-rg-dl-${String(Date.now())}-${String(Math.random()).slice(2)}`,
|
||||
);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
savedFetch = globalThis.fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeShare, { recursive: true, force: true });
|
||||
if (savedFetch === undefined) {
|
||||
delete (globalThis as unknown as { fetch?: typeof fetch }).fetch;
|
||||
} else {
|
||||
globalThis.fetch = savedFetch;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not bootstrap when allowCachedFallback is false', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(ensureRgPath(noRgProbe(), { shareDir: fakeShare })).rejects.toThrow(/on PATH/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a network error when fetch rejects', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network unreachable')) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/network unreachable/);
|
||||
});
|
||||
|
||||
it('does not start bootstrap work when the caller is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), {
|
||||
shareDir: fakeShare,
|
||||
signal: controller.signal,
|
||||
allowCachedFallback: true,
|
||||
}),
|
||||
).rejects.toHaveProperty('name', 'AbortError');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start bootstrap work when aborted after lookup misses', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const cacheProbe = deferred<{ readonly exitCode: number }>();
|
||||
const probe: RgProbe & { exec: ReturnType<typeof vi.fn> } = {
|
||||
exec: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ exitCode: -1 })
|
||||
.mockReturnValueOnce(cacheProbe.promise),
|
||||
};
|
||||
|
||||
const resultPromise = ensureRgPath(probe, {
|
||||
shareDir: fakeShare,
|
||||
signal: controller.signal,
|
||||
allowCachedFallback: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probe.exec).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
controller.abort();
|
||||
await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError');
|
||||
|
||||
cacheProbe.resolve({ exitCode: -1 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the current caller wait while shared bootstrap work continues', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchResponse = deferred<{
|
||||
readonly ok: false;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly body: null;
|
||||
}>();
|
||||
globalThis.fetch = vi.fn(() => fetchResponse.promise) as unknown as typeof fetch;
|
||||
|
||||
const resultPromise = ensureRgPath(noRgProbe(), {
|
||||
shareDir: fakeShare,
|
||||
signal: controller.signal,
|
||||
allowCachedFallback: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
controller.abort();
|
||||
await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError');
|
||||
|
||||
fetchResponse.resolve({ ok: false, status: 499, statusText: 'Client Closed', body: null });
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/HTTP 499 Client Closed/);
|
||||
});
|
||||
|
||||
it('surfaces HTTP failure (non-2xx response) with status + statusText', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
body: null,
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/HTTP 404 Not Found/);
|
||||
});
|
||||
|
||||
it('fetches ripgrep over HTTPS', async () => {
|
||||
const body = bodyFromBuffer(Buffer.from('not a real archive', 'utf8'));
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
body,
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(new URL(url).protocol).toBe('https:');
|
||||
});
|
||||
|
||||
it('rejects archives that do not match the pinned SHA-256 before extraction', async () => {
|
||||
const tarMock = vi.mocked(extractTar);
|
||||
tarMock.mockClear();
|
||||
const body = bodyFromBuffer(Buffer.from('tampered archive', 'utf8'));
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
body,
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/checksum/i);
|
||||
|
||||
expect(tarMock).not.toHaveBeenCalled();
|
||||
expect(existsSync(join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildFixtureZip(entries: Array<{ name: string; content: Buffer }>): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const zip = new ZipFile();
|
||||
for (const { name, content } of entries) {
|
||||
zip.addBuffer(content, name);
|
||||
}
|
||||
zip.end();
|
||||
const chunks: Buffer[] = [];
|
||||
zip.outputStream.on('data', (c: Buffer) => {
|
||||
chunks.push(c);
|
||||
});
|
||||
zip.outputStream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
zip.outputStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function bodyFromBuffer(buf: Buffer): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(buf));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ensureRgPath Windows download branch', () => {
|
||||
let fakeShare: string;
|
||||
let savedFetch: typeof globalThis.fetch | undefined;
|
||||
let savedArch: string;
|
||||
let savedPlatform: string;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(
|
||||
tmpdir(),
|
||||
`kimi-rg-win-${String(Date.now())}-${String(Math.random()).slice(2)}`,
|
||||
);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
savedFetch = globalThis.fetch;
|
||||
savedArch = process.arch;
|
||||
savedPlatform = process.platform;
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeShare, { recursive: true, force: true });
|
||||
if (savedFetch === undefined) {
|
||||
delete (globalThis as unknown as { fetch?: typeof fetch }).fetch;
|
||||
} else {
|
||||
globalThis.fetch = savedFetch;
|
||||
}
|
||||
Object.defineProperty(process, 'arch', { value: savedArch });
|
||||
Object.defineProperty(process, 'platform', { value: savedPlatform });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches the .zip URL (not .tar.gz) on Windows target', async () => {
|
||||
const zipBuf = await buildFixtureZip([
|
||||
{
|
||||
name: 'ripgrep-15.0.0-x86_64-pc-windows-msvc/rg.exe',
|
||||
content: Buffer.from('MZfake-pe-bytes', 'utf8'),
|
||||
},
|
||||
]);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
body: bodyFromBuffer(zipBuf),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/checksum mismatch/);
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toMatch(/ripgrep-15\.0\.0-x86_64-pc-windows-msvc\.zip$/);
|
||||
});
|
||||
|
||||
it('extracts rg.exe into <shareDir>/bin/rg.exe', async () => {
|
||||
const payload = Buffer.from('MZfake-pe-bytes-extracted', 'utf8');
|
||||
const zipBuf = await buildFixtureZip([
|
||||
{
|
||||
name: 'ripgrep-15.0.0-x86_64-pc-windows-msvc/rg.exe',
|
||||
content: payload,
|
||||
},
|
||||
]);
|
||||
|
||||
const archivePath = join(fakeShare, 'fixture.zip');
|
||||
const installed = join(fakeShare, 'bin', 'rg.exe');
|
||||
writeFileSync(archivePath, zipBuf);
|
||||
|
||||
await extractRgFromZip(archivePath, installed);
|
||||
|
||||
expect(existsSync(installed)).toBe(true);
|
||||
expect(readFileSync(installed)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('throws with "CDN content may have changed" when the zip omits rg.exe', async () => {
|
||||
const zipBuf = await buildFixtureZip([{ name: 'README.md', content: Buffer.from('readme') }]);
|
||||
const archivePath = join(fakeShare, 'fixture.zip');
|
||||
const installed = join(fakeShare, 'bin', 'rg.exe');
|
||||
writeFileSync(archivePath, zipBuf);
|
||||
|
||||
await expect(extractRgFromZip(archivePath, installed)).rejects.toThrow(
|
||||
/CDN content may have changed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces HTTP failure on Windows with status + statusText', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
body: null,
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
ensureRgPath(noRgProbe(), { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/HTTP 502 Bad Gateway/);
|
||||
});
|
||||
});
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -547,6 +547,9 @@ importers:
|
|||
socks:
|
||||
specifier: ^2.8.9
|
||||
version: 2.8.9
|
||||
tar:
|
||||
specifier: ^7.5.13
|
||||
version: 7.5.13
|
||||
ulid:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.2
|
||||
|
|
@ -587,6 +590,9 @@ importers:
|
|||
'@types/sinon':
|
||||
specifier: ^21.0.1
|
||||
version: 21.0.1
|
||||
'@types/tar':
|
||||
specifier: ^7.0.87
|
||||
version: 7.0.87
|
||||
'@types/yauzl':
|
||||
specifier: ^2.10.3
|
||||
version: 2.10.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue