diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 72288dffb..ae6b3fe18 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -5,7 +5,7 @@ description: Use when generating changesets in the kimi-code repository — deci # Generate Changesets -The only user-facing published package is the CLI: `@moonshot-ai/kimi-code`. All other `@moonshot-ai/*` packages (sdk, kosong, kaos, oauth, telemetry, and so on) are internal. +The only user-facing published package is the CLI: `@moonshot-ai/kimi-code`. All other `@moonshot-ai/*` packages (sdk, kosong, oauth, telemetry, and so on) are internal. ## 1. Whether to Write diff --git a/.changeset/README.md b/.changeset/README.md index 38e368a52..ad5df6fd4 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -15,7 +15,6 @@ Current publishable packages: All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`: -- `@moonshot-ai/kaos` - `@moonshot-ai/kimi-code-oauth` - `@moonshot-ai/kimi-telemetry` - `@moonshot-ai/kosong` diff --git a/.changeset/remove-kaos.md b/.changeset/remove-kaos.md new file mode 100644 index 000000000..98d7d93af --- /dev/null +++ b/.changeset/remove-kaos.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Remove the `kaos` / `persistenceKaos` session options and the `createSessionWithKaos` / `resumeSessionWithKaos` client methods, which were already ignored at runtime. diff --git a/.changeset/remove-mcp-kaos-executor.md b/.changeset/remove-mcp-kaos-executor.md new file mode 100644 index 000000000..36bc86460 --- /dev/null +++ b/.changeset/remove-mcp-kaos-executor.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the never-implemented `executor = "kaos"` option from MCP stdio server configuration; configs using it now fail validation at load time. diff --git a/AGENTS.md b/AGENTS.md index ed8a9d035..26e7891ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. -- `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5e50c778..ca79376b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ This is a pnpm monorepo. The most relevant entry points are: - `apps/vis` — session debug visualizer - `packages/node-sdk` — public TypeScript SDK (`@moonshot-ai/kimi-code-sdk`) - `packages/agent-core-v2` — the agent engine (v2, DI Scope architecture); `packages/agent-core` is v1 and being phased out -- `packages/klient`, `kap-server`, `protocol`, `transcript`, `kosong`, `kaos`, `oauth`, `telemetry` — internal engine packages +- `packages/klient`, `kap-server`, `protocol`, `transcript`, `kosong`, `oauth`, `telemetry` — internal engine packages - `docs/` — VitePress bilingual docs site For the full project map, see [AGENTS.md](AGENTS.md). diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 92ea4107b..4d8df0ca3 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -30,7 +30,7 @@ Kimi Code 对 CLI/TUI 行为、agent 工作流和公开 API 已有自己的主 - `apps/vis` — 会话调试可视化工具 - `packages/node-sdk` — 公开 TypeScript SDK(`@moonshot-ai/kimi-code-sdk`) - `packages/agent-core-v2` — 当前的 agent 引擎(v2,DI Scope 架构);`packages/agent-core` 为 v1,正在逐步废弃 -- `packages/klient`、`kap-server`、`protocol`、`transcript`、`kosong`、`kaos`、`oauth`、`telemetry` — 内部引擎包 +- `packages/klient`、`kap-server`、`protocol`、`transcript`、`kosong`、`oauth`、`telemetry` — 内部引擎包 - `docs/` — VitePress 双语文档站 完整项目地图见 [AGENTS.md](AGENTS.md)。 diff --git a/apps/vscode/scripts/watch-extension.mjs b/apps/vscode/scripts/watch-extension.mjs index 9dadf992d..f8d2849b4 100644 --- a/apps/vscode/scripts/watch-extension.mjs +++ b/apps/vscode/scripts/watch-extension.mjs @@ -10,7 +10,6 @@ const sourceDirectories = [ join(extensionRoot, 'src'), join(extensionRoot, 'shared'), ...[ - 'kaos', 'kosong', 'migration-legacy', 'node-sdk', diff --git a/apps/vscode/tsdown.config.ts b/apps/vscode/tsdown.config.ts index 9278c0297..264c1701c 100644 --- a/apps/vscode/tsdown.config.ts +++ b/apps/vscode/tsdown.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ '@moonshot-ai/kimi-code-sdk': resolve(root, '../../packages/node-sdk/src/index.ts'), '@moonshot-ai/kimi-telemetry': resolve(root, '../../packages/telemetry/src/index.ts'), '@moonshot-ai/migration-legacy': resolve(root, '../../packages/migration-legacy/src/index.ts'), - '@moonshot-ai/kaos': resolve(root, '../../packages/kaos/src/index.ts'), '@moonshot-ai/kimi-code-oauth/provider-credential': resolve( root, '../../packages/oauth/src/provider-credential.ts', diff --git a/flake.nix b/flake.nix index 67f2befaf..e1d3b4c6e 100644 --- a/flake.nix +++ b/flake.nix @@ -65,7 +65,6 @@ ./packages/acp-server ./packages/agent-core-v2 ./packages/kap-server - ./packages/kaos ./packages/klient ./packages/kosong ./packages/migration-legacy @@ -90,7 +89,6 @@ "@moonshot-ai/acp-server" "@moonshot-ai/agent-core-v2" "@moonshot-ai/kap-server" - "@moonshot-ai/kaos" "@moonshot-ai/kosong" "@moonshot-ai/migration-legacy" "@moonshot-ai/minidb" @@ -158,7 +156,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-xrn34bQ76s+ouOZPHZ4TBkpTHxG7gZejmx8RqSii2uA="; + hash = "sha256-XLjspHl3+xErXYH5fMW8J8fDB5m1ZF8MkATKh11odo0="; }; nativeBuildInputs = [ diff --git a/packages/agent-core-v2/src/mcpCore/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts index 7a45e1ece..203e5bd1b 100644 --- a/packages/agent-core-v2/src/mcpCore/client-stdio.ts +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -51,9 +51,6 @@ export class StdioMcpClient implements MCPClient { static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY; constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions) { - if (config.executor !== undefined && config.executor !== 'local') { - throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); - } this.transport = new RuntimeStdioTransport(config, options, this.stderrBuffer); this.client = new Client({ name: options.clientName ?? KIMI_MCP_CLIENT_NAME, diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts index e88a81dc2..a94699527 100644 --- a/packages/agent-core-v2/src/mcpCore/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -20,7 +20,7 @@ export const McpServerStdioConfigSchema = z.object({ args: z.array(z.string()).optional(), env: StringRecordSchema.optional(), cwd: z.string().optional(), - executor: z.enum(['local', 'kaos']).optional(), + executor: z.enum(['local']).optional(), runtime_id: z.string().min(1).optional(), ...McpServerCommonFields, }); diff --git a/packages/agent-core-v2/test/lint/import-boundaries.test.ts b/packages/agent-core-v2/test/lint/import-boundaries.test.ts index 66c1093b6..041a98bd9 100644 --- a/packages/agent-core-v2/test/lint/import-boundaries.test.ts +++ b/packages/agent-core-v2/test/lint/import-boundaries.test.ts @@ -184,7 +184,7 @@ describe('check-import-boundaries', () => { it('allows sibling-package imports outside kosong', () => { const violations = checkSource( - `import { something } from '@moonshot-ai/kaos';`, + `import { something } from '@moonshot-ai/minidb';`, at('log', 'log.ts'), ); expect(violations).toHaveLength(0); diff --git a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts index 71be67656..58a046d8d 100644 --- a/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts +++ b/packages/agent-core-v2/test/mcpCore/client-stdio.test.ts @@ -55,28 +55,6 @@ function isPostCloseTransportError(error: unknown): boolean { } describe('StdioMcpClient', () => { - it('rejects unsupported executor at construction time', () => { - expect( - () => - createClient({ - transport: 'stdio', - command: 'true', - executor: 'kaos', - }), - ).toThrow( - expect.objectContaining({ name: 'Error2', code: 'not_implemented' }) as unknown as Error, - ); - - let thrown: unknown; - try { - const client = createClient({ transport: 'stdio', command: 'true', executor: 'kaos' }); - void client; - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(Error2); - }); - it('uses defaultCwd when config.cwd is omitted', async () => { const cwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-default-cwd-')); const client = createClient( diff --git a/packages/kaos/CHANGELOG.md b/packages/kaos/CHANGELOG.md deleted file mode 100644 index 9895794d5..000000000 --- a/packages/kaos/CHANGELOG.md +++ /dev/null @@ -1,31 +0,0 @@ -# @moonshot-ai/kaos - -## 0.1.6 - -### Patch Changes - -- [#807](https://github.com/MoonshotAI/kimi-code/pull/807) [`b45672c`](https://github.com/MoonshotAI/kimi-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed. - -## 0.1.5 - -### Patch Changes - -- [#654](https://github.com/MoonshotAI/kimi-code/pull/654) [`ff80327`](https://github.com/MoonshotAI/kimi-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. - -## 0.1.4 - -### Patch Changes - -- [#529](https://github.com/MoonshotAI/kimi-code/pull/529) [`3b62b12`](https://github.com/MoonshotAI/kimi-code/commit/3b62b123e68cc4543bfa8fa376c7e8a24fee0afb) - Detect Git Bash installed through Scoop and other Git shims on Windows. - -## 0.1.3 - -### Patch Changes - -- [#282](https://github.com/MoonshotAI/kimi-code/pull/282) [`a580cd3`](https://github.com/MoonshotAI/kimi-code/commit/a580cd3a98664e18642e0e856aeaa9b71ba93516) - Fix glob pattern backslash escaping and include match count in truncation messages. - -## 0.1.2 - -### Patch Changes - -- [#84](https://github.com/MoonshotAI/kimi-code/pull/84) [`e5717b7`](https://github.com/MoonshotAI/kimi-code/commit/e5717b7261599f4b4379aa34eb0b5fdf2dd93898) - Unify path normalization by replacing ad-hoc `toForwardSlashes` helpers with `pathe`. Remove unnecessary `node:path/win32` branching in path-access policies and tools, and inline unused `joinPath` wrappers. Platform-specific path separators are now handled consistently through a single module. diff --git a/packages/kaos/README.md b/packages/kaos/README.md deleted file mode 100644 index d0c80f0bb..000000000 --- a/packages/kaos/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# @moonshot-ai/kaos - -Execution environment abstraction used by Kimi Code. - -Part of the [Kimi Code](https://github.com/MoonshotAI/kimi-code) monorepo. - -See the main repository for documentation, issues, and contribution guidelines. - -## License - -MIT diff --git a/packages/kaos/package.json b/packages/kaos/package.json deleted file mode 100644 index 6175b2a4a..000000000 --- a/packages/kaos/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@moonshot-ai/kaos", - "version": "0.1.6", - "private": true, - "description": "Execution environment abstraction for AI agent applications", - "license": "MIT", - "author": "Moonshot AI", - "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/kaos#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/MoonshotAI/kimi-code.git", - "directory": "packages/kaos" - }, - "bugs": { - "url": "https://github.com/MoonshotAI/kimi-code/issues" - }, - "keywords": [ - "kimi", - "agent", - "execution", - "sandbox", - "process", - "filesystem" - ], - "files": [ - "dist" - ], - "type": "module", - "imports": { - "#/*": "./src/*.ts" - }, - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./ssh": { - "types": "./src/ssh.ts", - "default": "./src/ssh.ts" - } - }, - "scripts": { - "build": "tsdown", - "typecheck": "tsc -p tsconfig.json --noEmit", - "clean": "rm -rf dist" - }, - "dependencies": { - "pathe": "^2.0.3", - "ssh2": "^1.17.0" - }, - "devDependencies": { - "@types/ssh2": "^1.15.5" - } -} diff --git a/packages/kaos/src/current.ts b/packages/kaos/src/current.ts deleted file mode 100644 index f7c62e0ab..000000000 --- a/packages/kaos/src/current.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; - -import { KaosError } from './errors'; -import type { Kaos } from './kaos'; -import type { KaosProcess } from './process'; -import type { StatResult } from './types'; - -const kaosStorage = new AsyncLocalStorage(); - -/** - * Return the {@link Kaos} instance bound to the current async context. - * - * Throws if nothing is bound — callers must wrap their entry point in - * {@link runWithKaos} or call {@link setCurrentKaos} once at startup. - */ -export function getCurrentKaos(): Kaos { - const store = kaosStorage.getStore(); - if (store === undefined) { - throw new KaosError( - 'No Kaos is bound to the current async context. Call `setCurrentKaos(await LocalKaos.create())` once at startup, or wrap the call in `runWithKaos(...)`.', - ); - } - return store; -} - -/** - * Bind `kaos` as the current instance for the running async context tree. - * Intended for a one-shot call at process startup (e.g. in a test setup - * file). Subsequent code in the same context — including nested awaits — - * resolves {@link getCurrentKaos} to this instance unless overridden by - * {@link runWithKaos}. - */ -export function setCurrentKaos(kaos: Kaos): void { - kaosStorage.enterWith(kaos); -} - -/** - * Run `fn` with `kaos` bound as the current Kaos instance for its async - * subtree. Concurrent calls do not pollute each other — bindings are - * scoped to the {@link AsyncLocalStorage} context. - */ -export function runWithKaos(kaos: Kaos, fn: () => T): T { - return kaosStorage.run(kaos, fn); -} - -// Module-level convenience functions for the current Kaos instance. - -export function readText( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, -): Promise { - return getCurrentKaos().readText(path, options); -} - -export function writeText( - path: string, - data: string, - options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, -): Promise { - return getCurrentKaos().writeText(path, data, options); -} - -export function readLines( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, -): AsyncGenerator { - return getCurrentKaos().readLines(path, options); -} - -export function exec(...args: string[]): Promise { - return getCurrentKaos().exec(...args); -} - -export function readBytes(path: string, n?: number): Promise { - return getCurrentKaos().readBytes(path, n); -} - -export function writeBytes(path: string, data: Buffer): Promise { - return getCurrentKaos().writeBytes(path, data); -} - -export function stat(path: string, options?: { followSymlinks?: boolean }): Promise { - return getCurrentKaos().stat(path, options); -} - -export function mkdir( - path: string, - options?: { parents?: boolean; existOk?: boolean }, -): Promise { - return getCurrentKaos().mkdir(path, options); -} - -export function iterdir(path: string): AsyncGenerator { - return getCurrentKaos().iterdir(path); -} - -export function glob( - path: string, - pattern: string, - options?: { caseSensitive?: boolean }, -): AsyncGenerator { - return getCurrentKaos().glob(path, pattern, options); -} - -export function chdir(path: string): Promise { - return getCurrentKaos().chdir(path); -} - -export function getcwd(): string { - return getCurrentKaos().getcwd(); -} - -export function gethome(): string { - return getCurrentKaos().gethome(); -} - -export function normpath(path: string): string { - return getCurrentKaos().normpath(path); -} - -export function pathClass(): 'posix' | 'win32' { - return getCurrentKaos().pathClass(); -} - -export function execWithEnv(args: string[], env?: Record): Promise { - return getCurrentKaos().execWithEnv(args, env); -} diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts deleted file mode 100644 index 29accd82a..000000000 --- a/packages/kaos/src/environment.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Environment — cross-platform probe of OS / shell. - * - * Detection is a pure function of injected probes (`platform` / `arch` / - * `release` / `env` / `isFile` / `execFileText`) so the same suite runs - * identically on any host OS. `detectEnvironmentFromNode()` bundles the Node - * defaults for production callers. - * - * On Windows the probe expects bash from Git for Windows or MSYS2. If it - * cannot be located the function throws `KaosShellNotFoundError`; the SDK - * layer can wrap that into a user-facing install hint. Set - * `KIMI_SHELL_PATH` to override. - */ - -import { execFile as nodeExecFile } from 'node:child_process'; -import { constants as fsConstants } from 'node:fs'; -import { access } from 'node:fs/promises'; -import * as nodeOs from 'node:os'; -import * as nodePath from 'node:path'; - -import { KaosShellNotFoundError } from './errors'; - -// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and -// falls back to the raw `process.platform` string for unknown ones (e.g. -// 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. -export type OsKind = string; -export type ShellName = 'bash' | 'sh'; - -export interface Environment { - readonly osKind: OsKind; - readonly osArch: string; - readonly osVersion: string; - readonly shellName: ShellName; - readonly shellPath: string; -} - -export interface EnvironmentDeps { - // Accepts the full Node `Platform` enum plus arbitrary strings for - // forward-compatible OS kinds. - readonly platform: string; - readonly arch: string; - readonly release: string; - readonly env: Record; - readonly isFile: (path: string) => Promise; - readonly execFileText: ( - file: string, - args: readonly string[], - timeoutMs: number, - ) => Promise; -} - -const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; - -const MINGW_PREFIX_SET: ReadonlySet = new Set([ - 'mingw32', - 'mingw64', - 'ucrt64', - 'clang64', - 'clangarm64', -]); - -function resolveOsKind(platform: string): OsKind { - switch (platform) { - case 'darwin': - return 'macOS'; - case 'linux': - return 'Linux'; - case 'win32': - return 'Windows'; - default: - return platform; - } -} - -export async function detectEnvironment(deps: EnvironmentDeps): Promise { - const osKind = resolveOsKind(deps.platform); - const osArch = deps.arch; - const osVersion = deps.release; - - if (deps.platform === 'win32') { - const shellPath = await locateWindowsGitBash(deps); - return { osKind, osArch, osVersion, shellName: 'bash', shellPath }; - } - - const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; - let found: string | undefined; - for (const p of candidates) { - if (await deps.isFile(p)) { - found = p; - break; - } - } - if (found !== undefined) { - return { osKind, osArch, osVersion, shellName: 'bash', shellPath: found }; - } - return { osKind, osArch, osVersion, shellName: 'sh', shellPath: '/bin/sh' }; -} - -async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { - const checked: string[] = []; - - const override = deps.env['KIMI_SHELL_PATH']?.trim(); - if (override !== undefined && override.length > 0) { - checked.push(override); - if (await deps.isFile(override)) { - return override; - } - } - - const gitExecutables = await findExecutablesOnPath( - 'git.exe', - deps.env['PATH'], - deps.platform, - deps.isFile, - ); - - for (const gitExe of gitExecutables) { - const inferred = gitBashCandidatesFromGitExe(gitExe); - if (inferred !== undefined) { - for (const candidate of inferred) { - checked.push(candidate); - if (await deps.isFile(candidate)) { - return candidate; - } - } - } - - const gitExecPath = await readGitExecPath(deps, gitExe); - if (gitExecPath === undefined) { - continue; - } - for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { - checked.push(candidate); - if (await deps.isFile(candidate)) { - return candidate; - } - } - } - - const candidates: string[] = [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', - ]; - const localAppData = deps.env['LOCALAPPDATA']?.trim(); - if (localAppData !== undefined && localAppData.length > 0) { - candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`); - candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`); - } - for (const candidate of candidates) { - checked.push(candidate); - if (await deps.isFile(candidate)) { - return candidate; - } - } - - throw new KaosShellNotFoundError( - `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, - ); -} - -async function readGitExecPath( - deps: EnvironmentDeps, - gitExe: string, -): Promise { - if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; - - const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); - if (stdout === undefined) return undefined; - - for (const line of stdout.split(/\r?\n/)) { - const execPath = line.trim(); - if (execPath.length > 0) { - return execPath; - } - } - return undefined; -} - -// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, -// with bash at `\bin\bash.exe`. Portable installs sometimes put -// both in `\bin\`. Only infer from those anchored layouts; package -// manager shims live elsewhere and must resolve through `git --exec-path`. -function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { - const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); - const gitDir = nodePath.win32.dirname(normalizedGitExe); - const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); - if (gitDirName !== 'cmd' && gitDirName !== 'bin') { - return undefined; - } - return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir)); -} - -function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { - const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); - const parts = normalized.split('\\'); - for (let i = parts.length - 1; i >= 0; i -= 1) { - const segment = parts[i]?.toLowerCase(); - if (segment !== undefined && MINGW_PREFIX_SET.has(segment)) { - const root = parts.slice(0, i).join('\\'); - if (root.length > 0) { - return gitBashCandidatesFromGitRoot(root); - } - } - } - - return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..')); -} - -function gitBashCandidatesFromGitRoot(root: string): readonly string[] { - return [ - nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')), - nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')), - ]; -} - -function normalizeWindowsPath(path: string): string { - return path.replaceAll('/', '\\'); -} - -function isAbsoluteWindowsPath(path: string): boolean { - return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); -} - -function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { - const deduped: string[] = []; - const seen = new Set(); - for (const path of paths) { - const key = normalizeWindowsPath(path).toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - deduped.push(path); - } - return deduped; -} - -/** - * Production convenience — derive the deps bag from Node's ambient surface. - * - * The result is memoised: subsequent calls return the original promise. - * `Environment` is immutable for the lifetime of the process (it derives - * from `process.platform`, `process.arch`, `os.release()`, and one-time - * shell-path discovery), so caching is sound. Tests that need to probe - * with different inputs should call {@link detectEnvironment} directly - * with an injected deps bag. - */ -let detectedEnvironment: Promise | undefined; - -export function detectEnvironmentFromNode(): Promise { - if (detectedEnvironment !== undefined) return detectedEnvironment; - const platform = process.platform; - const env = process.env as Record; - const isFile = async (path: string): Promise => { - try { - await access(path, fsConstants.F_OK); - return true; - } catch { - return false; - } - }; - detectedEnvironment = detectEnvironment({ - platform, - arch: process.arch, - release: nodeOs.release(), - env, - isFile, - execFileText, - }); - return detectedEnvironment; -} - -async function findExecutablesOnPath( - name: string, - pathEnv: string | undefined, - platform: string, - isFile: (p: string) => Promise, -): Promise { - if (pathEnv === undefined || pathEnv.length === 0) return []; - const listSep = platform === 'win32' ? ';' : ':'; - const dirSep = platform === 'win32' ? '\\' : '/'; - const paths: string[] = []; - for (const rawDir of pathEnv.split(listSep)) { - const dir = rawDir.trim(); - if (dir.length === 0) continue; - if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; - const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; - if (await isFile(candidate)) { - paths.push(candidate); - } - } - return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; -} - -export async function execFileText( - file: string, - args: readonly string[], - timeoutMs: number, -): Promise { - return new Promise((resolve) => { - nodeExecFile( - file, - [...args], - { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, - (error, stdout) => { - if (error !== null) { - resolve(undefined); - return; - } - resolve(stdout); - }, - ); - }); -} diff --git a/packages/kaos/src/errors.ts b/packages/kaos/src/errors.ts deleted file mode 100644 index 0283c9b01..000000000 --- a/packages/kaos/src/errors.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Base error class for the kaos package. - */ -export class KaosError extends Error { - constructor(message: string) { - super(message); - this.name = 'KaosError'; - } -} - -/** - * Equivalent to Python's ValueError — indicates an invalid argument was passed. - */ -export class KaosValueError extends KaosError { - constructor(message: string) { - super(message); - this.name = 'KaosValueError'; - } -} - -/** - * Equivalent to Python's FileExistsError — indicates a file or directory already exists. - */ -export class KaosFileExistsError extends KaosError { - constructor(message: string) { - super(message); - this.name = 'KaosFileExistsError'; - } -} - -/** - * Thrown by `detectEnvironment` on Windows when no Git Bash install can be - * located. Carries the list of paths that were probed so callers can include - * them in install hints. - */ -export class KaosShellNotFoundError extends KaosError { - constructor(message: string) { - super(message); - this.name = 'KaosShellNotFoundError'; - } -} diff --git a/packages/kaos/src/index.ts b/packages/kaos/src/index.ts deleted file mode 100644 index c27584ebd..000000000 --- a/packages/kaos/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type { StatResult } from './types'; -export type { KaosProcess } from './process'; -export type { Kaos } from './kaos'; -export type { - Environment, - EnvironmentDeps, - OsKind, - ShellName, -} from './environment'; -export { detectEnvironment, detectEnvironmentFromNode } from './environment'; -export type { - ShellPathBridge, - ShellPathBridgeDeps, - ShellPathBridgeEnv, -} from './shell-path-bridge'; -export { createShellPathBridge, getShellPathBridge, translateShellDrivePath } from './shell-path-bridge'; -export { - KaosError, - KaosValueError, - KaosFileExistsError, - KaosShellNotFoundError, -} from './errors'; -export { LocalKaos } from './local'; -export { - chdir, - exec, - execWithEnv, - getCurrentKaos, - getcwd, - gethome, - glob, - iterdir, - mkdir, - normpath, - pathClass, - readBytes, - readLines, - readText, - runWithKaos, - setCurrentKaos, - stat, - writeBytes, - writeText, -} from './current'; diff --git a/packages/kaos/src/internal.ts b/packages/kaos/src/internal.ts deleted file mode 100644 index 6c89acf27..000000000 --- a/packages/kaos/src/internal.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Readable } from 'node:stream'; - -function isUtf8Continuation(byte: number): boolean { - return byte >= 0x80 && byte <= 0xbf; -} - -function decodeUtf8Ignore(data: Buffer): string { - let output = ''; - let i = 0; - - while (i < data.length) { - const b0 = data[i]; - if (b0 === undefined) break; - - if (b0 <= 0x7f) { - output += String.fromCodePoint(b0); - i += 1; - continue; - } - - if (b0 >= 0xc2 && b0 <= 0xdf) { - const b1 = data[i + 1]; - if (b1 !== undefined && isUtf8Continuation(b1)) { - output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f)); - i += 2; - continue; - } - i += 1; - continue; - } - - if (b0 >= 0xe0 && b0 <= 0xef) { - const b1 = data[i + 1]; - const b2 = data[i + 2]; - const validSecond = - b1 !== undefined && - ((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) || - (b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) || - (b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) || - (b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1))); - - if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) { - output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f)); - i += 3; - continue; - } - i += 1; - continue; - } - - if (b0 >= 0xf0 && b0 <= 0xf4) { - const b1 = data[i + 1]; - const b2 = data[i + 2]; - const b3 = data[i + 3]; - const validSecond = - b1 !== undefined && - ((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) || - (b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) || - (b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f)); - - if ( - validSecond && - b2 !== undefined && - b3 !== undefined && - isUtf8Continuation(b2) && - isUtf8Continuation(b3) - ) { - output += String.fromCodePoint( - ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), - ); - i += 4; - continue; - } - i += 1; - continue; - } - - i += 1; - } - - return output; -} - -function decodeUtf16LeIgnore(data: Buffer): string { - let output = ''; - let i = 0; - - while (i + 1 < data.length) { - const first = data[i]; - const second = data[i + 1]; - if (first === undefined || second === undefined) break; - - const codeUnit = first | (second << 8); - - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const lowFirst = data[i + 2]; - const lowSecond = data[i + 3]; - if (lowFirst !== undefined && lowSecond !== undefined) { - const low = lowFirst | (lowSecond << 8); - if (low >= 0xdc00 && low <= 0xdfff) { - const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00); - output += String.fromCodePoint(codePoint); - i += 4; - continue; - } - } - i += 2; - continue; - } - - if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { - i += 2; - continue; - } - - output += String.fromCodePoint(codeUnit); - i += 2; - } - - return output; -} - -/** - * Decode a Buffer into a string with Python-compatible `errors` handling. - * - * - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`) - * - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default) - * - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters - * - * Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not - * support (e.g. `hex`, `base64`, `binary`, `latin1`) — those are lossless - * byte-to-character mappings so `errors` has no effect. - * @internal - */ -export function decodeTextWithErrors( - data: Buffer, - encoding: BufferEncoding, - errors: 'strict' | 'replace' | 'ignore' = 'strict', - ignoreBOM: boolean = false, -): string { - // Map Node's BufferEncoding names to Web TextDecoder labels where the two - // diverge. Only UTF-family encodings participate in the strict/replace/ - // ignore dance; the others are lossless and use Buffer.toString directly. - let webLabel: string | undefined; - // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check - switch (encoding) { - case 'utf-8': - case 'utf8': - webLabel = 'utf-8'; - break; - case 'utf16le': - case 'ucs2': - case 'ucs-2': - webLabel = 'utf-16le'; - break; - default: - webLabel = undefined; - } - - if (webLabel === undefined) { - // Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔ - // character mappings; `errors` is meaningless for them. Return raw. - return data.toString(encoding); - } - - if (errors === 'strict') { - return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data); - } - - // 'ignore' must skip invalid input bytes/code units, not delete every - // replacement character in the decoded output. A file can contain a valid - // U+FFFD, and Python preserves it under errors="ignore". - if (errors === 'ignore') { - return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data); - } - - // 'replace' → substitute each invalid sequence with U+FFFD (default). - return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data); -} - -/** - * Convert a glob pattern segment (e.g. "*.txt", "file?.log") into a RegExp. - * Mirrors Python pathlib behavior: includes dotfiles, case-sensitive by default. - * @internal - */ -export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp { - let regex = '^'; - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]; - if (ch === undefined) break; - switch (ch) { - case '*': - regex += '[^/]*'; - break; - case '?': - regex += '[^/]'; - break; - case '[': { - const end = pattern.indexOf(']', i + 1); - if (end === -1) { - regex += '\\['; - } else { - // Glob character classes only use `!` for negation. A literal - // leading `^` must remain literal even though JS regex char - // classes treat it as negation in the first position. - let charClass = pattern.slice(i + 1, end); - // Escape backslashes inside the class so a trailing backslash - // does not accidentally escape the closing `]`. - charClass = charClass.replace(/\\/g, '\\\\'); - if (charClass.startsWith('!')) { - charClass = '^' + charClass.slice(1); - } else if (charClass.startsWith('^')) { - charClass = '\\' + charClass; - } - regex += '[' + charClass + ']'; - i = end; - } - break; - } - case '\\': { - if (i + 1 < pattern.length) { - const next = pattern.charAt(i + 1); - regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); - // Advance past the escaped character so it is not processed - // again as a regex metacharacter. match literally. - i++; - } else { - regex += '\\\\'; - } - break; - } - default: - regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); - } - } - regex += '$'; - return new RegExp(regex, caseSensitive ? '' : 'i'); -} - -/** - * A Readable wrapper that preserves source backpressure while still allowing - * consumers to read buffered output after the source has ended. - * @internal - */ -export class BufferedReadable extends Readable { - private readonly _source: Readable; - private _ended: boolean = false; - - constructor(source: Readable) { - // Keep a modest prefetch window so wait()-then-read still works for - // common small/medium outputs without draining unboundedly. - super({ highWaterMark: 128 * 1024 }); - this._source = source; - this._source.on('data', this._onData); - this._source.on('end', this._onEnd); - this._source.on('close', this._onClose); - this._source.on('error', this._onError); - } - - override _read(): void { - if (!this._ended && !this.destroyed) { - this._source.resume(); - } - } - - override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { - this._source.off('data', this._onData); - this._source.off('end', this._onEnd); - this._source.off('close', this._onClose); - this._source.off('error', this._onError); - this._source.destroy(); - callback(error); - } - - private readonly _onData = (chunk: string | Uint8Array): void => { - if (!this.push(chunk)) { - this._source.pause(); - } - }; - - private readonly _onEnd = (): void => { - this._ended = true; - this.push(null); - }; - - private readonly _onClose = (): void => { - if (!this._ended) { - this._ended = true; - this.push(null); - } - }; - - private readonly _onError = (error: Error): void => { - this.destroy(error); - }; -} diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts deleted file mode 100644 index e674418ce..000000000 --- a/packages/kaos/src/kaos.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Environment } from './environment'; -import type { KaosProcess } from './process'; -import type { StatResult } from './types'; - -/** - * Kimi Agent Operating System (KAOS) interface. - * - * This is the core abstraction that allows the agent to interact with - * different execution environments (local, SSH, containers, etc.) - * through a unified API. - */ -export interface Kaos { - /** Human-readable name for this environment (e.g. `"local"`, `"ssh:host"`). */ - readonly name: string; - - /** - * OS / shell probe describing the target environment. Populated by the - * concrete Kaos implementation (e.g. `detectEnvironmentFromNode()` for - * `LocalKaos`, a remote probe for `SSHKaos`). - */ - readonly osEnv: Environment; - - // ── Path operations (sync) ────────────────────────────────────────── - - /** Return the path style used by this environment. */ - pathClass(): 'posix' | 'win32'; - /** Normalize the given path string (resolve `.` / `..` segments). */ - normpath(path: string): string; - /** Return the home directory of the current user. */ - gethome(): string; - /** Return the current working directory. */ - getcwd(): string; - - // ── Directory operations (async) ──────────────────────────────────── - - /** Change the working directory to `path`. */ - chdir(path: string): Promise; - /** Return a new Kaos with the given `cwd`. */ - withCwd(cwd: string): Kaos; - /** - * Return a new Kaos that overlays `env` onto every spawned process. - * - * The provided record is read when a process is spawned, so callers may - * mutate a stable record to update future executions. - */ - withEnv(env: Record): Kaos; - /** Return stat metadata for `path`. */ - stat(path: string, options?: { followSymlinks?: boolean }): Promise; - /** Yield entry names in the directory at `path`. */ - iterdir(path: string): AsyncGenerator; - /** Yield paths matching `pattern` under `path`. */ - glob( - path: string, - pattern: string, - options?: { caseSensitive?: boolean }, - ): AsyncGenerator; - - // ── File operations (async) ───────────────────────────────────────── - - /** Read up to `n` bytes from `path` (all bytes if `n` is omitted). */ - readBytes(path: string, n?: number): Promise; - /** - * Read the file at `path` as a string. - * - * `errors` controls how decode errors are handled — mirrors Python's - * `open(..., errors=)` parameter: - * - `'strict'` (default): throw on any invalid byte for the encoding - * - `'replace'`: substitute each invalid byte with U+FFFD (REPLACEMENT CHARACTER) - * - `'ignore'`: drop invalid bytes silently - */ - readText( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, - ): Promise; - /** Yield lines from the file at `path` one by one. */ - readLines( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, - ): AsyncGenerator; - /** Write raw bytes to `path`, returning the number of bytes written. */ - writeBytes(path: string, data: Buffer): Promise; - /** Write text to `path`, returning the number of characters written. */ - writeText( - path: string, - data: string, - options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, - ): Promise; - /** Create a directory at `path`. */ - mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise; - - // ── Process execution ─────────────────────────────────────────────── - - /** Spawn a process with the given arguments. */ - exec(...args: string[]): Promise; - /** Spawn a process with explicit environment variables. */ - execWithEnv(args: string[], env?: Record): Promise; -} diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts deleted file mode 100644 index 92ff99b67..000000000 --- a/packages/kaos/src/local.ts +++ /dev/null @@ -1,901 +0,0 @@ -import type { ChildProcess, SpawnOptions } from 'node:child_process'; -import { spawn } from 'node:child_process'; -import { - appendFile, - lstat, - mkdir, - open, - readdir, - readFile, - stat, - writeFile, -} from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { isAbsolute, join, normalize } from 'pathe'; -import type { Readable, Writable } from 'node:stream'; - -import { detectEnvironmentFromNode, type Environment } from './environment'; -import { KaosFileExistsError } from './errors'; -import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal'; -import type { Kaos } from './kaos'; -import { applyLoginShellPathFromNode } from './login-shell-path'; -import type { KaosProcess } from './process'; -import type { StatResult } from './types'; - -const isWindows: boolean = process.platform === 'win32'; -const READ_CHUNK_SIZE = 64 * 1024; - -type TextDecodeErrors = 'strict' | 'replace' | 'ignore'; - -interface LineEndingFlags { - hasCrLf: boolean; - hasLf: boolean; - hasLoneCr: boolean; -} - -interface TextFileScan { - totalLines: number; - endsWithNewline: boolean; - hasNul: boolean; - lineEndingFlags: LineEndingFlags; -} - -/** - * Build the `(dev, ino)` cycle-detection key used by `_globWalk`'s - * visited set. Returns `null` when `ino` is 0, which Node returns on - * filesystems that don't carry inodes (Windows FAT/exFAT, some SMB/NFS - * mounts). A null key signals "no reliable identity for this dir" so - * the caller skips visited tracking for that descent — cycle safety - * is weakened on those filesystems, but normal walking works instead - * of every directory colliding on the shared key `":0"`. - */ -function cycleKey(s: { dev: number; ino: number }): string | null { - if (s.ino === 0) return null; - return `${String(s.dev)}:${String(s.ino)}`; -} - -export function buildLocalSpawnOptions( - isWindows: boolean, - cwd: string, - env: Record | undefined, -): SpawnOptions { - return { - cwd, - env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: !isWindows, - windowsHide: true, - }; -} - -class LocalProcess implements KaosProcess { - readonly stdin: Writable; - readonly stdout: Readable; - readonly stderr: Readable; - readonly pid: number; - - private readonly _child: ChildProcess; - private _exitCode: number | null = null; - private readonly _exitPromise: Promise; - private _disposed = false; - - constructor(child: ChildProcess) { - if (child.stdin === null || child.stdout === null || child.stderr === null) { - throw new Error('Process must be created with stdin/stdout/stderr pipes.'); - } - - this._child = child; - this.stdin = child.stdin; - this.stdout = new BufferedReadable(child.stdout); - this.stderr = new BufferedReadable(child.stderr); - this.pid = child.pid ?? -1; - - this._exitPromise = new Promise((resolve, reject) => { - child.on('exit', (code: number | null) => { - this._exitCode = code ?? -1; - resolve(this._exitCode); - }); - child.on('error', (error: Error) => { - reject(error); - }); - }); - } - - get exitCode(): number | null { - return this._exitCode; - } - - async wait(): Promise { - return this._exitPromise; - } - - kill(signal?: NodeJS.Signals): Promise { - // Reject if the process never actually started (spawn failed). - // pid <= 0 indicates ChildProcess.pid was undefined, which happens - // when spawn() fails to find/execute the command. Calling - // process.kill(-1, ...) on POSIX would signal the entire process - // group, potentially killing unrelated processes. - if (this.pid <= 0) { - return Promise.resolve(); - } - - // On Windows, `ChildProcess.kill()` only signals the shell parent, leaving - // grandchildren alive, so terminate the whole process tree with - // `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually - // terminate a console node.exe tree, and Windows has no real graceful - // signal for it — Node's own `ChildProcess.kill()` is always a forceful - // TerminateProcess on Windows — so always force-terminate the tree. - if (isWindows) { - const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)]; - return new Promise((resolve) => { - const killer = spawn('taskkill', taskkillArgs, { - stdio: 'ignore', - windowsHide: true, - }); - const done = (): void => { - resolve(); - }; - killer.once('error', done); - killer.once('close', done); - }); - } - - // On POSIX, `detached:true` makes the child a process-group leader - // (pgid === pid). A plain `ChildProcess.kill()` still only signals the - // direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves - // grandchildren orphaned. `process.kill(-pid, signal)` signals the group - // (negative pid = process-group id under POSIX kill(2)). - try { - process.kill(-this.pid, signal ?? 'SIGTERM'); - } catch (error) { - const err = error as NodeJS.ErrnoException; - // ESRCH = group already gone (child exited + reaped between - // `wait()` racing spawn + this call). Treat as successful kill. - if (err.code === 'ESRCH') return Promise.resolve(); - // EPERM is typically a misconfiguration (e.g. non-detached - // spawn earlier in the file); fall back to direct `.kill()` so - // we at least signal the direct child instead of throwing. - if (err.code === 'EPERM') { - try { - this._child.kill(signal ?? 'SIGTERM'); - } catch { - /* best effort */ - } - return Promise.resolve(); - } - throw error; - } - return Promise.resolve(); - } - - dispose(): void { - if (this._disposed) return; - this._disposed = true; - this.stdin.destroy(); - this.stdout.destroy(); - this.stderr.destroy(); - } -} - -/** - * A KAOS implementation that directly interacts with the local filesystem. - * - * Note: LocalKaos maintains its own per-instance working directory (`_cwd`) - * rather than mutating `process.cwd()`. This lets multiple LocalKaos instances - * coexist with independent cwds (e.g. when switching contexts via - * `runWithKaos`) without cross-polluting each other's relative-path resolution. - */ -export class LocalKaos implements Kaos { - readonly name: string = 'local'; - readonly osEnv: Environment; - private _cwd: string; - private readonly _envLayers: readonly Record[]; - - private constructor( - osEnv: Environment, - cwd?: string, - envLayers: readonly Record[] = [], - ) { - // After construction we never touch `process.cwd()` / `process.chdir()` - // — all path resolution goes through `this._cwd`. The default seeds - // from `process.cwd()` but callers can pin to anything via `withCwd` - // (or supplying `cwd` directly). - this._cwd = normalize(cwd ?? process.cwd()); - this.osEnv = osEnv; - this._envLayers = envLayers; - } - - /** - * Construct a fresh `LocalKaos` after probing the host environment. - * - * Each call returns a new instance with its own `_cwd`; concurrent - * callers can therefore operate on independent working directories - * without polluting one another. - */ - static async create(): Promise { - // Enrich process.env.PATH from the user's login shell so spawned - // commands find user-installed tools (e.g. Homebrew's gh) even when - // kimi-code itself was launched without the full profile PATH. Both - // probes are memoised, independent, and run concurrently. - const [osEnv] = await Promise.all([detectEnvironmentFromNode(), applyLoginShellPathFromNode()]); - return new LocalKaos(osEnv); - } - - withCwd(cwd: string): LocalKaos { - return new LocalKaos(this.osEnv, cwd, this._envLayers); - } - - withEnv(env: Record): LocalKaos { - return new LocalKaos(this.osEnv, this._cwd, [...this._envLayers, env]); - } - - private _resolvePath(path: string): string { - if (isAbsolute(path)) return normalize(path); - return join(this._cwd, path); - } - - pathClass(): 'posix' | 'win32' { - return isWindows ? 'win32' : 'posix'; - } - - normpath(path: string): string { - return normalize(path); - } - - gethome(): string { - return normalize(homedir()); - } - - getcwd(): string { - return this._cwd; - } - - /** - * Change the working directory of this LocalKaos instance. - * - * Unlike Python's `os.chdir`, this is instance-scoped and never touches - * `process.cwd()`. Child processes spawned via {@link exec} inherit this - * instance's `_cwd`; concurrent LocalKaos instances each carry their own - * independent cwd. If you need Python-compatible process-global cwd, - * call `process.chdir(x)` directly. - */ - async chdir(path: string): Promise { - const resolved = this._resolvePath(path); - const s = await stat(resolved); - if (!s.isDirectory()) { - throw new Error(`Not a directory: ${resolved}`); - } - this._cwd = resolved; - } - - async stat(path: string, options?: { followSymlinks?: boolean }): Promise { - const resolved = this._resolvePath(path); - const followSymlinks = options?.followSymlinks ?? true; - const s = followSymlinks ? await stat(resolved) : await lstat(resolved); - return { - stMode: s.mode, - stIno: s.ino, - stDev: s.dev, - stNlink: s.nlink, - stUid: s.uid, - stGid: s.gid, - stSize: s.size, - stAtime: s.atimeMs / 1000, - stMtime: s.mtimeMs / 1000, - stCtime: isWindows ? s.birthtimeMs / 1000 : s.ctimeMs / 1000, - }; - } - - async *iterdir(path: string): AsyncGenerator { - const resolved = this._resolvePath(path); - const entries = await readdir(resolved); - for (const entry of entries) { - // Use join so root paths like "/" or "C:\\" don't produce "//entry" - // or "C:\\\\entry" — join normalizes trailing separators correctly. - yield join(resolved, entry); - } - } - - async *glob( - path: string, - pattern: string, - options?: { caseSensitive?: boolean }, - ): AsyncGenerator { - const resolved = this._resolvePath(path); - const caseSensitive = options?.caseSensitive ?? true; - const patternParts = pattern.split('/'); - // Seed `visited` with basePath's own inode so that a symlink inside - // basePath that points back at basePath is caught on its first - // encounter (not on the second level — the "+1 depth" off-by-one - // that would otherwise leak if the caller globs directly from the - // loop root). `stat` failure here is tolerated: `_globWalk` will - // hit the same error via readdir and return empty. - const initVisited = new Set(); - try { - const rootStat = await stat(resolved); - const rootKey = cycleKey(rootStat); - if (rootKey !== null) initVisited.add(rootKey); - } catch { - // base does not exist / not accessible — walker handles via its own catch - } - yield* this._globWalk(resolved, patternParts, caseSensitive, initVisited); - } - - // `visited` holds the `(stDev, stIno)` keys of directories on the - // current descent path. Before recursing into a subdirectory, we - // check its key against `visited`; if present we skip it (cycle - // detected) and otherwise recurse with a fresh Set containing the - // additional key. The per-recurse copy gives the check path-local - // semantics: two legitimate symlinks to the same target in separate - // branches both traverse, which is more permissive than Python stdlib - // while still cycle-safe. - // Same-directory self-recursion (e.g. `**` matching zero dirs with - // pattern tail) passes `visited` unchanged — no descent, no cycle - // risk. - // - // Windows note: Node's `fs.Stats.ino` returns `0` on filesystems - // that don't support inodes (FAT/exFAT, some SMB/NFS mounts). If we - // keyed on `ino=0`, every directory on such a drive would share the - // key `":0"` and the first would "visit" all others. The - // module-level `cycleKey` helper returns `null` in that case, which - // causes the call sites to skip visited tracking for that descent - // — cycle safety is lost on those filesystems, but normal walking - // works. - private async *_globWalk( - basePath: string, - patternParts: string[], - caseSensitive: boolean, - visited: Set, - ): AsyncGenerator { - if (patternParts.length === 0) { - return; - } - - const [currentPattern, ...remainingParts] = patternParts; - - if (currentPattern === '**') { - // `**` matches zero or more directory components. - // - // There are exactly two cases to handle: - // (a) `**` matches zero directories → continue at basePath with the - // remaining pattern parts (or yield basePath itself when `**` - // is the final segment). - // (b) `**` matches one or more directories → recurse into each - // subdirectory, keeping `**` (i.e. the full patternParts) at - // the front. The "zero directories" case is then re-evaluated - // at the subdirectory level by that recursive call. - // - // We must NOT additionally recurse with `remainingParts` on - // subdirectories — that would double-count every match at depth ≥ 1 - // because case (a) inside the child recursion already yields those - // results. - if (remainingParts.length > 0) { - yield* this._globWalk(basePath, remainingParts, caseSensitive, visited); - } else { - // Pattern ends with `**`: yield basePath itself (zero-dir match). - yield basePath; - } - - let entries: string[]; - try { - entries = await readdir(basePath); - } catch { - return; - } - - for (const entry of entries) { - // Use join to avoid "//entry" when basePath is a filesystem root. - const fullPath = join(basePath, entry); - let entryStat; - try { - entryStat = await stat(fullPath); - } catch { - continue; - } - if (entryStat.isDirectory()) { - const key = cycleKey(entryStat); - if (key !== null && visited.has(key)) continue; - yield* this._globWalk( - fullPath, - patternParts, - caseSensitive, - key !== null ? new Set([...visited, key]) : visited, - ); - } else if (remainingParts.length === 0) { - // Pattern ends with `**`: non-directory entries match too - // (since `**` matches "anything"). - yield fullPath; - } - } - } else { - const regex = globPatternToRegex(currentPattern ?? '', caseSensitive); - - let entries: string[]; - try { - entries = await readdir(basePath); - } catch { - return; - } - - for (const entry of entries) { - if (!regex.test(entry)) { - continue; - } - - // Use join to avoid "//entry" when basePath is a filesystem root. - const fullPath = join(basePath, entry); - - if (remainingParts.length === 0) { - yield fullPath; - } else { - let entryStat; - try { - entryStat = await stat(fullPath); - } catch { - continue; - } - if (entryStat.isDirectory()) { - const key = cycleKey(entryStat); - if (key !== null && visited.has(key)) continue; - yield* this._globWalk( - fullPath, - remainingParts, - caseSensitive, - key !== null ? new Set([...visited, key]) : visited, - ); - } - } - } - } - } - - async readBytes(path: string, n?: number): Promise { - const resolved = this._resolvePath(path); - if (n === undefined) { - return Buffer.from(await readFile(resolved)); - } - const fh = await open(resolved, 'r'); - try { - const buf = Buffer.alloc(n); - const { bytesRead } = await fh.read(buf, 0, n, 0); - return buf.subarray(0, bytesRead); - } finally { - await fh.close(); - } - } - - async readText( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, - ): Promise { - const resolved = this._resolvePath(path); - const encoding = options?.encoding ?? 'utf-8'; - const errors = options?.errors ?? 'strict'; - const data = await readFile(resolved); - return decodeTextWithErrors(data, encoding, errors); - } - - async *readLines( - path: string, - options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, - ): AsyncGenerator { - const resolved = this._resolvePath(path); - const encoding = options?.encoding ?? 'utf-8'; - const errors = options?.errors ?? 'strict'; - - if (!isUtf8Encoding(encoding)) { - const content = decodeTextWithErrors(await readFile(resolved), encoding, errors); - yield* splitLinesKeepingTerminator(content); - return; - } - - yield* this._readUtf8Lines(resolved, errors); - } - - async scanTextFile(path: string): Promise { - const resolved = this._resolvePath(path); - const fh = await open(resolved, 'r'); - try { - const buf = Buffer.alloc(READ_CHUNK_SIZE); - const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; - const validator = createUtf8Validator(); - let totalLines = 0; - let totalBytes = 0; - let endsWithNewline = false; - let hasNul = false; - let prevWasCr = false; - - while (true) { - const { bytesRead } = await fh.read(buf, 0, buf.length, null); - if (bytesRead === 0) break; - const chunk = buf.subarray(0, bytesRead); - validator.write(chunk); - for (let i = 0; i < chunk.length; i += 1) { - const byte = chunk[i]; - if (byte === undefined) continue; - if (byte === 0) hasNul = true; - if (byte === 0x0a) totalLines += 1; - } - prevWasCr = updateLineEndingFlagsFromBytes(flags, chunk, prevWasCr); - totalBytes += bytesRead; - endsWithNewline = chunk[bytesRead - 1] === 0x0a; - } - - if (prevWasCr) flags.hasLoneCr = true; - validator.end(); - if (totalBytes > 0 && !endsWithNewline) totalLines += 1; - return { totalLines, endsWithNewline, hasNul, lineEndingFlags: flags }; - } finally { - await fh.close(); - } - } - - async *readLineRange( - path: string, - options: { startLine: number; maxLines: number; errors?: TextDecodeErrors }, - ): AsyncGenerator { - const resolved = this._resolvePath(path); - const errors = options.errors ?? 'strict'; - yield* this._readUtf8Lines(resolved, errors, { - startLine: options.startLine, - maxLines: options.maxLines, - }); - } - - async *readTailLines( - path: string, - options: { tailCount: number; errors?: TextDecodeErrors }, - ): AsyncGenerator { - if (options.tailCount <= 0) return; - const resolved = this._resolvePath(path); - const errors = options.errors ?? 'strict'; - const fh = await open(resolved, 'r'); - try { - const s = await fh.stat(); - if (s.size === 0) return; - - let pos = s.size; - let foundLf = 0; - let startOffset = 0; - let needLf = options.tailCount; - let sawTailBlock = false; - - while (pos > 0 && foundLf < needLf) { - const readSize = Math.min(READ_CHUNK_SIZE, pos); - pos -= readSize; - const buf = Buffer.alloc(readSize); - await fh.read(buf, 0, readSize, pos); - if (!sawTailBlock) { - sawTailBlock = true; - const endsWithNewline = buf[readSize - 1] === 0x0a; - needLf = endsWithNewline ? options.tailCount + 1 : options.tailCount; - } - for (let i = readSize - 1; i >= 0; i -= 1) { - const byte = buf[i]; - if (byte !== 0x0a) continue; - foundLf += 1; - if (foundLf === needLf) { - startOffset = pos + i + 1; - break; - } - } - } - - if (foundLf < needLf) startOffset = 0; - const data = await readRange(fh, startOffset, s.size - startOffset); - const text = decodeTextWithErrors(data, 'utf-8', errors, startOffset !== 0); - yield* splitLinesKeepingTerminator(text); - } finally { - await fh.close(); - } - } - - private async *_readUtf8Lines( - resolved: string, - errors: TextDecodeErrors, - range?: { startLine?: number; maxLines?: number }, - ): AsyncGenerator { - const startLine = range?.startLine ?? 1; - const maxLines = range?.maxLines ?? Number.POSITIVE_INFINITY; - const fh = await open(resolved, 'r'); - try { - const buf = Buffer.alloc(READ_CHUNK_SIZE); - let pending: Buffer[] = []; - let pendingOffset = 0; - let fileOffset = 0; - let lineNo = 1; - let yielded = 0; - - while (true) { - const { bytesRead } = await fh.read(buf, 0, buf.length, null); - if (bytesRead === 0) break; - const chunk = buf.subarray(0, bytesRead); - let lineStart = 0; - - for (let i = 0; i < chunk.length; i += 1) { - const byte = chunk[i]; - if (byte !== 0x0a) continue; - const piece = chunk.subarray(lineStart, i + 1); - const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset; - const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); - if (lineNo >= startLine) { - yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); - yielded += 1; - if (yielded >= maxLines) return; - } - pending = []; - lineStart = i + 1; - lineNo += 1; - } - - if (lineStart < chunk.length) { - const tail = Buffer.from(chunk.subarray(lineStart)); - if (pending.length === 0) pendingOffset = fileOffset + lineStart; - pending.push(tail); - } - fileOffset += bytesRead; - } - - if (pending.length > 0) { - const line = Buffer.concat(pending); - if (lineNo >= startLine) { - yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); - } - } - } finally { - await fh.close(); - } - } - - async writeBytes(path: string, data: Buffer): Promise { - const resolved = this._resolvePath(path); - await writeFile(resolved, data); - return data.length; - } - - async writeText( - path: string, - data: string, - options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, - ): Promise { - const resolved = this._resolvePath(path); - const encoding = options?.encoding ?? 'utf-8'; - const mode = options?.mode ?? 'w'; - if (mode === 'a') { - await appendFile(resolved, data, encoding); - } else { - await writeFile(resolved, data, encoding); - } - return data.length; - } - - async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { - const resolved = this._resolvePath(path); - const parents = options?.parents ?? false; - const existOk = options?.existOk ?? false; - - if (parents) { - // `fs.mkdir(..., { recursive: true })` silently succeeds when the - // target already exists — it does NOT raise EEXIST. To honor the - // `existOk: false` semantics, we must probe for existence ourselves - // before delegating to the recursive mkdir. - if (!existOk) { - try { - const s = await stat(resolved); - if (s.isDirectory()) { - throw new KaosFileExistsError(`${resolved} already exists`); - } - // Path exists but is not a directory — let `mkdir` surface the - // appropriate error (EEXIST/ENOTDIR) below. - } catch (error: unknown) { - if (error instanceof KaosFileExistsError) throw error; - const err = error as NodeJS.ErrnoException; - if (err.code !== 'ENOENT') throw error; - // ENOENT: target doesn't exist yet — proceed to mkdir. - } - } - await mkdir(resolved, { recursive: true }); - return; - } - - // Non-recursive: fs.mkdir naturally throws EEXIST on collision. - try { - await mkdir(resolved); - } catch (error: unknown) { - if ( - existOk && - error instanceof Error && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'EEXIST' - ) { - // `existOk` only applies when the conflicting path is itself a - // directory. If a regular file (or other non-directory) already - // occupies the path, silently returning would be a lie — the - // requested directory still does not exist. Surface the conflict - // explicitly so callers cannot mistake "file collision" for - // "directory already present". - const s = await stat(resolved); - if (!s.isDirectory()) { - throw new KaosFileExistsError(`${resolved} already exists but is not a directory`); - } - return; - } - throw error; - } - } - - async exec(...args: string[]): Promise { - const command = args[0]; - if (command === undefined) { - throw new Error('LocalKaos.exec(): at least one argument (the command to run) is required.'); - } - const restArgs = args.slice(1); - const child = spawn( - command, - restArgs, - buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv()), - ); - await waitForSpawn(child); - return new LocalProcess(child); - } - - async execWithEnv(args: string[], env?: Record): Promise { - const command = args[0]; - if (command === undefined) { - throw new Error( - 'LocalKaos.execWithEnv(): at least one argument (the command to run) is required.', - ); - } - const restArgs = args.slice(1); - const child = spawn( - command, - restArgs, - buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv(env)), - ); - await waitForSpawn(child); - return new LocalProcess(child); - } - - private _buildExecEnv(invocationEnv?: Record): Record | undefined { - if (this._envLayers.length === 0) return invocationEnv; - const merged: Record = { - ...(process.env as Record), - ...invocationEnv, - }; - for (const layer of this._envLayers) { - Object.assign(merged, layer); - } - return merged; - } -} - -function isUtf8Encoding(encoding: BufferEncoding): boolean { - return encoding === 'utf-8' || encoding === 'utf8'; -} - -function* splitLinesKeepingTerminator(text: string): Generator { - if (text.length === 0) return; - let start = 0; - for (let i = 0; i < text.length; i += 1) { - if (text.codePointAt(i) === 0x0a) { - yield text.slice(start, i + 1); - start = i + 1; - } - } - if (start < text.length) { - yield text.slice(start); - } -} - -function updateLineEndingFlagsFromBytes( - flags: LineEndingFlags, - chunk: Buffer, - prevWasCr: boolean, -): boolean { - for (let i = 0; i < chunk.length; i += 1) { - const byte = chunk[i]; - if (byte === undefined) continue; - if (byte === 0x0d) { - if (prevWasCr) flags.hasLoneCr = true; - prevWasCr = true; - } else if (byte === 0x0a) { - if (prevWasCr) { - flags.hasCrLf = true; - } else { - flags.hasLf = true; - } - prevWasCr = false; - } else { - if (prevWasCr) flags.hasLoneCr = true; - prevWasCr = false; - } - } - return prevWasCr; -} - -function createUtf8Validator(): { write(chunk: Buffer): void; end(): void } { - let needed = 0; - let lower = 0x80; - let upper = 0xbf; - - const fail = (): never => { - throw new TypeError('Invalid UTF-8 data'); - }; - - return { - write(chunk: Buffer): void { - for (let i = 0; i < chunk.length; i += 1) { - const byte = chunk[i]; - if (byte === undefined) continue; - if (needed === 0) { - if (byte <= 0x7f) continue; - if (byte >= 0xc2 && byte <= 0xdf) { - needed = 1; - } else if (byte === 0xe0) { - needed = 2; - lower = 0xa0; - } else if (byte >= 0xe1 && byte <= 0xec) { - needed = 2; - } else if (byte === 0xed) { - needed = 2; - upper = 0x9f; - } else if (byte >= 0xee && byte <= 0xef) { - needed = 2; - } else if (byte === 0xf0) { - needed = 3; - lower = 0x90; - } else if (byte >= 0xf1 && byte <= 0xf3) { - needed = 3; - } else if (byte === 0xf4) { - needed = 3; - upper = 0x8f; - } else { - fail(); - } - } else { - if (byte < lower || byte > upper) fail(); - lower = 0x80; - upper = 0xbf; - needed -= 1; - } - } - }, - end(): void { - if (needed !== 0) fail(); - }, - }; -} - -async function readRange( - fh: Awaited>, - start: number, - length: number, -): Promise { - const data = Buffer.alloc(length); - let offset = 0; - while (offset < length) { - const { bytesRead } = await fh.read(data, offset, length - offset, start + offset); - if (bytesRead === 0) break; - offset += bytesRead; - } - return offset === length ? data : data.subarray(0, offset); -} - -// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or -// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not -// assume the child is running — they may otherwise write to the stdin of a -// process that never existed. -function waitForSpawn(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - const onSpawn = (): void => { - child.off('error', onError); - resolve(); - }; - const onError = (err: Error): void => { - child.off('spawn', onSpawn); - reject(err); - }; - child.once('spawn', onSpawn); - child.once('error', onError); - }); -} diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts deleted file mode 100644 index 1983c8058..000000000 --- a/packages/kaos/src/login-shell-path.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Login-shell PATH probe — enrich `process.env.PATH` with entries from the - * user's login shell. - * - * When kimi-code is launched from a context that skipped the user's shell - * profile (GUI launchers, non-login parent shells), `process.env.PATH` - * misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash - * tool can't find tools the user has in their interactive shell (e.g. - * `gh`). We run the user's login shell once (`$SHELL -l -c /usr/bin/env`), - * extract its PATH, and append the entries the current PATH lacks. Existing - * entries keep their order and priority; failures (no resolvable shell, - * hung or broken profile) silently leave PATH untouched. - * - * launchd/daemon launches can leave `$SHELL` unset or blank, so - * the probe falls back to the OS account's login shell from the user - * database before giving up. - * - * Like `detectEnvironment`, the probe is a pure function of injected deps - * so the suite runs identically on any host. Windows is skipped: the - * problem is specific to POSIX login-shell profiles. - */ - -import { userInfo } from 'node:os'; - -import { execFileText } from './environment'; - -export interface LoginShellPathDeps { - readonly platform: string; - readonly env: Record; - /** Login shell from the OS user database; fallback when $SHELL is unset. */ - readonly userShell: () => string | undefined; - readonly execFileText: ( - file: string, - args: readonly string[], - timeoutMs: number, - ) => Promise; -} - -const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; - -/** - * Run the user's login shell and return its PATH, or `undefined` when the - * probe does not apply (Windows, no resolvable shell) or fails (spawn - * error, timeout, no PATH in the output). - */ -export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { - if (deps.platform === 'win32') return undefined; - // A set-but-blank $SHELL (some daemon/launchd envs) must also fall back. - const envShell = deps.env['SHELL']?.trim(); - const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell; - if (shell === undefined || shell.length === 0) return undefined; - - // `env` prints the resolved environment in every shell dialect, unlike - // `echo $PATH`, which fish would join with spaces. Invoke it by absolute - // path: a bare `env` resolves through the inherited PATH — which may - // carry cwd-dependent components — from the workspace cwd, so a - // repo-planted `env` binary could run at session startup and feed us an - // arbitrary PATH. The absolute path also bypasses profile function - // shadowing, and /usr/bin/env is guaranteed on every mainstream POSIX - // system (it is the canonical shebang interpreter path). - const stdout = await deps.execFileText( - shell, - ['-l', '-c', '/usr/bin/env'], - LOGIN_SHELL_ENV_TIMEOUT_MS, - ); - if (stdout === undefined) return undefined; - - // Profile output lands on stdout before `env` runs, so keep the last - // PATH= line. - let path: string | undefined; - for (const line of stdout.split('\n')) { - if (line.startsWith('PATH=')) { - path = line.slice('PATH='.length).trim(); - } - } - if (path === undefined || path.length === 0) return undefined; - return path; -} - -/** - * Union of the current PATH and the login-shell PATH: the current PATH - * string is kept verbatim — including empty components, which POSIX - * command lookup treats as the current directory — and login-shell - * entries the current PATH lacks are appended in their own order. When - * nothing is missing the current string is returned unchanged. Only - * absolute login-shell entries are imported: empty, `.`, and relative - * components are all cwd-dependent lookup, and appending one the user - * did not already have would widen their search path — LocalKaos runs - * commands from arbitrary workspace directories. - */ -export function mergeLoginShellPath( - currentPath: string | undefined, - loginShellPath: string, -): string { - const current = currentPath ?? ''; - const seen = new Set(current.split(':').filter((entry) => entry.length > 0)); - const additions: string[] = []; - for (const entry of loginShellPath.split(':')) { - // The probe only runs on POSIX (win32 bails before merging), so a - // leading slash is a sufficient absoluteness test. Empty components - // fail it too. - if (!entry.startsWith('/') || seen.has(entry)) continue; - seen.add(entry); - additions.push(entry); - } - if (additions.length === 0) return current; - // `undefined` means "no PATH at all", so the additions stand alone; '' - // is a real (cwd-only) PATH whose empty component must survive as a - // leading colon. - if (currentPath === undefined) return additions.join(':'); - return `${current}:${additions.join(':')}`; -} - -/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */ -export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { - const loginShellPath = await probeLoginShellPath(deps); - if (loginShellPath === undefined) return; - const currentPath = deps.env['PATH']; - const merged = mergeLoginShellPath(currentPath, loginShellPath); - // Only write when something was appended — an unset PATH must stay - // unset (assigning '' would turn "implementation default search path" - // into "cwd-only lookup"), and a set PATH must not be rewritten. - if (merged === (currentPath ?? '')) return; - deps.env['PATH'] = merged; -} - -/** - * Production convenience — apply the probe to `process.env` once per - * process. Memoised like `detectEnvironmentFromNode`: the login-shell PATH - * does not change for the lifetime of the process, and repeated - * `LocalKaos.create()` calls must not re-spawn the shell. - */ -/** - * Login shell from the OS user database (`/etc/passwd` via getpwuid on - * Linux, Directory Services on macOS). `userInfo()` throws when the uid - * has no database entry (e.g. containers running an arbitrary uid), and - * service accounts may carry `/usr/sbin/nologin` — the latter needs no - * special casing here because probing it simply fails and degrades - * silently. - */ -function userShellFromNode(): string | undefined { - try { - const shell = userInfo().shell; - return shell === null || shell.length === 0 ? undefined : shell; - } catch { - return undefined; - } -} - -let appliedLoginShellPath: Promise | undefined; - -export function applyLoginShellPathFromNode(): Promise { - if (appliedLoginShellPath !== undefined) return appliedLoginShellPath; - appliedLoginShellPath = applyLoginShellPath({ - platform: process.platform, - env: process.env as Record, - userShell: userShellFromNode, - execFileText, - }); - return appliedLoginShellPath; -} diff --git a/packages/kaos/src/process.ts b/packages/kaos/src/process.ts deleted file mode 100644 index 9a19ecc70..000000000 --- a/packages/kaos/src/process.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Readable, Writable } from 'node:stream'; - -/** - * A running process spawned by a {@link Kaos} environment. - * - * Provides access to standard I/O streams, the process ID, and lifecycle - * management (wait / kill). The interface is intentionally minimal so it - * can be backed by local child processes, SSH sessions, or container runtimes. - */ -export interface KaosProcess { - /** Writable stream connected to the process's standard input. */ - readonly stdin: Writable; - /** Readable stream for the process's standard output. */ - readonly stdout: Readable; - /** Readable stream for the process's standard error. */ - readonly stderr: Readable; - /** Operating-system process ID. */ - readonly pid: number; - /** Exit code if the process has already terminated, otherwise `null`. */ - readonly exitCode: number | null; - /** Wait for the process to exit and return its exit code. */ - wait(): Promise; - /** Send a signal to the process (defaults to `SIGTERM`). */ - kill(signal?: NodeJS.Signals): Promise; - /** Release stdin/stdout/stderr resources owned by this process wrapper. */ - dispose(): Promise | void; -} diff --git a/packages/kaos/src/shell-path-bridge.ts b/packages/kaos/src/shell-path-bridge.ts deleted file mode 100644 index a9402eaa8..000000000 --- a/packages/kaos/src/shell-path-bridge.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Shell path bridge — translate between native win32 paths and the POSIX - * path dialect spoken by the MSYS2 / Git Bash shell. - * - * The msys runtime gives the shell a POSIX path view native Node.js cannot - * resolve (`/c/Users/x` is `C:\Users\x`; `/tmp/x` is `%TEMP%\x`, not - * `/tmp`). `toShellPath` renders native paths for bash command - * lines; `fromShellPath` resolves model/shell-supplied paths for fs access, - * translating drive-letter forms lexically and other root-relative paths - * through `cygpath -w` next to the probed bash. Anything unconvertible - * passes through unchanged, and both directions are identity outside win32 - * bash. - * - * Synchronous and self-contained (node builtins only): - * `createShellPathBridge` takes injectable deps for tests; - * `getShellPathBridge` bundles the Node defaults, memoised per env object. - */ - -import { execFileSync as nodeExecFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import * as nodePath from 'node:path'; - -import type { Environment } from './environment'; - -export interface ShellPathBridge { - /** Native win32 path → shell dialect, for building bash commands. Identity on posix. */ - toShellPath(nativePath: string): string; - /** Model/shell-supplied path → native, for fs access. Identity when not convertible. */ - fromShellPath(path: string): string; -} - -export type ShellPathBridgeEnv = Pick; - -export interface ShellPathBridgeDeps { - readonly execFileSync: (file: string, args: readonly string[]) => string; - readonly isFile: (path: string) => boolean; -} - -const CYGPATH_TIMEOUT_MS = 5_000; - -const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/; -const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/; -const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/; - -// cygpath semantics are undefined for the virtual filesystems. -const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/']; - -const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; - -function joinDrive(letter: string, rest: string): string { - const normalizedRest = rest.replaceAll('\\', '/'); - return normalizedRest === '' - ? `${letter.toUpperCase()}:/` - : `${letter.toUpperCase()}:${normalizedRest}`; -} - -/** - * Lexical translation of shell-dialect drive paths (`/c/x`, `/c:/x`, - * `/cygdrive/c/x`) to native win32 form — pure string rewriting, no cygpath - * involved. Anything else is returned unchanged. - */ -export function translateShellDrivePath(path: string): string { - const colonMatch = DRIVE_COLON_RE.exec(path); - if (colonMatch !== null) { - return joinDrive(colonMatch[1]!, path.slice(3)); - } - const cygdriveMatch = CYGDRIVE_RE.exec(path); - if (cygdriveMatch !== null) { - return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length)); - } - const driveMatch = DRIVE_RE.exec(path); - if (driveMatch !== null) { - return joinDrive(driveMatch[1]!, path.slice(2)); - } - return path; -} - -export function createShellPathBridge( - env: ShellPathBridgeEnv, - deps: ShellPathBridgeDeps, -): ShellPathBridge { - const enabled = env.osKind === 'Windows' && env.shellName === 'bash'; - - // Lazily located on first use; `null` = not found → permanent pass-through. - let cygpathExe: string | null | undefined; - // Cache successes only: a missing cygpath.exe is a stable fact, but an - // execution failure may be transient. First-segment caching is exact for - // default (first-level) mount tables; deeper user mounts are out of scope. - const segmentCache = new Map(); - - function locateCygpath(): string | null { - if (cygpathExe !== undefined) return cygpathExe; - const shellDir = nodePath.win32.dirname(env.shellPath); - const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')]; - if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') { - candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe')); - } - cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null; - return cygpathExe; - } - - function resolveRootSegment(firstSegment: string): string | null { - const cached = segmentCache.get(firstSegment); - if (cached !== undefined) return cached; - - const exe = locateCygpath(); - if (exe === null) return null; - let resolved: string; - try { - const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]); - // cygpath appends a newline and may emit a trailing separator (`D:\`). - const trimmed = output.replace(/\r?\n$/, ''); - if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null; - resolved = trimmed.replace(/[\\/]$/, ''); - } catch { - return null; - } - segmentCache.set(firstSegment, resolved); - return resolved; - } - - function fromShellPath(path: string): string { - if (!enabled) return path; - - // Keep UNC out first: posix.normalize would collapse the leading `//`. - if (path.startsWith('//')) return path; - - if (path.startsWith('/')) { - // Fold dot segments first: `/tmp/..` is `/` in the shell VFS, not `%TEMP%\..`. - const normalized = nodePath.posix.normalize(path); - const lexical = translateShellDrivePath(normalized); - if (lexical !== normalized) return lexical; - if (normalized === '/') return normalized; - if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized; - const firstSegment = normalized.slice(1).split('/')[0]!; - const prefix = resolveRootSegment(firstSegment); - if (prefix === null) return normalized; - const remainder = normalized.slice(firstSegment.length + 1); - const joined = `${prefix}${remainder}`.replaceAll('\\', '/'); - // A mounted drive root resolved from a bare segment (`D:`) stays absolute. - return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined; - } - - return path; - } - - function toShellPath(nativePath: string): string { - if (!enabled) return nativePath; - - if (nativePath.startsWith('\\\\')) { - return nativePath.replaceAll('\\', '/'); - } - - const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath); - if (driveMatch !== null) { - const drive = driveMatch[1]!.toLowerCase(); - const rest = nativePath.slice(2).replaceAll('\\', '/'); - return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; - } - - return nativePath.replaceAll('\\', '/'); - } - - return { toShellPath, fromShellPath }; -} - -const bridgeCache = new Map(); - -/** - * Production convenience — Node's ambient `execFileSync` / `existsSync`, - * memoised per shell identity so call sites that wrap the same probed - * environment in a fresh object still share one bridge. - */ -export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge { - const key = `${env.osKind} ${env.shellName} ${env.shellPath}`; - const cached = bridgeCache.get(key); - if (cached !== undefined) return cached; - const bridge = createShellPathBridge(env, { - execFileSync: (file, args) => - nodeExecFileSync(file, [...args], { - encoding: 'utf8', - timeout: CYGPATH_TIMEOUT_MS, - windowsHide: true, - }), - isFile: (path) => existsSync(path), - }); - bridgeCache.set(key, bridge); - return bridge; -} diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts deleted file mode 100644 index 3b897f9aa..000000000 --- a/packages/kaos/src/ssh.ts +++ /dev/null @@ -1,945 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { isAbsolute, join, normalize, resolve } from 'pathe'; -import type { Readable, Writable } from 'node:stream'; - -import * as ssh2 from 'ssh2'; -import type { - AnyAuthMethod, - Client, - ClientChannel, - ConnectConfig, - SFTPWrapper, - Stats as SFTPStats, -} from 'ssh2'; - -import type { Environment } from './environment'; -import { KaosError, KaosFileExistsError, KaosValueError } from './errors'; -import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal'; -import type { Kaos } from './kaos'; -import type { KaosProcess } from './process'; -import type { StatResult } from './types'; - -// ── stat mode constants ──────────────────────────────────────────────── -const S_IFMT = 0o170000; -const S_IFREG = 0o100000; -const S_IFDIR = 0o040000; -const S_IFLNK = 0o120000; -const S_IFSOCK = 0o140000; -const S_IFCHR = 0o020000; -const S_IFBLK = 0o060000; -const S_IFIFO = 0o010000; - -const DEFAULT_SFTP_STATUS_CODE = { - BAD_MESSAGE: 5, - CONNECTION_LOST: 7, - FAILURE: 4, - NO_CONNECTION: 6, - NO_SUCH_FILE: 2, - OP_UNSUPPORTED: 8, - PERMISSION_DENIED: 3, -} as const; - -// ── SSH options ──────────────────────────────────────────────────────── - -/** - * Advanced ssh2 connect options that may be passed through `SSHKaosOptions.extraOptions`. - * - * Excludes fields that SSHKaos manages itself (`host`, `port`, `username`, - * `password`, `privateKey`, `authHandler`, `hostVerifier`) — those are derived - * from the top-level `SSHKaosOptions` fields and cannot be overridden here. - */ -export type SSHKaosExtraOptions = Omit< - ConnectConfig, - 'host' | 'port' | 'username' | 'password' | 'privateKey' | 'authHandler' | 'hostVerifier' ->; - -export interface SSHKaosOptions { - host: string; - port?: number; - username: string; - password?: string; - keyPaths?: string[]; - keyContents?: string[]; - cwd?: string; - /** - * Pass-through for advanced ssh2 `ConnectConfig` fields such as `algorithms`, - * `keepaliveInterval`, `readyTimeout`, `debug`, `tryKeyboard`, `agent`, etc. - * - * Managed fields (`host`, `port`, `username`, `password`, `privateKey`, - * `authHandler`, `hostVerifier`) are excluded from this type and will take - * precedence over anything set here. - */ - extraOptions?: SSHKaosExtraOptions; -} - -// ── SSH error types ─────────────────────────────────────────────────── - -export class KaosSSHError extends KaosError { - readonly code: number | undefined; - - constructor(message: string, code?: number) { - super(message); - this.name = 'KaosSSHError'; - this.code = code; - } -} - -export class KaosFileNotFoundError extends KaosSSHError { - constructor(message: string, code?: number) { - super(message, code); - this.name = 'KaosFileNotFoundError'; - } -} - -export class KaosPermissionError extends KaosSSHError { - constructor(message: string, code?: number) { - super(message, code); - this.name = 'KaosPermissionError'; - } -} - -export class KaosConnectionError extends KaosSSHError { - constructor(message: string, code?: number) { - super(message, code); - this.name = 'KaosConnectionError'; - } -} - -// ── shell quoting ────────────────────────────────────────────────────── - -/** - * Shell-escape a single argument (POSIX sh compatible). - * Mirrors Python's shlex.quote(). - */ -function shellQuote(arg: string): string { - if (arg === '') return "''"; - // If the string is safe (only contains safe chars), return as-is - if (/^[A-Za-z0-9_./:=@%^,+-]+$/.test(arg)) return arg; - // Otherwise wrap in single quotes, escaping any embedded single quotes - return "'" + arg.replaceAll("'", "'\"'\"'") + "'"; -} - -// ── stat mode builder ────────────────────────────────────────────────── - -/** - * Build a POSIX st_mode from SFTP Stats. - * ssh2's Stats has .mode which already includes both file-type bits and - * permission bits, but we also check the boolean helpers as a fallback. - */ -function buildStMode(attrs: SFTPStats): number { - const raw = attrs.mode; - // If mode already contains file-type bits, return as-is - if ((raw & S_IFMT) !== 0) return raw; - - // Derive file-type bits from the is* helpers - let typeBits = 0; - if (attrs.isDirectory()) typeBits = S_IFDIR; - else if (attrs.isFile()) typeBits = S_IFREG; - else if (attrs.isSymbolicLink()) typeBits = S_IFLNK; - else if (attrs.isSocket()) typeBits = S_IFSOCK; - else if (attrs.isCharacterDevice()) typeBits = S_IFCHR; - else if (attrs.isBlockDevice()) typeBits = S_IFBLK; - else if (attrs.isFIFO()) typeBits = S_IFIFO; - - return (raw & ~S_IFMT) | typeBits; -} - -function getSftpStatusCode(): typeof DEFAULT_SFTP_STATUS_CODE { - return { - ...DEFAULT_SFTP_STATUS_CODE, - ...ssh2.utils?.sftp?.STATUS_CODE, - }; -} - -function getErrorCode(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) { - return undefined; - } - const { code } = error as { code?: unknown }; - return typeof code === 'number' ? code : undefined; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function mapSftpError(operation: string, error: unknown): KaosSSHError { - const code = getErrorCode(error); - const message = `${operation} failed: ${getErrorMessage(error)}`; - const statusCode = getSftpStatusCode(); - - if (code === statusCode.NO_SUCH_FILE) { - return new KaosFileNotFoundError(message, code); - } - if (code === statusCode.PERMISSION_DENIED) { - return new KaosPermissionError(message, code); - } - if (code === statusCode.NO_CONNECTION || code === statusCode.CONNECTION_LOST) { - return new KaosConnectionError(message, code); - } - return new KaosSSHError(message, code); -} - -function buildAuthHandler( - username: string, - privateKeys: readonly (Buffer | string)[], - password?: string, -): ConnectConfig['authHandler'] { - const authQueue: AnyAuthMethod[] = privateKeys.map((key) => ({ - key, - type: 'publickey', - username, - })); - if (password !== undefined) { - authQueue.push({ - password, - type: 'password', - username, - }); - } - - let index = 0; - return (_authsLeft, _partialSuccess, next) => { - const nextAuth = authQueue[index]; - index += 1; - const nextWithFalse = next as (auth: AnyAuthMethod | false) => void; - nextWithFalse(nextAuth ?? false); - }; -} - -// ── SSH process ──────────────────────────────────────────────────────── - -/** Exported for unit tests only. Do not use directly. */ -export class SSHProcess implements KaosProcess { - readonly stdin: Writable; - readonly stdout: Readable; - readonly stderr: Readable; - readonly pid: number = -1; - - private _exitCode: number | null = null; - private readonly _exitPromise: Promise; - private readonly _channel: ClientChannel; - private _disposed = false; - - constructor(channel: ClientChannel) { - this._channel = channel; - this.stdin = channel; - this.stdout = new BufferedReadable(channel as unknown as Readable); - this.stderr = new BufferedReadable(channel.stderr); - - this._exitPromise = new Promise((resolve) => { - // Listen to 'close' on the channel, not 'exit', to ensure all - // buffered output is flushed before we resolve. - channel.on('close', (code: number | null) => { - // Some ssh2 backends surface the exit status only on 'close'. - this._exitCode ??= code ?? 1; - resolve(this._exitCode); - }); - channel.on('exit', (code: number | null) => { - this._exitCode = code ?? 1; - }); - }); - } - - get exitCode(): number | null { - return this._exitCode; - } - - async wait(): Promise { - return this._exitPromise; - } - - kill(signal?: NodeJS.Signals): Promise { - // SSH signals must be stripped of the "SIG" prefix (RFC 4254 §6.9): - // e.g. 'SIGTERM' → 'TERM', 'SIGKILL' → 'KILL', 'SIGINT' → 'INT'. - // Honor the caller's requested signal so that remote processes can - // perform graceful shutdown on SIGTERM/SIGINT. - const rawSignal = signal ?? 'SIGTERM'; - const sshSignal = rawSignal.startsWith('SIG') ? rawSignal.slice(3) : rawSignal; - this._channel.signal(sshSignal); - return Promise.resolve(); - } - - dispose(): void { - if (this._disposed) return; - this._disposed = true; - this.stdin.destroy(); - this.stdout.destroy(); - this.stderr.destroy(); - } -} - -// ── Promisified SSH helpers ──────────────────────────────────────────── - -function connectClient(config: ConnectConfig): Promise { - const client = new ssh2.Client(); - return new Promise((resolve, reject) => { - client.on('ready', () => { - resolve(client); - }); - client.on('error', (err: Error) => { - reject(err); - }); - client.connect(config); - }); -} - -function getSftp(client: Client): Promise { - return new Promise((resolve, reject) => { - client.sftp((err, sftp) => { - if (err) { - reject(err); - } else { - resolve(sftp); - } - }); - }); -} - -// Every promisified SFTP helper funnels rejections through `mapSftpError` so -// callers see a KaosSSHError subclass (KaosFileNotFoundError / KaosPermissionError / -// KaosConnectionError / generic KaosSSHError) instead of the raw ssh2 error. -// The operation label is the underlying SFTP RPC name — it shows up in the -// error message for debugging and is the same label used by `stat()` before -// this was hoisted into the helpers. - -function sftpRealpath(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.realpath(path, (err, absPath) => { - if (err) { - reject(mapSftpError('realpath', err)); - } else { - resolve(absPath); - } - }); - }); -} - -function sftpStat(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.stat(path, (err, stats) => { - if (err) { - reject(mapSftpError('stat', err)); - } else { - resolve(stats); - } - }); - }); -} - -function sftpLstat(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.lstat(path, (err, stats) => { - if (err) { - reject(mapSftpError('lstat', err)); - } else { - resolve(stats); - } - }); - }); -} - -interface SFTPFileEntry { - filename: string; - attrs: SFTPStats; -} - -function sftpReaddir(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.readdir(path, (err, list) => { - if (err) { - reject(mapSftpError('readdir', err)); - } else { - resolve(list as SFTPFileEntry[]); - } - }); - }); -} - -function sftpMkdir(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.mkdir(path, (err) => { - if (err) { - reject(mapSftpError('mkdir', err)); - } else { - resolve(); - } - }); - }); -} - -function sftpExists(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve) => { - sftp.exists(path, (exists) => { - resolve(exists); - }); - }); -} - -function sftpReadFile(sftp: SFTPWrapper, path: string): Promise { - return new Promise((resolve, reject) => { - sftp.readFile(path, (err, data) => { - if (err) { - reject(mapSftpError('readFile', err)); - } else { - resolve(data); - } - }); - }); -} - -function sftpWriteFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise { - return new Promise((resolve, reject) => { - sftp.writeFile(path, data, (err) => { - if (err) { - reject(mapSftpError('writeFile', err)); - } else { - resolve(); - } - }); - }); -} - -function sftpAppendFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise { - return new Promise((resolve, reject) => { - sftp.appendFile(path, data, (err) => { - if (err) { - reject(mapSftpError('appendFile', err)); - } else { - resolve(); - } - }); - }); -} - -function clientExec(client: Client, command: string): Promise { - return new Promise((resolve, reject) => { - client.exec(command, (err: Error | undefined, channel: ClientChannel) => { - if (err) { - reject(err); - } else { - resolve(channel); - } - }); - }); -} - -// ── SSHKaos ──────────────────────────────────────────────────────────── - -/** - * A KAOS implementation that interacts with a remote machine via SSH and SFTP. - */ -export class SSHKaos implements Kaos { - readonly name: string = 'ssh'; - - private _client: Client; - private _sftp: SFTPWrapper; - private _home: string; - private _cwd: string; - private readonly _envLayers: readonly Record[]; - - // Stub: real wiring (probing the remote host via `uname` / `$SHELL` over the - // SSH transport) is deferred. - get osEnv(): Environment { - throw new KaosError( - 'SSHKaos.osEnv is not yet wired — remote environment probing is not implemented.', - ); - } - - private constructor( - client: Client, - sftp: SFTPWrapper, - home: string, - cwd: string, - envLayers: readonly Record[] = [], - ) { - this._client = client; - this._sftp = sftp; - this._home = home; - this._cwd = cwd; - this._envLayers = envLayers; - } - - withCwd(cwd: string): SSHKaos { - return new SSHKaos(this._client, this._sftp, this._home, cwd, this._envLayers); - } - - withEnv(env: Record): SSHKaos { - return new SSHKaos(this._client, this._sftp, this._home, this._cwd, [...this._envLayers, env]); - } - - private _resolvePath(path: string): string { - if (isAbsolute(path)) return path; - return join(this._cwd, path); - } - - /** - * Factory method to create an SSHKaos instance. - * Establishes the SSH connection and SFTP session. - */ - static async create(options: SSHKaosOptions): Promise { - // Start from extraOptions (advanced ssh2 options) so our managed fields - // below take precedence. - const config: ConnectConfig = { - ...options.extraOptions, - host: options.host, - port: options.port ?? 22, - username: options.username, - }; - - if (options.password !== undefined) { - config.password = options.password; - } - - // Build private keys from keyContents and keyPaths - const privateKeys: (Buffer | string)[] = []; - if (options.keyContents) { - for (const content of options.keyContents) { - privateKeys.push(content); - } - } - if (options.keyPaths) { - const keyPromises = options.keyPaths.map((keyPath) => readFile(keyPath, 'utf-8')); - const keyData = await Promise.all(keyPromises); - for (const key of keyData) { - privateKeys.push(key); - } - } - if (privateKeys.length > 0) { - const authHandler = buildAuthHandler(options.username, privateKeys, options.password); - if (authHandler !== undefined) { - config.authHandler = authHandler; - } - } - - // Disable host key verification (like asyncssh known_hosts=None) - config.hostVerifier = () => true; - - const client = await connectClient(config); - try { - const sftp = await getSftp(client); - - // Determine home and cwd - const home = await sftpRealpath(sftp, '.'); - let cwd: string; - if (options.cwd === undefined) { - cwd = home; - } else { - cwd = await sftpRealpath(sftp, options.cwd); - const attrs = await sftpStat(sftp, cwd); - if (!attrs.isDirectory()) { - throw new KaosValueError(`${cwd} is not a directory`); - } - } - - return new SSHKaos(client, sftp, home, cwd); - } catch (error) { - client.end(); - throw error; - } - } - - // ── Path operations (sync) ───────────────────────────────────────── - - pathClass(): 'posix' | 'win32' { - return 'posix'; - } - - normpath(path: string): string { - return normalize(path); - } - - gethome(): string { - return this._home; - } - - getcwd(): string { - return this._cwd; - } - - // ── Directory operations (async) ─────────────────────────────────── - - async chdir(path: string): Promise { - let target: string; - if (isAbsolute(path)) { - target = path; - } else { - target = resolve(this._cwd, path); - } - // Resolve to the real path via SFTP - const resolved = await sftpRealpath(this._sftp, target); - // Verify the resolved target is actually a directory. Without this - // guard, `realpath` happily returns file paths, causing later relative - // reads/writes/execs to treat a regular file as a working directory. - const attrs = await sftpStat(this._sftp, resolved); - if (!attrs.isDirectory()) { - throw new KaosValueError(`${resolved} is not a directory`); - } - this._cwd = resolved; - } - - async stat(path: string, options?: { followSymlinks?: boolean }): Promise { - const resolved = this._resolvePath(path); - const followSymlinks = options?.followSymlinks ?? true; - // sftpStat / sftpLstat already wrap errors via mapSftpError. - const st = followSymlinks - ? await sftpStat(this._sftp, resolved) - : await sftpLstat(this._sftp, resolved); - - return { - stMode: buildStMode(st), - // SFTP does not provide inode - stIno: 0, - // SFTP does not provide device - stDev: 0, - // ssh2 Stats does not expose nlink - stNlink: 0, - stUid: st.uid, - stGid: st.gid, - stSize: st.size, - stAtime: st.atime, - stMtime: st.mtime, - // SFTP v3 has no ctime, fallback to mtime - stCtime: st.mtime, - }; - } - - async *iterdir(path: string): AsyncGenerator { - const resolved = this._resolvePath(path); - const entries = await sftpReaddir(this._sftp, resolved); - for (const entry of entries) { - if (entry.filename === '.' || entry.filename === '..') continue; - yield join(resolved, entry.filename); - } - } - - async *glob( - path: string, - pattern: string, - options?: { caseSensitive?: boolean }, - ): AsyncGenerator { - const resolved = this._resolvePath(path); - const caseSensitive = options?.caseSensitive ?? true; - if (!caseSensitive) { - throw new KaosValueError('Case insensitive glob is not supported in current environment'); - } - // Use local glob implementation over SFTP readdir - const patternParts = pattern.split('/'); - yield* this._globWalk(resolved, patternParts, caseSensitive); - } - - private async *_globWalk( - basePath: string, - patternParts: string[], - caseSensitive: boolean, - ): AsyncGenerator { - if (patternParts.length === 0) return; - - const [currentPattern, ...remainingParts] = patternParts; - - if (currentPattern === '**') { - // `**` matches zero or more directory components. - // - // Two cases to handle: - // (a) `**` matches zero directories → continue at basePath with - // the remaining pattern parts (or yield basePath when `**` is - // the final segment). - // (b) `**` matches one or more directories → recurse into each - // subdirectory, keeping `**` (the full patternParts) at the - // front. The "zero directories" case is re-evaluated at the - // subdirectory level by that recursive call. - // - // Do NOT additionally recurse with `remainingParts` on subdirectories - // — that would double-count matches at depth ≥ 1 because case (a) - // inside the child recursion already yields those results. - if (remainingParts.length > 0) { - yield* this._globWalk(basePath, remainingParts, caseSensitive); - } else { - // Pattern ends with `**`: yield basePath itself (zero-dir match). - yield basePath; - } - - let entries: SFTPFileEntry[]; - try { - entries = await sftpReaddir(this._sftp, basePath); - } catch { - return; - } - - for (const entry of entries) { - if (entry.filename === '.' || entry.filename === '..') continue; - const fullPath = join(basePath, entry.filename); - if (entry.attrs.isDirectory()) { - yield* this._globWalk(fullPath, patternParts, caseSensitive); - } else if (remainingParts.length === 0) { - // Pattern ends with `**`: non-directory entries match too. - yield fullPath; - } - } - } else { - const regex = globPatternToRegex(currentPattern ?? '', caseSensitive); - - let entries: SFTPFileEntry[]; - try { - entries = await sftpReaddir(this._sftp, basePath); - } catch { - return; - } - - for (const entry of entries) { - if (entry.filename === '.' || entry.filename === '..') continue; - if (!regex.test(entry.filename)) continue; - - const fullPath = join(basePath, entry.filename); - - if (remainingParts.length === 0) { - yield fullPath; - } else if (entry.attrs.isDirectory()) { - yield* this._globWalk(fullPath, remainingParts, caseSensitive); - } - } - } - } - - // ── File operations (async) ──────────────────────────────────────── - - async readBytes(path: string, n?: number): Promise { - const data = await sftpReadFile(this._sftp, this._resolvePath(path)); - if (n === undefined) return data; - return data.subarray(0, n); - } - - async readText( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, - ): Promise { - const encoding = options?.encoding ?? 'utf-8'; - const errors = options?.errors ?? 'strict'; - const data = await sftpReadFile(this._sftp, this._resolvePath(path)); - return decodeTextWithErrors(data, encoding, errors); - } - - async *readLines( - path: string, - options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, - ): AsyncGenerator { - // SFTP does not support streaming line reads; read all then split. - // Match Python's splitlines() semantics: returned lines do NOT include - // the line terminator, and a trailing newline does not create an extra - // empty line. - const text = await this.readText(this._resolvePath(path), options); - if (text === '') { - return; - } - - const lines = text.split(/\r\n|[\n\r]/u); - if (/(?:\r\n|[\n\r])$/u.test(text)) { - lines.pop(); - } - for (const line of lines) { - yield line ?? ''; - } - } - - async writeBytes(path: string, data: Buffer): Promise { - await sftpWriteFile(this._sftp, this._resolvePath(path), data); - return data.length; - } - - async writeText( - path: string, - data: string, - options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, - ): Promise { - const resolved = this._resolvePath(path); - const mode = options?.mode ?? 'w'; - const encoding = options?.encoding ?? 'utf-8'; - const buf = Buffer.from(data, encoding); - if (mode === 'a') { - await sftpAppendFile(this._sftp, resolved, buf); - } else { - await sftpWriteFile(this._sftp, resolved, buf); - } - return data.length; - } - - async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { - const resolved = this._resolvePath(path); - const parents = options?.parents ?? false; - const existOk = options?.existOk ?? false; - - if (parents) { - await this._mkdirRecursive(resolved, existOk); - } else { - const exists = await sftpExists(this._sftp, resolved); - if (exists) { - if (!existOk) { - throw new KaosFileExistsError(`${resolved} already exists`); - } - // `existOk` only applies when the conflicting path is itself a - // directory. A regular file sitting at the target path is still - // a conflict — we must not pretend mkdir succeeded. - const st = await sftpStat(this._sftp, resolved); - if (!st.isDirectory()) { - throw new KaosFileExistsError(`${resolved} already exists but is not a directory`); - } - return; - } - await sftpMkdir(this._sftp, resolved); - } - } - - private async _mkdirRecursive(path: string, existOk: boolean): Promise { - // Split path into components and create each level. - const parts = path.split('/').filter(Boolean); - let current = path.startsWith('/') ? '/' : ''; - const lastIndex = parts.length - 1; - for (const [i, part] of parts.entries()) { - current = current ? join(current, part) : part; - - const isFinal = i === lastIndex; - - // eslint-disable-next-line no-await-in-loop - const exists = await sftpExists(this._sftp, current); - if (exists) { - // For intermediate components, it's fine (and expected) for the - // path to already exist. For the final target, honor `existOk`. - if (isFinal && !existOk) { - throw new KaosFileExistsError(`${current} already exists`); - } - // Regardless of whether this is an intermediate or the final - // component, an existing path must actually be a directory. - // An intermediate non-directory would cause the next `sftpMkdir` - // to fail with a confusing error; a final non-directory would - // otherwise be silently accepted when `existOk` is true. - // eslint-disable-next-line no-await-in-loop - const st = await sftpStat(this._sftp, current); - if (!st.isDirectory()) { - throw new KaosFileExistsError(`${current} already exists but is not a directory`); - } - continue; - } - try { - // eslint-disable-next-line no-await-in-loop - await sftpMkdir(this._sftp, current); - } catch (error) { - // Race condition: another process may have created it. - // eslint-disable-next-line no-await-in-loop - const nowExists = await sftpExists(this._sftp, current); - if (!nowExists) throw new Error(`Failed to create directory: ${current}`, { cause: error }); - // A raced path must still be a directory. Another process may have - // created a regular file at the same pathname after our exists() - // check but before mkdir(), which must remain a hard conflict. - // eslint-disable-next-line no-await-in-loop - const st = await sftpStat(this._sftp, current); - if (!st.isDirectory()) { - throw new KaosFileExistsError(`${current} already exists but is not a directory`); - } - // If the final component lost a race and existOk=false, surface the - // conflict to match the non-race path above. - if (isFinal && !existOk) { - throw new KaosFileExistsError(`${current} already exists`); - } - } - } - } - - // ── Process execution ────────────────────────────────────────────── - - exec(...args: string[]): Promise { - if (args.length === 0) { - throw new KaosValueError( - 'SSHKaos.exec(): at least one argument (the command to run) is required.', - ); - } - return this._execInternal(args, this._buildExecEnv()); - } - - execWithEnv(args: string[], env?: Record): Promise { - if (args.length === 0) { - throw new KaosValueError( - 'SSHKaos.execWithEnv(): at least one argument (the command to run) is required.', - ); - } - return this._execInternal(args, this._buildExecEnv(env)); - } - - private _buildExecEnv(invocationEnv?: Record): Record | undefined { - if (this._envLayers.length === 0) return invocationEnv; - const merged: Record = { ...invocationEnv }; - for (const layer of this._envLayers) { - Object.assign(merged, layer); - } - return merged; - } - - /** - * Build the full remote shell command string that will be handed to - * `client.exec`. Exposed as a static so it can be unit-tested without - * needing a live SSH connection — see `ssh.test.ts`. - * - * Shape: `cd '' && KEY1='v1' KEY2='v2' ...` - * - * Environment variables are injected as POSIX inline assignments instead - * of being passed through ssh2's `ExecOptions.env`. The env-request path - * silently drops anything not whitelisted by sshd's `AcceptEnv` directive - * (stock OpenSSH only allows LANG/LC_*), which is a well-known footgun - * inherited from the Python / asyncssh implementation. Inline assignments - * run inside the remote shell itself, so they bypass AcceptEnv entirely - * and reach the command regardless of server configuration. - */ - private static _buildExecCommand( - args: string[], - cwd: string, - env?: Record, - ): string { - let command = args.map((arg) => shellQuote(arg)).join(' '); - - if (env !== undefined) { - const assignments: string[] = []; - for (const [key, value] of Object.entries(env)) { - // Reject anything that isn't a POSIX-valid shell variable name so - // the injected prefix can never become a shell-injection vector. - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - throw new KaosValueError( - `SSHKaos.execWithEnv(): invalid env variable name ${JSON.stringify(key)}`, - ); - } - assignments.push(`${key}=${shellQuote(value)}`); - } - if (assignments.length > 0) { - command = `${assignments.join(' ')} ${command}`; - } - } - - if (cwd !== '') { - command = `cd ${shellQuote(cwd)} && ${command}`; - } - - return command; - } - - private async _execInternal(args: string[], env?: Record): Promise { - const command = SSHKaos._buildExecCommand(args, this._cwd, env); - const channel = await clientExec(this._client, command); - return new SSHProcess(channel); - } - - // ── SSH lifecycle ────────────────────────────────────────────────── - - /** - * Close the SSH connection. After this, the SSHKaos instance is unusable. - */ - close(): Promise { - this._sftp.end(); - return new Promise((resolve) => { - this._client.once('close', () => { - resolve(); - }); - this._client.end(); - }); - } -} diff --git a/packages/kaos/src/types.ts b/packages/kaos/src/types.ts deleted file mode 100644 index 7ef03d33d..000000000 --- a/packages/kaos/src/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * KAOS stat result, mirroring Python's os.stat_result fields. - */ -export interface StatResult { - stMode: number; - stIno: number; - stDev: number; - stNlink: number; - stUid: number; - stGid: number; - stSize: number; - stAtime: number; - stMtime: number; - stCtime: number; -} diff --git a/packages/kaos/test/cmd.test.ts b/packages/kaos/test/cmd.test.ts deleted file mode 100644 index 4e3ac3c4b..000000000 --- a/packages/kaos/test/cmd.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import type { Kaos } from '#/kaos'; -import { LocalKaos } from '#/local'; -import type { KaosProcess } from '#/process'; - -/** - * Helper to run a cmd.exe command and collect stdout/stderr/exitCode. - * Prepends `chcp 65001>nul &` to ensure UTF-8 output. - */ -async function runCmd( - kaos: Kaos, - command: string, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const proc: KaosProcess = await kaos.exec('cmd.exe', '/c', `chcp 65001>nul & ${command}`); - - proc.stdin.end(); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - const stdoutDone = new Promise((resolve) => { - proc.stdout.on('data', (chunk: Buffer) => { - stdoutChunks.push(chunk); - }); - proc.stdout.on('end', () => { - resolve(); - }); - }); - - const stderrDone = new Promise((resolve) => { - proc.stderr.on('data', (chunk: Buffer) => { - stderrChunks.push(chunk); - }); - proc.stderr.on('end', () => { - resolve(); - }); - }); - - const exitCode = await proc.wait(); - await stdoutDone; - await stderrDone; - - return { - stdout: Buffer.concat(stdoutChunks).toString('utf-8'), - stderr: Buffer.concat(stderrChunks).toString('utf-8'), - exitCode, - }; -} - -describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => { - let kaos: Kaos; - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'kaos-cmd-')); - kaos = await LocalKaos.create(); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('should run a simple command', async () => { - const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo Hello Windows'); - expect(exitCode).toBe(0); - expect(stdout.trim()).toBe('Hello Windows'); - expect(stderr).toBe(''); - }); - - it('should handle command with error exit', async () => { - // `exit /b 1` must produce neither stdout nor stderr — pinning that - // keeps us honest if cmd.exe or the chcp prefix ever leaks output. - const { exitCode, stdout, stderr } = await runCmd(kaos, 'exit /b 1'); - expect(exitCode).toBe(1); - expect(stdout).toBe(''); - expect(stderr).toBe(''); - }); - - it('should support command chaining', async () => { - const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo First&& echo Second'); - expect(exitCode).toBe(0); - expect(stdout.replaceAll('\r\n', '\n')).toBe('First\nSecond\n'); - expect(stderr).toBe(''); - }); - - it('should perform file operations', async () => { - // Write via kaos (avoids cmd.exe redirect quoting quirks on Windows where - // Node's auto-escaping of the redirected path breaks the command), then - // read back via `type` and pin the exact stdout byte-for-byte. - const filePath = join(tmpDir, 'test_file.txt').replaceAll('/', '\\'); - - await kaos.writeText(filePath, 'Test content\r\n'); - - const statInfo = await fsStat(filePath); - expect(statInfo.isFile()).toBe(true); - - // The path contains no spaces (tmpDir is under the 8.3 short-name temp - // dir), so pass it unquoted: wrapping it in `"…"` would make Node's - // Windows arg-quoting escape the inner quotes to `\"`, which cmd.exe - // does not unescape — leaving `type` looking for a literal `\"…\"`. - const read = await runCmd(kaos, `type ${filePath}`); - expect(read.exitCode).toBe(0); - expect(read.stdout).toBe('Test content\r\n'); - expect(read.stderr).toBe(''); - }); -}); diff --git a/packages/kaos/test/current.test.ts b/packages/kaos/test/current.test.ts deleted file mode 100644 index cf814babe..000000000 --- a/packages/kaos/test/current.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { - execWithEnv, - getCurrentKaos, - LocalKaos, - normpath, - pathClass, - readLines, - readText, - writeText, -} from '#/index'; - -describe('getCurrentKaos', () => { - it('returns the LocalKaos bound by the test setup', () => { - const kaos = getCurrentKaos(); - expect(kaos).toBeInstanceOf(LocalKaos); - expect(kaos.name).toBe('local'); - }); -}); - -describe('module-level proxy functions', () => { - it('normpath delegates to the current kaos instance', () => { - // LocalKaos on posix normalizes '/foo/../bar' to '/bar' - const result = normpath('/foo/../bar'); - expect(typeof result).toBe('string'); - expect(result.endsWith('bar')).toBe(true); - }); - - it('pathClass returns posix or win32 from the current kaos', () => { - const result = pathClass(); - expect(result === 'posix' || result === 'win32').toBe(true); - }); - - it('readLines proxies to the current kaos and yields lines', async () => { - const dir = await mkdtemp(join(tmpdir(), 'kaos-readlines-')); - try { - const filePath = join(dir, 'lines.txt'); - await writeText(filePath, 'alpha\nbravo\ncharlie'); - const collected: string[] = []; - for await (const line of readLines(filePath)) { - collected.push(line); - } - // readLines preserves newline terminators on each line. - expect(collected).toEqual(['alpha\n', 'bravo\n', 'charlie']); - expect(collected.join('')).toBe('alpha\nbravo\ncharlie'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('writeText accepts an encoding option through the module-level proxy', async () => { - const dir = await mkdtemp(join(tmpdir(), 'kaos-writetext-enc-')); - try { - const filePath = join(dir, 'enc.txt'); - // Pass a non-default encoding to prove the option flows through the - // proxy signature without a TypeScript error. - await writeText(filePath, 'hello-latin1', { encoding: 'latin1' }); - const contents = await readText(filePath, { encoding: 'latin1' }); - expect(contents).toBe('hello-latin1'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('execWithEnv proxies to the current kaos', async () => { - // Use the real LocalKaos to run `env | grep CUSTOM_VAR` - const proc = await execWithEnv(['sh', '-c', 'echo "$CUSTOM_VAR"'], { - CUSTOM_VAR: 'proxy_test_value', - // Preserve PATH so sh can be found - PATH: process.env['PATH'] ?? '/usr/bin:/bin', - }); - - const chunks: Buffer[] = []; - for await (const chunk of proc.stdout) { - chunks.push(chunk as Buffer); - } - const stdout = Buffer.concat(chunks).toString('utf-8').trim(); - await proc.wait(); - - expect(stdout).toBe('proxy_test_value'); - }); -}); diff --git a/packages/kaos/test/e2e/concurrent-operations.test.ts b/packages/kaos/test/e2e/concurrent-operations.test.ts deleted file mode 100644 index 1bf3475ca..000000000 --- a/packages/kaos/test/e2e/concurrent-operations.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { mkdtemp, realpath, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { LocalKaos } from '#/local'; - -// ── Tests ───────────────────────────────────────────────────────────── - -describe('e2e: concurrent operations', () => { - let kaos: LocalKaos; - let tempDir: string; - let originalCwd: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - originalCwd = process.cwd(); - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-concurrent-'))); - process.chdir(tempDir); - }); - - afterEach(async () => { - process.chdir(originalCwd); - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('concurrent reads and writes on different files', () => { - it('10 concurrent writeText + readText on separate files → all consistent', async () => { - const count = 10; - const promises = Array.from({ length: count }, async (_, i): Promise => { - const filePath = join(tempDir, `file-${i}.txt`); - const content = `content-${i}-${'data'.repeat(100)}`; - await kaos.writeText(filePath, content); - const readBack = await kaos.readText(filePath); - expect(readBack).toBe(content); - }); - - await Promise.all(promises); - }); - }); - - describe('concurrent writes to the same file', () => { - it('sequential writes to same file → last write wins', async () => { - const filePath = join(tempDir, 'shared.txt'); - const writes = 20; - - // Write sequentially to guarantee ordering - for (let i = 0; i < writes; i++) { - await kaos.writeText(filePath, `version-${i}`); - } - - // The file should have the last written content - const content = await kaos.readText(filePath); - expect(content).toBe(`version-${writes - 1}`); - }); - - it('concurrent appends to same file → all data present', async () => { - const filePath = join(tempDir, 'append-target.txt'); - await kaos.writeText(filePath, ''); - - const count = 20; - const promises: Promise[] = []; - - for (let i = 0; i < count; i++) { - promises.push(kaos.writeText(filePath, `line-${i}\n`, { mode: 'a' })); - } - - await Promise.all(promises); - - const content = await kaos.readText(filePath); - const lines = content.trimEnd().split('\n'); - - // All lines should be present (order may vary due to concurrency) - expect(lines).toHaveLength(count); - const lineSet = new Set(lines); - for (let i = 0; i < count; i++) { - expect(lineSet.has(`line-${i}`)).toBe(true); - } - }); - }); - - describe('concurrent exec of multiple subprocesses', () => { - it('5 concurrent node processes → all complete independently', async () => { - const count = 5; - const promises = Array.from( - { length: count }, - async (_, i): Promise<{ index: number; exitCode: number; stdout: string }> => { - const code = `process.stdout.write('proc-${i}');`; - const proc = await kaos.exec('node', '-e', code); - const exitCode = await proc.wait(); - - const chunks: Buffer[] = []; - for await (const chunk of proc.stdout) { - chunks.push(Buffer.from(chunk as Buffer)); - } - const stdout = Buffer.concat(chunks).toString('utf-8'); - - return { index: i, exitCode, stdout }; - }, - ); - - const results = await Promise.all(promises); - - for (const result of results) { - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`proc-${result.index}`); - } - }); - - it('concurrent processes with different exit codes', async () => { - const exitCodes = [0, 1, 2, 42, 0]; - const promises = exitCodes.map(async (code) => { - const proc = await kaos.exec('node', '-e', `process.exit(${code})`); - return proc.wait(); - }); - - const results = await Promise.all(promises); - expect(results).toEqual(exitCodes); - }); - }); - - describe('concurrent iterdir + file creation', () => { - it('iterdir does not crash when files are being created concurrently', async () => { - // Pre-create some files - for (let i = 0; i < 5; i++) { - await kaos.writeText(join(tempDir, `existing-${i}.txt`), `data-${i}`); - } - - // Start iterdir and file creation concurrently - const iterdirPromise = (async (): Promise => { - const entries: string[] = []; - for await (const entry of kaos.iterdir(tempDir)) { - entries.push(entry); - } - return entries; - })(); - - const creationPromise = (async (): Promise => { - for (let i = 0; i < 5; i++) { - await kaos.writeText(join(tempDir, `new-${i}.txt`), `new-data-${i}`); - } - })(); - - const [entries] = await Promise.all([iterdirPromise, creationPromise]); - - // iterdir should return at least the pre-existing files - // (new files may or may not be included depending on timing) - expect(entries.length).toBeGreaterThanOrEqual(5); - }); - }); - - describe('concurrent glob operations', () => { - it('multiple concurrent globs return correct results', async () => { - // Create files with different extensions - await kaos.writeText(join(tempDir, 'a.ts'), 'ts'); - await kaos.writeText(join(tempDir, 'b.ts'), 'ts'); - await kaos.writeText(join(tempDir, 'c.js'), 'js'); - await kaos.writeText(join(tempDir, 'd.js'), 'js'); - await kaos.writeText(join(tempDir, 'e.json'), 'json'); - - const [tsFiles, jsFiles, jsonFiles] = await Promise.all([ - collectGlob(kaos, tempDir, '*.ts'), - collectGlob(kaos, tempDir, '*.js'), - collectGlob(kaos, tempDir, '*.json'), - ]); - - expect(tsFiles.toSorted()).toEqual([join(tempDir, 'a.ts'), join(tempDir, 'b.ts')].toSorted()); - expect(jsFiles.toSorted()).toEqual([join(tempDir, 'c.js'), join(tempDir, 'd.js')].toSorted()); - expect(jsonFiles).toEqual([join(tempDir, 'e.json')]); - }); - - it('10 concurrent glob(*.txt) on same directory → consistent results', async () => { - // Use a flat glob pattern to avoid ** duplication behavior - await kaos.writeText(join(tempDir, 'a.txt'), 'a'); - await kaos.writeText(join(tempDir, 'b.txt'), 'b'); - await kaos.writeText(join(tempDir, 'c.txt'), 'c'); - - const expected = [ - join(tempDir, 'a.txt'), - join(tempDir, 'b.txt'), - join(tempDir, 'c.txt'), - ].toSorted(); - - const promises: Promise[] = []; - for (let i = 0; i < 10; i++) { - promises.push(collectGlob(kaos, tempDir, '*.txt')); - } - - const results = await Promise.all(promises); - - for (const result of results) { - expect(result.toSorted()).toEqual(expected); - } - }); - }); - - describe('concurrent mixed operations', () => { - it('read + write + stat + iterdir concurrently on same directory', async () => { - const filePath = join(tempDir, 'mixed.txt'); - await kaos.writeText(filePath, 'initial'); - - const [readResult, _writeResult, statResult, entries] = await Promise.all([ - kaos.readText(filePath), - kaos.writeText(join(tempDir, 'another.txt'), 'other'), - kaos.stat(filePath), - collectIterdir(kaos, tempDir), - ]); - - // readResult might be 'initial' (read before write) or a valid string - expect(typeof readResult).toBe('string'); - expect(statResult.stSize).toBeGreaterThan(0); - expect(entries.length).toBeGreaterThanOrEqual(1); - }); - }); -}); - -// ── Helper functions ────────────────────────────────────────────────── - -async function collectGlob(kaos: LocalKaos, path: string, pattern: string): Promise { - const results: string[] = []; - for await (const entry of kaos.glob(path, pattern)) { - results.push(entry); - } - return results; -} - -async function collectIterdir(kaos: LocalKaos, path: string): Promise { - const results: string[] = []; - for await (const entry of kaos.iterdir(path)) { - results.push(entry); - } - return results; -} diff --git a/packages/kaos/test/e2e/exec-edge-cases.test.ts b/packages/kaos/test/e2e/exec-edge-cases.test.ts deleted file mode 100644 index cfc5fbd84..000000000 --- a/packages/kaos/test/e2e/exec-edge-cases.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { mkdtemp, realpath, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { LocalKaos } from '#/local'; - -// ── E2E: exec edge cases ────────────────────────────────────────────── -// -// Covers exec() scenarios that the other kaos suites do not touch: -// -// - spawning a non-existent command and safely awaiting the error, -// - killing a running child with SIGTERM, -// - closing stdin while the child is still alive, -// - >10MB stdout throughput without dropping or corrupting bytes, -// - proving that each LocalKaos instance carries its OWN cwd into -// concurrent child processes (isolation invariant). - -// ── Helpers ─────────────────────────────────────────────────────────── - -async function streamToString(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk as Buffer)); - } - return Buffer.concat(chunks).toString('utf-8'); -} - -async function streamByteLength(stream: NodeJS.ReadableStream): Promise { - let n = 0; - for await (const chunk of stream) { - n += (chunk as Buffer).length; - } - return n; -} - -// ── Tests ───────────────────────────────────────────────────────────── - -describe('e2e: exec edge cases', () => { - let kaos: LocalKaos; - let tempDir: string; - let originalCwd: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - originalCwd = process.cwd(); - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-exec-edge-'))); - await kaos.chdir(tempDir); - }); - - afterEach(async () => { - // Restore original cwd in case any test accidentally mutated it - // (though LocalKaos should never touch process.cwd()). - process.chdir(originalCwd); - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('spawning a non-existent command', () => { - it('exec() rejects promptly when the binary does not exist (never hangs)', async () => { - // Contract: LocalKaos.exec() awaits the child's 'spawn' or 'error' event - // before returning, so a missing binary becomes a synchronous rejection - // rather than a ghost process handle. - await expect(kaos.exec('this-binary-does-not-exist-kaos-edge-test-12345')).rejects.toThrow( - /ENOENT|ENOTFOUND|not found|spawn/i, - ); - }); - }); - - describe('kill() terminates a running child', () => { - it.skipIf(process.platform === 'win32')('long-running child can be killed with SIGTERM', async () => { - // A node script that sleeps forever. - const proc = await kaos.exec('node', '-e', 'setInterval(() => {}, 1000 * 60);'); - - expect(proc.pid).toBeGreaterThan(0); - - // Give the child a moment to actually start. - await new Promise((r) => setTimeout(r, 20)); - - await proc.kill('SIGTERM'); - - const exitCode = await proc.wait(); - // SIGTERM typically produces exitCode = null → -1 under our wrapper, - // or 143 (128 + 15). Either way, it must NOT be 0. - expect(exitCode).not.toBe(0); - }); - - it('kill() after the child has already exited is a no-op (no ESRCH leak)', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(0);'); - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - // Killing an already-exited process must not throw. - await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); - }); - }); - - describe('stdin lifecycle', () => { - it('closing stdin while the child keeps running does not corrupt stdout', async () => { - // Child reads stdin until EOF, then emits "done:". - const proc = await kaos.exec( - 'node', - '-e', - ` - let total = 0; - process.stdin.on('data', (chunk) => { total += chunk.length; }); - process.stdin.on('end', () => { - // Intentionally wait before writing, simulating child work - // continuing after stdin EOF. - setTimeout(() => { - process.stdout.write('done:' + total); - process.exit(0); - }, 20); - }); - `, - ); - - proc.stdin.write('abc'); - proc.stdin.write('def'); - proc.stdin.end(); - - const stdout = await streamToString(proc.stdout); - const exitCode = await proc.wait(); - - expect(exitCode).toBe(0); - expect(stdout).toBe('done:6'); - }); - }); - - describe('large stdout throughput', () => { - it('>10MB of stdout streams through without byte loss', async () => { - // 10.5MB: 10500 writes of a 1KB payload. Using a tight loop in the - // child guarantees the OS pipe buffer gets exercised and we exit - // the BufferedReadable backpressure path. - const targetKB = 10500; - const proc = await kaos.exec( - 'node', - '-e', - ` - const chunk = Buffer.alloc(1024, 0x61); // 1KB of 'a' - let written = 0; - function tick() { - while (written < ${targetKB} - 1) { - const ok = process.stdout.write(chunk); - written++; - if (!ok) { - process.stdout.once('drain', tick); - return; - } - } - // Final write with callback guarantees bytes are flushed to the - // pipe before we exit. Without this, process.exit() races with - // the internal libuv write queue and drops the tail. - process.stdout.write(chunk, () => process.exit(0)); - } - tick(); - `, - ); - - // Read stream and wait concurrently to avoid races where wait() resolves - // before the stream pipe has flushed (BufferedReadable continues to - // accumulate after the child exits, but the consumer must be drained - // explicitly before checking length). - const [stdoutLen, exitCode] = await Promise.all([streamByteLength(proc.stdout), proc.wait()]); - - expect(exitCode).toBe(0); - expect(stdoutLen).toBe(targetKB * 1024); - }); - }); - - describe('cwd isolation for concurrent instances', () => { - it('two LocalKaos instances with different cwds run concurrent child processes that each see their own cwd', async () => { - const subA = join(tempDir, 'A'); - const subB = join(tempDir, 'B'); - - await kaos.mkdir(subA); - await kaos.mkdir(subB); - - const kaosA = await LocalKaos.create(); - const kaosB = await LocalKaos.create(); - await kaosA.chdir(subA); - await kaosB.chdir(subB); - - // Verify process.cwd() is NOT mutated by chdir. - expect(process.cwd()).not.toBe(subA); - expect(process.cwd()).not.toBe(subB); - - // Run concurrently: each child prints its cwd to stdout. - const [procA, procB] = await Promise.all([ - kaosA.exec('node', '-e', 'process.stdout.write(process.cwd())'), - kaosB.exec('node', '-e', 'process.stdout.write(process.cwd())'), - ]); - - const [outA, outB, exitA, exitB] = await Promise.all([ - streamToString(procA.stdout), - streamToString(procB.stdout), - procA.wait(), - procB.wait(), - ]); - - expect(exitA).toBe(0); - expect(exitB).toBe(0); - - // Each child's cwd MUST equal its kaos instance's cwd. - // On macOS `tmpdir()` can be either `/var/folders/...` or - // `/private/var/folders/...`. We already realpath'd tempDir so - // string equality should hold. - expect(outA).toBe(subA); - expect(outB).toBe(subB); - }); - - it('execWithEnv honors the per-instance cwd and injects env vars', async () => { - const proc = await kaos.execWithEnv( - ['node', '-e', 'process.stdout.write(process.env.KAOS_TEST_MARKER + "|" + process.cwd())'], - { KAOS_TEST_MARKER: 'beacon42', PATH: process.env['PATH'] ?? '' }, - ); - const stdout = await streamToString(proc.stdout); - const exitCode = await proc.wait(); - - expect(exitCode).toBe(0); - expect(stdout.startsWith('beacon42|')).toBe(true); - expect(stdout.endsWith(tempDir)).toBe(true); - }); - }); -}); diff --git a/packages/kaos/test/e2e/glob-boundaries-parity.test.ts b/packages/kaos/test/e2e/glob-boundaries-parity.test.ts deleted file mode 100644 index 578b47a33..000000000 --- a/packages/kaos/test/e2e/glob-boundaries-parity.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { mkdtemp, realpath, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { LocalKaos } from '#/local'; - -describe('e2e: glob parity boundaries', () => { - let kaos: LocalKaos; - let tempDir: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-glob-'))); - await kaos.chdir(tempDir); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('** traverses hidden directories and yields each nested match only once', async () => { - await kaos.mkdir(join(tempDir, 'visible', 'nested'), { parents: true }); - await kaos.mkdir(join(tempDir, '.hidden-root'), { parents: true }); - await kaos.mkdir(join(tempDir, 'visible', '.hidden-dir'), { parents: true }); - - await kaos.writeText(join(tempDir, 'root-visible.txt'), 'root-visible'); - await kaos.writeText(join(tempDir, '.hidden-root', 'root-hidden.txt'), 'root-hidden'); - await kaos.writeText(join(tempDir, 'visible', 'nested', 'deep.txt'), 'deep'); - await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'secret.txt'), 'secret'); - await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'skip.log'), 'skip'); - - const results: string[] = []; - for await (const entry of kaos.glob(tempDir, '**/*.txt')) { - results.push(entry); - } - - expect(results).toHaveLength(4); - expect(new Set(results).size).toBe(4); - expect(results).toEqual( - expect.arrayContaining([ - join(tempDir, 'root-visible.txt'), - join(tempDir, '.hidden-root', 'root-hidden.txt'), - join(tempDir, 'visible', 'nested', 'deep.txt'), - join(tempDir, 'visible', '.hidden-dir', 'secret.txt'), - ]), - ); - expect(results.some((entry) => entry.endsWith('skip.log'))).toBe(false); - }); - - it('root-level glob includes hidden dotfiles', async () => { - await kaos.writeText(join(tempDir, '.hidden.txt'), 'hidden'); - await kaos.writeText(join(tempDir, 'visible.txt'), 'visible'); - await kaos.writeText(join(tempDir, 'visible.log'), 'log'); - - const results: string[] = []; - for await (const entry of kaos.glob(tempDir, '*.txt')) { - results.push(entry); - } - - expect(new Set(results)).toEqual( - new Set([join(tempDir, '.hidden.txt'), join(tempDir, 'visible.txt')]), - ); - }); -}); diff --git a/packages/kaos/test/e2e/process-lifecycle.test.ts b/packages/kaos/test/e2e/process-lifecycle.test.ts deleted file mode 100644 index a943c4628..000000000 --- a/packages/kaos/test/e2e/process-lifecycle.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { mkdtemp, realpath, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { LocalKaos } from '#/local'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -// ── Helper ──────────────────────────────────────────────────────────── - -async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk as Buffer)); - } - return Buffer.concat(chunks); -} - -// ── Tests ───────────────────────────────────────────────────────────── - -describe('e2e: process lifecycle', () => { - let kaos: LocalKaos; - let tempDir: string; - let originalCwd: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - originalCwd = process.cwd(); - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-'))); - process.chdir(tempDir); - }); - - afterEach(async () => { - process.chdir(originalCwd); - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('stdin → stdout → wait → exitCode', () => { - it('write to stdin, read from stdout, wait for exit', async () => { - // Node script that reads stdin and echoes it to stdout - const code = ` - let data = ''; - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => { data += chunk; }); - process.stdin.on('end', () => { - process.stdout.write('echo:' + data); - }); - `; - const proc = await kaos.exec('node', '-e', code); - - // Write to stdin - proc.stdin.write('hello from test'); - proc.stdin.end(); - - // Wait for process to complete - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - // Read stdout - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('echo:hello from test'); - }); - - it('multiple stdin writes before end', async () => { - const code = ` - let data = ''; - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => { data += chunk; }); - process.stdin.on('end', () => { - process.stdout.write(data.toUpperCase()); - }); - `; - const proc = await kaos.exec('node', '-e', code); - - proc.stdin.write('hello '); - proc.stdin.write('world '); - proc.stdin.write('test'); - proc.stdin.end(); - - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('HELLO WORLD TEST'); - }); - - it('exitCode is null before wait, correct after wait', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(42)'); - - // Before wait, exitCode may be null - // (it could also be set if process is very fast, so we just check after wait) - const exitCode = await proc.wait(); - expect(exitCode).toBe(42); - expect(proc.exitCode).toBe(42); - }); - }); - - describe('long-running process → kill', () => { - it.skipIf(process.platform === 'win32')('start → verify running → kill → confirm exit', async () => { - // Process that runs indefinitely - const code = ` - process.stdout.write('started\\n'); - setInterval(() => {}, 1000); - `; - const proc = await kaos.exec('node', '-e', code); - - expect(proc.pid).toBeGreaterThan(0); - - // Kill it - await proc.kill('SIGTERM'); - - const exitCode = await proc.wait(); - // On SIGTERM, node typically exits with non-zero - expect(typeof exitCode).toBe('number'); - }); - - it('kill with SIGKILL → immediate termination', async () => { - const code = ` - // Trap SIGTERM to test that SIGKILL forces exit - process.on('SIGTERM', () => { /* ignore */ }); - process.stdout.write('alive\\n'); - setInterval(() => {}, 1000); - `; - const proc = await kaos.exec('node', '-e', code); - - await proc.kill('SIGKILL'); - const exitCode = await proc.wait(); - - // SIGKILL cannot be caught - process is terminated - expect(exitCode).not.toBe(0); - }); - - it('multiple wait() calls return same exit code', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(7)'); - - const code1 = await proc.wait(); - const code2 = await proc.wait(); - const code3 = await proc.wait(); - - expect(code1).toBe(7); - expect(code2).toBe(7); - expect(code3).toBe(7); - }); - }); - - describe('stdin close → natural exit', () => { - it('closing stdin causes stdin-reading process to exit naturally', async () => { - // Process that exits when stdin closes - const code = ` - process.stdin.resume(); - process.stdin.on('end', () => { - process.stdout.write('stdin closed'); - process.exit(0); - }); - `; - const proc = await kaos.exec('node', '-e', code); - - // Close stdin immediately - proc.stdin.end(); - - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('stdin closed'); - }); - - it('cat-like process exits when stdin is closed', async () => { - const code = ` - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => { - process.stdout.write(chunk); - }); - process.stdin.on('end', () => { - process.exit(0); - }); - `; - const proc = await kaos.exec('node', '-e', code); - - proc.stdin.write('line1\n'); - proc.stdin.write('line2\n'); - proc.stdin.end(); - - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('line1\nline2\n'); - }); - }); - - describe('process.pid validity', () => { - it('pid is a positive integer', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(0)'); - expect(proc.pid).toBeGreaterThan(0); - expect(Number.isInteger(proc.pid)).toBe(true); - - await proc.wait(); - }); - - it('different processes have different pids', async () => { - const proc1 = await kaos.exec('node', '-e', 'process.exit(0)'); - const proc2 = await kaos.exec('node', '-e', 'process.exit(0)'); - - expect(proc1.pid).not.toBe(proc2.pid); - - await Promise.all([proc1.wait(), proc2.wait()]); - }); - - it('pid matches the actual child process pid', async () => { - // Have the child process report its own pid - const code = `process.stdout.write(String(process.pid))`; - const proc = await kaos.exec('node', '-e', code); - await proc.wait(); - - const stdout = await streamToBuffer(proc.stdout); - const reportedPid = Number(stdout.toString('utf-8')); - - expect(proc.pid).toBe(reportedPid); - }); - }); - - describe('exec failure (command not found)', () => { - it('non-existent command → error on wait()', async () => { - // exec() itself may succeed (spawn returns) but wait() should - // report the error or the process should fail - try { - const proc = await kaos.exec('this-command-absolutely-does-not-exist-xyz-123'); - // If exec resolves, wait should give a rejection - await expect(proc.wait()).rejects.toThrow(); - } catch (error: unknown) { - // If exec itself throws, that's also acceptable - expect(error).toBeInstanceOf(Error); - } - }); - - it('exec with empty arguments rejects', async () => { - // exec() is now async, so validation errors (missing command) surface - // as rejected promises rather than synchronous throws. - await expect(kaos.exec()).rejects.toThrow(/at least one argument/); - }); - }); - - describe('execWithEnv', () => { - it('passes custom environment variables to the child process', async () => { - const code = `process.stdout.write(process.env.MY_VAR || 'undefined')`; - const proc = await kaos.execWithEnv(['node', '-e', code], { - ...(process.env as Record), - MY_VAR: 'test-value-123', - }); - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('test-value-123'); - }); - - it('custom env overrides specific variables', async () => { - // Passing a custom env with PATH so node can still run, - // but with a custom variable that overrides a default one. - const code = `process.stdout.write(process.env.MY_CUSTOM || 'missing')`; - const proc = await kaos.execWithEnv(['node', '-e', code], { - PATH: process.env['PATH'] ?? '', - MY_CUSTOM: 'overridden-value', - }); - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - expect(stdout.toString('utf-8')).toBe('overridden-value'); - }); - }); - - describe('process output ordering', () => { - it('stdout preserves line order for sequential writes', async () => { - const code = ` - for (let i = 0; i < 100; i++) { - process.stdout.write(i + '\\n'); - } - `; - const proc = await kaos.exec('node', '-e', code); - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stdout = await streamToBuffer(proc.stdout); - const lines = stdout.toString('utf-8').trimEnd().split('\n'); - - expect(lines).toHaveLength(100); - for (let i = 0; i < 100; i++) { - expect(lines[i]).toBe(String(i)); - } - }); - - it('stderr preserves line order for sequential writes', async () => { - const code = ` - for (let i = 0; i < 50; i++) { - process.stderr.write('err-' + i + '\\n'); - } - `; - const proc = await kaos.exec('node', '-e', code); - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - const stderr = await streamToBuffer(proc.stderr); - const lines = stderr.toString('utf-8').trimEnd().split('\n'); - - expect(lines).toHaveLength(50); - for (let i = 0; i < 50; i++) { - expect(lines[i]).toBe(`err-${i}`); - } - }); - }); - - describe('process exit codes', () => { - it('exit code 0 for successful process', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(0)'); - expect(await proc.wait()).toBe(0); - }); - - it('exit code 1 for generic failure', async () => { - const proc = await kaos.exec('node', '-e', 'process.exit(1)'); - expect(await proc.wait()).toBe(1); - }); - - it('custom exit codes (2, 42, 127, 255)', async () => { - for (const code of [2, 42, 127, 255]) { - const proc = await kaos.exec('node', '-e', `process.exit(${code})`); - expect(await proc.wait()).toBe(code); - } - }); - - it('uncaught exception results in exit code 1', async () => { - const code = `throw new Error('uncaught')`; - const proc = await kaos.exec('node', '-e', code); - const exitCode = await proc.wait(); - expect(exitCode).toBe(1); - - // stderr should contain the error message - const stderr = await streamToBuffer(proc.stderr); - expect(stderr.toString('utf-8')).toContain('uncaught'); - }); - }); -}); diff --git a/packages/kaos/test/e2e/ssh-mock.test.ts b/packages/kaos/test/e2e/ssh-mock.test.ts deleted file mode 100644 index a3b66ed41..000000000 --- a/packages/kaos/test/e2e/ssh-mock.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -// ── Tests: SSHKaos parameter validation ────────────────────────────── -// -// SSHKaos.create() requires a live SSH connection to proceed past -// connectClient(). These tests verify the parameter validation and -// shell quoting logic without needing a real SSH server. - -describe('e2e: SSH mock tests', () => { - describe('SSHKaos.create() parameter validation', () => { - it('missing host -> connect attempt with empty host', async () => { - // SSHKaos.create() passes the host directly to ssh2's connect(). - // With an empty/undefined host, ssh2 will fail to connect. - // We verify the error is thrown. - const { SSHKaos } = await import('#/ssh'); - - await expect( - SSHKaos.create({ - host: '', - username: 'testuser', - }), - ).rejects.toThrow(); - }); - - it('missing username -> connect attempt with empty username', async () => { - const { SSHKaos } = await import('#/ssh'); - - await expect( - SSHKaos.create({ - host: '127.0.0.1', - port: 99999, // Use an unlikely port to ensure fast failure - username: '', - }), - ).rejects.toThrow(); - }); - - it('invalid port -> connection error', async () => { - const { SSHKaos } = await import('#/ssh'); - - // Port 1 is unlikely to have an SSH server; should fail quickly - await expect( - SSHKaos.create({ - host: '127.0.0.1', - port: 1, - username: 'testuser', - }), - ).rejects.toThrow(); - }); - }); - - describe('shell quoting logic', () => { - // We test the shellQuote function indirectly by examining its behavior. - // The function is not exported, but we can verify its contract: - // - Empty string -> '' - // - Safe chars -> unchanged - // - Special chars -> single-quoted with embedded quote escaping - - it('shellQuote rules verified via regex match', () => { - // The shellQuote regex for safe chars: /^[A-Za-z0-9_./:=@%^,+-]+$/ - const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/; - - // These should be considered safe (no quoting needed) - expect(safeRegex.test('simple')).toBe(true); - expect(safeRegex.test('/usr/bin/node')).toBe(true); - expect(safeRegex.test('key=value')).toBe(true); - expect(safeRegex.test('file.txt')).toBe(true); - - // These should need quoting - expect(safeRegex.test('')).toBe(false); - expect(safeRegex.test('hello world')).toBe(false); - expect(safeRegex.test("it's")).toBe(false); - expect(safeRegex.test('$HOME')).toBe(false); - expect(safeRegex.test('a|b')).toBe(false); - expect(safeRegex.test('a"b')).toBe(false); - expect(safeRegex.test('a;b')).toBe(false); - expect(safeRegex.test('a&b')).toBe(false); - expect(safeRegex.test('a`b')).toBe(false); - }); - - it('shellQuote single-quote escaping pattern is correct', () => { - // shellQuote wraps in single quotes, replacing ' with '"'"' - // Verify the pattern: 'text'"'"'more' handles embedded quotes - const input = "it's a test"; - const escaped = "'" + input.replaceAll("'", "'\"'\"'") + "'"; - - expect(escaped).toBe("'it'\"'\"'s a test'"); - // This should be safe to use in a shell command - expect(escaped).toContain("'\"'\"'"); - }); - - it('special characters that need quoting', () => { - const specialChars = [' ', "'", '"', '$', '|', '&', ';', '`', '(', ')', '{', '}', '<', '>']; - const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/; - - for (const ch of specialChars) { - expect(safeRegex.test(`arg${ch}value`)).toBe(false); - } - }); - - it('empty string quoting produces two single quotes', () => { - // shellQuote('') should return "''" - const emptyQuoted = "''"; - expect(emptyQuoted).toBe("''"); - expect(emptyQuoted.length).toBe(2); - }); - }); - - describe('SSHKaosOptions type constraints', () => { - it('options structure has required fields', () => { - // Verify at compile time and runtime that SSHKaosOptions - // requires host and username - const validOptions = { - host: 'example.com', - username: 'user', - }; - - expect(validOptions.host).toBe('example.com'); - expect(validOptions.username).toBe('user'); - }); - - it('options with all optional fields', () => { - const fullOptions = { - host: 'example.com', - port: 2222, - username: 'user', - password: 'pass', - keyPaths: ['/path/to/key'], - keyContents: ['ssh-rsa AAAA...'], - cwd: '/home/user', - }; - - expect(fullOptions.port).toBe(2222); - expect(fullOptions.password).toBe('pass'); - expect(fullOptions.keyPaths).toHaveLength(1); - expect(fullOptions.keyContents).toHaveLength(1); - expect(fullOptions.cwd).toBe('/home/user'); - }); - }); -}); diff --git a/packages/kaos/test/e2e/ssh-resolve-path.test.ts b/packages/kaos/test/e2e/ssh-resolve-path.test.ts deleted file mode 100644 index a9623414f..000000000 --- a/packages/kaos/test/e2e/ssh-resolve-path.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import type { Client, SFTPWrapper, Stats as SFTPStats } from 'ssh2'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { KaosFileExistsError } from '#/errors'; -import { KaosFileNotFoundError, SSHKaos } from '#/ssh'; - -// ── SSH path resolution: mock SFTP harness ──────────────────────────── -// -// This test file validates that SSHKaos file/dir operations correctly -// resolve *relative* paths against the instance's current working -// directory (`_cwd`) before handing the path off to SFTP. -// -// We mock the underlying `SFTPWrapper` so no network traffic is needed. -// Every SFTP method (readFile, writeFile, stat, lstat, mkdir, appendFile, -// exists, readdir, realpath) records the absolute path it receives, and -// returns a deterministic response. After each SSHKaos operation we -// inspect the recorded path to ensure the resolution worked. -// -// The private SSHKaos constructor is bypassed via `Reflect.construct` — -// TypeScript `private` is compile-time only, and we need a test-only -// instance that is not backed by a real SSH connection. - -// ── Mock helpers ────────────────────────────────────────────────────── - -interface MockSFTP { - calls: { method: string; path: string; data?: string | Buffer }[]; - files: Map; - dirs: Set; - mkdirFailures: Map; -} - -function makeMockStats(isDir: boolean, size: number = 0): SFTPStats { - return { - mode: isDir ? 0o040755 : 0o100644, - uid: 1000, - gid: 1000, - size, - atime: 0, - mtime: 0, - isDirectory: () => isDir, - isFile: () => !isDir, - isSymbolicLink: () => false, - isSocket: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - } as unknown as SFTPStats; -} - -function createMockSftp(state: MockSFTP): SFTPWrapper { - // Only stub the SFTP methods used by the tests below. - const sftp = { - realpath(path: string, cb: (err: unknown, absPath: string) => void): void { - state.calls.push({ method: 'realpath', path }); - // Mock realpath: if path is already absolute, echo it. - // Otherwise it would be relative — but since SSHKaos always - // resolves first, we should never see a relative input here. - cb(null, path); - }, - stat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void { - state.calls.push({ method: 'stat', path }); - if (state.dirs.has(path)) { - cb(null, makeMockStats(true)); - return; - } - const buf = state.files.get(path); - if (buf !== undefined) { - cb(null, makeMockStats(false, buf.length)); - return; - } - // Missing — surface an ENOENT-shaped error. - const err = Object.assign(new Error(`ENOENT: ${path}`), { code: 2 }); - cb(err, undefined as unknown as SFTPStats); - }, - lstat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void { - state.calls.push({ method: 'lstat', path }); - sftp.stat(path, cb); - }, - readFile(path: string, cb: (err: unknown, data: Buffer) => void): void { - state.calls.push({ method: 'readFile', path }); - const buf = state.files.get(path); - if (buf === undefined) { - cb(new Error(`ENOENT: ${path}`), Buffer.alloc(0)); - return; - } - cb(null, buf); - }, - writeFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void { - state.calls.push({ method: 'writeFile', path, data }); - const buf = typeof data === 'string' ? Buffer.from(data) : data; - state.files.set(path, buf); - cb(null); - }, - appendFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void { - state.calls.push({ method: 'appendFile', path, data }); - const buf = typeof data === 'string' ? Buffer.from(data) : data; - const existing = state.files.get(path) ?? Buffer.alloc(0); - state.files.set(path, Buffer.concat([existing, buf])); - cb(null); - }, - exists(path: string, cb: (exists: boolean) => void): void { - state.calls.push({ method: 'exists', path }); - cb(state.files.has(path) || state.dirs.has(path)); - }, - mkdir(path: string, cb: (err: unknown) => void): void { - state.calls.push({ method: 'mkdir', path }); - const failure = state.mkdirFailures.get(path); - if (failure) { - if (failure.materializeAs === 'dir') { - state.dirs.add(path); - } else { - state.files.set(path, Buffer.from('collision')); - } - cb(failure.error); - return; - } - state.dirs.add(path); - cb(null); - }, - readdir( - path: string, - cb: (err: unknown, list: { filename: string; attrs: SFTPStats }[]) => void, - ): void { - state.calls.push({ method: 'readdir', path }); - // Return empty listing for simplicity. - cb(null, []); - }, - end(): void { - // no-op - }, - }; - return sftp as unknown as SFTPWrapper; -} - -function createMockClient(): Client { - return { - end(): void { - // no-op - }, - } as unknown as Client; -} - -/** - * Construct an SSHKaos with the private constructor bypassed. TS - * `private` is compile-time only — runtime reflection works. We use - * `Reflect.construct` to hand-build an instance seeded with the mock - * SFTP wrapper and a chosen home/cwd. - */ -function createMockedKaos(sftp: SFTPWrapper, home: string, cwd: string): SSHKaos { - const client = createMockClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const CtorAny = SSHKaos as any; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return new CtorAny(client, sftp, home, cwd) as SSHKaos; -} - -// ── Tests ───────────────────────────────────────────────────────────── - -describe('e2e: SSHKaos relative path resolution after chdir (mocked SFTP)', () => { - let state: MockSFTP; - let sftp: SFTPWrapper; - let kaos: SSHKaos; - - beforeEach(() => { - state = { - calls: [], - files: new Map(), - dirs: new Set(), - mkdirFailures: new Map(), - }; - sftp = createMockSftp(state); - kaos = createMockedKaos(sftp, '/home/user', '/home/user'); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('readText / readBytes / readLines', () => { - it('readText resolves relative path against current cwd', async () => { - // chdir to /remote/tmp; relative "file.txt" must resolve to /remote/tmp/file.txt. - state.dirs.add('/remote/tmp'); - await kaos.chdir('/remote/tmp'); - - state.files.set('/remote/tmp/file.txt', Buffer.from('hello')); - - const text = await kaos.readText('file.txt'); - expect(text).toBe('hello'); - - // Verify the SFTP readFile call received the absolute resolved path. - const readCalls = state.calls.filter((c) => c.method === 'readFile'); - expect(readCalls).toHaveLength(1); - expect(readCalls[0]!.path).toBe('/remote/tmp/file.txt'); - }); - - it('readText accepts absolute path unchanged', async () => { - state.dirs.add('/remote/tmp'); - await kaos.chdir('/remote/tmp'); - state.files.set('/etc/hosts', Buffer.from('127.0.0.1 localhost')); - - const text = await kaos.readText('/etc/hosts'); - expect(text).toBe('127.0.0.1 localhost'); - - const readCalls = state.calls.filter((c) => c.method === 'readFile'); - expect(readCalls.some((c) => c.path === '/etc/hosts')).toBe(true); - // And NOT /remote/tmp/etc/hosts - expect(readCalls.some((c) => c.path === '/remote/tmp/etc/hosts')).toBe(false); - }); - - it('readBytes resolves relative path against current cwd', async () => { - state.dirs.add('/opt/data'); - await kaos.chdir('/opt/data'); - state.files.set('/opt/data/blob.bin', Buffer.from([1, 2, 3, 4])); - - const bytes = await kaos.readBytes('blob.bin'); - expect(Array.from(bytes)).toEqual([1, 2, 3, 4]); - - const readCalls = state.calls.filter((c) => c.method === 'readFile'); - expect(readCalls.some((c) => c.path === '/opt/data/blob.bin')).toBe(true); - }); - - it('readLines resolves relative path against current cwd', async () => { - state.dirs.add('/var/log'); - await kaos.chdir('/var/log'); - state.files.set('/var/log/app.log', Buffer.from('line1\nline2\nline3')); - - const lines: string[] = []; - for await (const line of kaos.readLines('app.log')) { - lines.push(line); - } - expect(lines).toEqual(['line1', 'line2', 'line3']); - - const readCalls = state.calls.filter((c) => c.method === 'readFile'); - expect(readCalls.some((c) => c.path === '/var/log/app.log')).toBe(true); - }); - - it('readLines strips CRLF terminators', async () => { - state.dirs.add('/var/log'); - await kaos.chdir('/var/log'); - state.files.set('/var/log/app-crlf.log', Buffer.from('line1\r\nline2\r\n')); - - const lines: string[] = []; - for await (const line of kaos.readLines('app-crlf.log')) { - lines.push(line); - } - - expect(lines).toEqual(['line1', 'line2']); - }); - }); - - describe('writeText / writeBytes / appendFile', () => { - it('writeText resolves relative path against current cwd', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - const n = await kaos.writeText('out.txt', 'hello world'); - expect(n).toBe('hello world'.length); - - const writeCalls = state.calls.filter((c) => c.method === 'writeFile'); - expect(writeCalls).toHaveLength(1); - expect(writeCalls[0]!.path).toBe('/workspace/out.txt'); - - // Mock file store now contains the file at the resolved path. - expect(state.files.get('/workspace/out.txt')?.toString('utf-8')).toBe('hello world'); - }); - - it('writeBytes resolves relative path against current cwd', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - const data = Buffer.from([0xde, 0xad, 0xbe, 0xef]); - const n = await kaos.writeBytes('blob.bin', data); - expect(n).toBe(4); - - const writeCalls = state.calls.filter((c) => c.method === 'writeFile'); - expect(writeCalls).toHaveLength(1); - expect(writeCalls[0]!.path).toBe('/workspace/blob.bin'); - }); - - it('writeText with mode=a uses appendFile and resolves path', async () => { - state.dirs.add('/logs'); - await kaos.chdir('/logs'); - - await kaos.writeText('a.log', 'line1\n', { mode: 'a' }); - await kaos.writeText('a.log', 'line2\n', { mode: 'a' }); - - const appendCalls = state.calls.filter((c) => c.method === 'appendFile'); - expect(appendCalls).toHaveLength(2); - expect(appendCalls[0]!.path).toBe('/logs/a.log'); - expect(appendCalls[1]!.path).toBe('/logs/a.log'); - - expect(state.files.get('/logs/a.log')?.toString('utf-8')).toBe('line1\nline2\n'); - }); - }); - - describe('stat / mkdir', () => { - it('stat resolves relative path against current cwd', async () => { - state.dirs.add('/srv'); - await kaos.chdir('/srv'); - state.files.set('/srv/config.json', Buffer.from('{}')); - - const result = await kaos.stat('config.json'); - expect(result.stSize).toBe(2); - - const statCalls = state.calls.filter((c) => c.method === 'stat'); - expect(statCalls.some((c) => c.path === '/srv/config.json')).toBe(true); - }); - - it('stat preserves NO_SUCH_FILE as KaosFileNotFoundError', async () => { - state.dirs.add('/srv'); - await kaos.chdir('/srv'); - - await expect(kaos.stat('missing.json')).rejects.toBeInstanceOf(KaosFileNotFoundError); - await expect(kaos.stat('missing.json')).rejects.toMatchObject({ code: 2 }); - }); - - it('stat with followSymlinks=false uses lstat and resolves path', async () => { - state.dirs.add('/srv'); - await kaos.chdir('/srv'); - state.files.set('/srv/link', Buffer.from('data')); - - await kaos.stat('link', { followSymlinks: false }); - - const lstatCalls = state.calls.filter((c) => c.method === 'lstat'); - expect(lstatCalls).toHaveLength(1); - expect(lstatCalls[0]!.path).toBe('/srv/link'); - }); - - it('mkdir resolves relative path against current cwd', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - await kaos.mkdir('newdir'); - - const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir'); - expect(mkdirCalls).toHaveLength(1); - expect(mkdirCalls[0]!.path).toBe('/workspace/newdir'); - }); - - it('mkdir with parents=true resolves and creates intermediate dirs', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - await kaos.mkdir('a/b/c', { parents: true }); - - const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir'); - const mkdirPaths = mkdirCalls.map((c) => c.path); - expect(mkdirPaths).toContain('/workspace/a'); - expect(mkdirPaths).toContain('/workspace/a/b'); - expect(mkdirPaths).toContain('/workspace/a/b/c'); - expect(mkdirPaths.every((path) => !path.includes('//'))).toBe(true); - }); - - it('mkdir existOk=true is idempotent against the resolved path', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - // Pre-seed the target directory. - state.dirs.add('/workspace/existing'); - - await expect(kaos.mkdir('existing', { existOk: true })).resolves.toBeUndefined(); - - // Without existOk, it should throw. - await expect(kaos.mkdir('existing', { existOk: false })).rejects.toThrow(); - }); - - it('mkdir with parents=true rejects a raced file collision when existOk=true', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - const racePaths = ['/workspace/collision', '//workspace/collision']; - for (const racePath of racePaths) { - state.mkdirFailures.set(racePath, { - error: new Error('EEXIST'), - materializeAs: 'file', - }); - } - - await expect( - kaos.mkdir('collision', { parents: true, existOk: true }), - ).rejects.toBeInstanceOf(KaosFileExistsError); - }); - }); - - describe('iterdir', () => { - it('iterdir with relative "." resolves to cwd', async () => { - state.dirs.add('/workspace'); - await kaos.chdir('/workspace'); - - const entries: string[] = []; - for await (const entry of kaos.iterdir('.')) { - entries.push(entry); - } - // (readdir mock returns empty list; we're asserting the path passed to readdir) - - const readdirCalls = state.calls.filter((c) => c.method === 'readdir'); - expect(readdirCalls).toHaveLength(1); - // posix.join('/workspace', '.') normalises to '/workspace' - expect(readdirCalls[0]!.path).toBe('/workspace'); - expect(entries).toEqual([]); - }); - - it('iterdir with named relative subdir resolves under cwd', async () => { - state.dirs.add('/workspace'); - state.dirs.add('/workspace/sub'); - await kaos.chdir('/workspace'); - - const entries: string[] = []; - for await (const entry of kaos.iterdir('sub')) { - entries.push(entry); - } - - const readdirCalls = state.calls.filter((c) => c.method === 'readdir'); - expect(readdirCalls).toHaveLength(1); - expect(readdirCalls[0]!.path).toBe('/workspace/sub'); - expect(entries).toEqual([]); - }); - }); - - describe('chdir follows realpath and affects subsequent relative ops', () => { - it('consecutive chdir + readText rebase the working directory each time', async () => { - state.dirs.add('/a'); - state.dirs.add('/b'); - state.files.set('/a/one.txt', Buffer.from('A')); - state.files.set('/b/two.txt', Buffer.from('B')); - - await kaos.chdir('/a'); - expect(await kaos.readText('one.txt')).toBe('A'); - - await kaos.chdir('/b'); - expect(await kaos.readText('two.txt')).toBe('B'); - - // After chdir(/b), a relative path that only exists under /a - // must NOT resolve — confirming we've truly rebased. - await expect(kaos.readText('one.txt')).rejects.toThrow(); - }); - - it('chdir with a relative argument resolves against the prior cwd', async () => { - state.dirs.add('/home/user'); - state.dirs.add('/home/user/project'); - - await kaos.chdir('/home/user'); - await kaos.chdir('project'); - - expect(kaos.getcwd()).toBe('/home/user/project'); - - state.files.set('/home/user/project/README.md', Buffer.from('readme')); - expect(await kaos.readText('README.md')).toBe('readme'); - }); - }); -}); diff --git a/packages/kaos/test/e2e/symlink-stat-parity.test.ts b/packages/kaos/test/e2e/symlink-stat-parity.test.ts deleted file mode 100644 index 4a91d668a..000000000 --- a/packages/kaos/test/e2e/symlink-stat-parity.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { LocalKaos } from '#/local'; - -const S_IFMT = 0o170000; -const S_IFDIR = 0o040000; -const S_IFLNK = 0o120000; -const S_IFREG = 0o100000; - -describe.skipIf(process.platform === 'win32')('e2e: symlink stat parity', () => { - let kaos: LocalKaos; - let tempDir: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-symlink-'))); - await kaos.chdir(tempDir); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('follows file symlinks by default and preserves lstat metadata when followSymlinks=false', async () => { - const targetFile = join(tempDir, 'target.txt'); - const linkFile = join(tempDir, 'target-link.txt'); - const payload = 'payload through symlink'; - - await kaos.writeText(targetFile, payload); - await symlink(targetFile, linkFile); - - const linkStat = await kaos.stat(linkFile, { followSymlinks: false }); - expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK); - - const resolvedStat = await kaos.stat(linkFile); - expect(resolvedStat.stMode & S_IFMT).toBe(S_IFREG); - expect(resolvedStat.stSize).toBe(Buffer.byteLength(payload, 'utf-8')); - expect(await kaos.readText(linkFile)).toBe(payload); - }); - - it('follows directory symlinks while lstat still reports a symlink', async () => { - const targetDir = join(tempDir, 'target-dir'); - const linkDir = join(tempDir, 'target-dir-link'); - const nestedFile = join(targetDir, 'nested.txt'); - - await kaos.mkdir(targetDir); - await kaos.writeText(nestedFile, 'directory payload'); - await symlink(targetDir, linkDir); - - const linkStat = await kaos.stat(linkDir, { followSymlinks: false }); - expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK); - - const resolvedStat = await kaos.stat(linkDir); - expect(resolvedStat.stMode & S_IFMT).toBe(S_IFDIR); - - const entries: string[] = []; - for await (const entry of kaos.iterdir(linkDir)) { - entries.push(entry); - } - - expect(entries).toEqual([join(linkDir, 'nested.txt')]); - }); -}); diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts deleted file mode 100644 index fee6947c8..000000000 --- a/packages/kaos/test/environment.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Environment detection. - * - * Pins the cross-platform shape of `detectEnvironment()`: - * - * - macOS / Linux / Windows / unknown → `osKind` - * - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash, - * /usr/local/bin/bash, then /bin/sh (with shellName 'sh'). - * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH - * (including `git --exec-path` for shims), or well-known install - * locations; throws `KaosShellNotFoundError` - * if none are present. - * - `osArch` / `osVersion` are populated from the Node OS APIs. - * - * All tests expect `detectEnvironment()` to be a pure function of - * injected platform probes (no ambient state) so the same suite runs - * identically on macOS/Linux/Windows CI runners. - */ - -import { describe, expect, it } from 'vitest'; - -import { - detectEnvironment, - type Environment, - type OsKind, - type ShellName, -} from '#/environment'; -import { KaosShellNotFoundError } from '#/errors'; - -interface StubOpts { - readonly platform: NodeJS.Platform; - readonly arch?: string; - readonly release?: string; - readonly env?: Record; - readonly existingPaths?: readonly string[]; - readonly execFileResults?: Readonly>; - readonly execFileText?: Parameters[0]['execFileText']; -} - -/** Build a stub deps bag mimicking Node's `os` + `process` surface. */ -function stubDeps(opts: StubOpts): Parameters[0] { - const existing = new Set(opts.existingPaths ?? []); - return { - platform: opts.platform, - arch: opts.arch ?? 'x86_64', - release: opts.release ?? '1.2.3', - env: opts.env ?? {}, - isFile: async (path: string) => existing.has(path), - execFileText: - opts.execFileText ?? - (async (file: string, args: readonly string[]) => opts.execFileResults?.[execFileKey(file, args)]), - }; -} - -function execFileKey(file: string, args: readonly string[]): string { - return [file, ...args].join('\0'); -} - -describe('detectEnvironment', () => { - it('reports osKind "macOS" on darwin', async () => { - const env: Environment = await detectEnvironment( - stubDeps({ - platform: 'darwin', - arch: 'arm64', - release: '23.4.0', - existingPaths: ['/bin/bash'], - }), - ); - expect(env.osKind satisfies OsKind).toBe('macOS'); - expect(env.osArch).toBe('arm64'); - expect(env.osVersion).toBe('23.4.0'); - }); - - it('reports osKind "Linux" on linux', async () => { - const env = await detectEnvironment( - stubDeps({ platform: 'linux', existingPaths: ['/bin/bash'] }), - ); - expect(env.osKind).toBe('Linux'); - }); - - it('reports osKind "Windows" on win32', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], - }), - ); - expect(env.osKind).toBe('Windows'); - }); - - it('passes through unknown platform string verbatim', async () => { - const env = await detectEnvironment( - stubDeps({ platform: 'freebsd' as NodeJS.Platform, existingPaths: ['/bin/sh'] }), - ); - // Python `Environment.detect` returns `platform.system()` verbatim - // for unknown OS strings; TS mirrors that behaviour. - expect(env.osKind).toBe('freebsd'); - }); - - // ── POSIX shell probing ──────────────────────────────────────────── - - it('prefers /bin/bash when it exists (shellName=bash)', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'linux', - existingPaths: ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'], - }), - ); - expect(env.shellName satisfies ShellName).toBe('bash'); - expect(env.shellPath).toBe('/bin/bash'); - }); - - it('falls back to /usr/bin/bash when /bin/bash is missing', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'linux', - existingPaths: ['/usr/bin/bash', '/usr/local/bin/bash'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('/usr/bin/bash'); - }); - - it('falls back to /usr/local/bin/bash when /bin and /usr/bin are missing', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'linux', - existingPaths: ['/usr/local/bin/bash'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('/usr/local/bin/bash'); - }); - - it('falls back to /bin/sh with shellName=sh when no bash is found', async () => { - const env = await detectEnvironment(stubDeps({ platform: 'linux', existingPaths: [] })); - expect(env.shellName).toBe('sh'); - expect(env.shellPath).toBe('/bin/sh'); - }); - - // ── Windows Git Bash probing ─────────────────────────────────────── - - it('uses KIMI_SHELL_PATH override when set and the file exists', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, - existingPaths: ['D:\\custom\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe'], - }), - ); - expect(env.shellName satisfies ShellName).toBe('bash'); - expect(env.shellPath).toBe('D:\\custom\\bash.exe'); - }); - - it('infers Git Bash from git.exe on PATH when override is absent', async () => { - const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Program Files\\Git\\cmd' }, - existingPaths: [gitExe, 'C:\\Program Files\\Git\\bin\\bash.exe'], - execFileText: async (file: string) => { - throw new Error(`unexpected execFileText call for ${file}`); - }, - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); - }); - - it('resolves a Scoop git shim through git --exec-path', async () => { - const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: - 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', - }, - existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); - }); - - it('resolves a Scoop git shim through usr/bin when bin/bash.exe is missing', async () => { - const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: - 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', - }, - existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'); - }); - - it('resolves MSYS2 ucrt64 native git through git --exec-path', async () => { - const gitExe = 'C:\\msys64\\ucrt64\\bin\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\msys64\\ucrt64\\bin' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/ucrt64/libexec/git-core\n', - }, - existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); - }); - - it('resolves MSYS2 clang64 native git through git --exec-path', async () => { - const gitExe = 'C:\\msys64\\clang64\\bin\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\msys64\\clang64\\bin' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clang64/libexec/git-core\n', - }, - existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); - }); - - it('resolves MSYS2 clangarm64 native git through git --exec-path', async () => { - const gitExe = 'C:\\msys64\\clangarm64\\bin\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\msys64\\clangarm64\\bin' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clangarm64/libexec/git-core\n', - }, - existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); - }); - - it('does not treat shim-adjacent bash.exe as the Git installation shell', async () => { - const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, - execFileResults: { - [execFileKey(gitExe, ['--exec-path'])]: - 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', - }, - existingPaths: [ - gitExe, - 'C:\\Users\\me\\scoop\\bin\\bash.exe', - 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', - ], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); - }); - - it('checks later git.exe matches when the first one cannot resolve Git Bash', async () => { - const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; - const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, - existingPaths: [scoopGit, portableGit, 'D:\\PortableGit\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); - }); - - it('keeps PATH order when an earlier shim resolves through git --exec-path', async () => { - const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; - const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, - execFileResults: { - [execFileKey(scoopGit, ['--exec-path'])]: - 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', - }, - existingPaths: [ - scoopGit, - portableGit, - 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', - 'D:\\PortableGit\\bin\\bash.exe', - ], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); - }); - - it('skips relative Windows PATH entries before git --exec-path probing', async () => { - const relativeGit = 'tools\\git.exe'; - const error = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'tools' }, - existingPaths: [relativeGit], - execFileText: async (file: string) => { - throw new Error(`unexpected execFileText call for ${file}`); - }, - }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error, - ); - expect(error).toBeInstanceOf(KaosShellNotFoundError); - }); - - it('scans PATH directly for git.exe candidates', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'D:\\PortableGit\\cmd' }, - existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); - }); - - it('infers Git Bash from usr/bin when bin/bash.exe is missing', async () => { - const gitExe = 'D:\\Program Files\\Git\\cmd\\git.exe'; - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { PATH: 'D:\\Program Files\\Git\\cmd' }, - existingPaths: [gitExe, 'D:\\Program Files\\Git\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellName).toBe('bash'); - expect(env.shellPath).toBe('D:\\Program Files\\Git\\usr\\bin\\bash.exe'); - }); - - it('falls back to the well-known Program Files install location', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], - }), - ); - expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); - }); - - it('falls back to usr/bin under Program Files when bin/bash.exe is missing', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - existingPaths: ['C:\\Program Files\\Git\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellPath).toBe('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); - }); - - it('falls back to LOCALAPPDATA install when present', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, - existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'], - }), - ); - expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'); - }); - - it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, - existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'], - }), - ); - expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'); - }); - - it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => { - const error = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, - existingPaths: [], - }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error, - ); - expect(error).toBeInstanceOf(KaosShellNotFoundError); - }); - - it('includes attempted paths in the thrown error message', async () => { - const error = await detectEnvironment( - stubDeps({ - platform: 'win32', - env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, - existingPaths: [], - }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error as KaosShellNotFoundError, - ); - expect(error.message).toContain('D:\\custom\\bash.exe'); - expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); - expect(error.message).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); - }); - - // ── arch / version passthrough ───────────────────────────────────── - - it('reports osArch verbatim from the injected probe', async () => { - const env = await detectEnvironment( - stubDeps({ platform: 'darwin', arch: 'arm64', existingPaths: ['/bin/bash'] }), - ); - expect(env.osArch).toBe('arm64'); - }); - - it('reports osVersion verbatim from the injected probe', async () => { - const env = await detectEnvironment( - stubDeps({ - platform: 'linux', - release: '6.1.0-test', - existingPaths: ['/bin/bash'], - }), - ); - expect(env.osVersion).toBe('6.1.0-test'); - }); -}); diff --git a/packages/kaos/test/fixtures/killtree.cjs b/packages/kaos/test/fixtures/killtree.cjs deleted file mode 100644 index c89e84c9a..000000000 --- a/packages/kaos/test/fixtures/killtree.cjs +++ /dev/null @@ -1,31 +0,0 @@ -// Helper for the Windows process-tree kill test (test/local.test.ts). -// -// Boots a parent → child → grandchild chain and writes the grandchild's pid -// to the path passed as `process.argv[2]`, then idles so the test can kill -// the parent and assert the grandchild is reaped too. -// -// Lives in a real file (rather than an inline `node -e` string) so the path -// travels via argv instead of being embedded in nested template literals — -// inline multi-line `node -e` with backslash paths gets mangled by Node's -// Windows arg-quoting and by JS string escapes (`\f` etc.). - -const { spawn } = require('node:child_process'); - -const pidPath = process.argv[2]; -if (!pidPath) { - process.exit(2); -} - -// Single-line child code: no newlines, no nested template literals, and the -// pid path comes from argv (not a string literal), so it survives Windows -// arg-quoting intact. -const childCode = [ - "const { spawn } = require('node:child_process');", - "const { writeFileSync } = require('node:fs');", - "const g = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)']);", - 'writeFileSync(process.argv[1], String(g.pid));', - 'setInterval(() => {}, 1000);', -].join(' '); - -spawn(process.execPath, ['-e', childCode, pidPath], { stdio: 'inherit' }); -setInterval(() => {}, 1000); diff --git a/packages/kaos/test/internal.test.ts b/packages/kaos/test/internal.test.ts deleted file mode 100644 index f38d3107c..000000000 --- a/packages/kaos/test/internal.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { PassThrough } from 'node:stream'; -import { setImmediate as defer, setTimeout as delay } from 'node:timers/promises'; - -import { describe, expect, it } from 'vitest'; - -import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from '#/internal'; - -async function collectBytes(readable: AsyncIterable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of readable) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -function withTimeout(promise: Promise, timeoutMs: number): Promise { - return Promise.race([ - promise, - (async () => { - await delay(timeoutMs); - throw new Error(`timed out after ${timeoutMs}ms`); - })(), - ]); -} - -describe('BufferedReadable', () => { - it('preserves source backpressure until the consumer starts reading', () => { - const source = new PassThrough({ highWaterMark: 64 * 1024 }); - const buffered = new BufferedReadable(source); - const chunk = Buffer.alloc(32 * 1024, 0x61); - - let writes = 0; - let writeOk = true; - while (writes < 20 && writeOk) { - writeOk = source.write(chunk); - writes++; - } - - buffered.destroy(); - source.destroy(); - - expect(writeOk).toBe(false); - expect(writes).toBeLessThan(10); - }); - - it('stops pushing when its readable buffer reaches highWaterMark', async () => { - const source = new PassThrough(); - const chunk = Buffer.alloc(32 * 1024, 0x62); - - for (let index = 0; index < 10; index++) { - source.write(chunk); - } - source.end(); - - const buffered = new BufferedReadable(source); - buffered.read(0); - await defer(); - - expect(buffered.readableLength).toBeLessThanOrEqual(buffered.readableHighWaterMark); - - const output = await collectBytes(buffered); - expect(output).toHaveLength(chunk.length * 10); - }); - - it('ends async iteration when the source closes without emitting end', async () => { - const source = new PassThrough(); - const buffered = new BufferedReadable(source); - const outputPromise = collectBytes(buffered).then((output) => output.toString('utf8')); - - await defer(); - source.write('hello'); - await defer(); - source.destroy(); - - await expect(withTimeout(outputPromise, 250)).resolves.toBe('hello'); - }); - - it('propagates a source error through destroy()', async () => { - // When the source emits an 'error' event, BufferedReadable must tear - // itself down with the same error so consumers see the failure rather - // than waiting forever for data. - const source = new PassThrough(); - const buffered = new BufferedReadable(source); - const boom = new Error('source boom'); - - const errorReceived = new Promise((resolve) => { - buffered.on('error', (err: Error) => { - resolve(err); - }); - }); - - source.emit('error', boom); - - const received = await withTimeout(errorReceived, 250); - expect(received).toBe(boom); - expect(buffered.destroyed).toBe(true); - }); - - it('destroys the underlying source when the wrapper is destroyed', () => { - const source = new PassThrough(); - const buffered = new BufferedReadable(source); - - buffered.destroy(); - - expect(source.destroyed).toBe(true); - }); -}); - -describe('decodeTextWithErrors', () => { - it('decodes utf-16le content under strict mode', () => { - // Covers the utf16le / ucs2 / ucs-2 alias branches that the readText - // tests (which only exercise utf-8) never touch. - const data = Buffer.from('hello', 'utf16le'); - expect(decodeTextWithErrors(data, 'utf16le')).toBe('hello'); - }); - - it('accepts the ucs2 and ucs-2 encoding aliases', () => { - const data = Buffer.from('hello', 'utf16le'); - expect(decodeTextWithErrors(data, 'ucs2')).toBe('hello'); - expect(decodeTextWithErrors(data, 'ucs-2' as BufferEncoding)).toBe('hello'); - }); - - it('falls back to Buffer.toString for non-UTF encodings', () => { - // hex / base64 / latin1 are lossless byte↔character mappings so `errors` - // has no effect; the helper must take the non-TextDecoder branch. - const data = Buffer.from([0x68, 0x69]); // 'hi' in latin1 - expect(decodeTextWithErrors(data, 'latin1')).toBe('hi'); - expect(decodeTextWithErrors(data, 'hex')).toBe('6869'); - }); - - it('preserves valid U+FFFD when ignoring invalid utf-8 bytes', () => { - const data = Buffer.concat([ - Buffer.from('A\uFFFDB', 'utf-8'), - Buffer.from([0xff]), - Buffer.from('C', 'utf-8'), - ]); - - expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A\uFFFDBC'); - }); - - it('matches Python ignore behavior for invalid utf-8 sequence boundaries', () => { - const cases: Buffer[] = [ - Buffer.from([0xc2, 0x41]), - Buffer.from([0xe0, 0xa0, 0x41]), - Buffer.from([0xf0, 0x90, 0x80, 0x41]), - Buffer.from([0xed, 0xa0, 0x80, 0x41]), - ]; - - for (const data of cases) { - expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A'); - } - }); - - it('preserves valid U+FFFD when ignoring invalid utf-16le code units', () => { - const data = Buffer.concat([ - Buffer.from('A\uFFFDB', 'utf16le'), - Buffer.from([0x3d, 0xd8]), // lone high surrogate - Buffer.from('C', 'utf16le'), - Buffer.from([0xff]), // trailing half code unit - ]); - - expect(decodeTextWithErrors(data, 'utf16le', 'ignore')).toBe('A\uFFFDBC'); - }); - - it('matches Python ignore behavior for utf-16le surrogate boundaries', () => { - const emojiPair = Buffer.from([0x3d, 0xd8, 0x00, 0xde]); - expect(decodeTextWithErrors(emojiPair, 'utf16le', 'ignore')).toBe('\u{1F600}'); - - const highThenNormalThenLow = Buffer.from([0x3d, 0xd8, 0x58, 0x00, 0x00, 0xdc]); - expect(decodeTextWithErrors(highThenNormalThenLow, 'utf16le', 'ignore')).toBe('X'); - }); -}); - -describe('globPatternToRegex additional cases', () => { - it('converts ? to a single-character class that excludes /', () => { - const regex = globPatternToRegex('f?o.txt', true); - expect(regex.test('foo.txt')).toBe(true); - expect(regex.test('fao.txt')).toBe(true); - // ? must match exactly one character - expect(regex.test('fo.txt')).toBe(false); - // ? must not cross path segments - expect(regex.test('f/o.txt')).toBe(false); - }); - - it('treats an unclosed [ as a literal bracket', () => { - // Mirrors Python fnmatch/glob: a bare `[` with no closing `]` is - // re-emitted as an escaped literal instead of starting a char class. - const regex = globPatternToRegex('file[', true); - expect(regex.test('file[')).toBe(true); - expect(regex.test('file]')).toBe(false); - }); - - it('escapes regex metacharacters in the default branch', () => { - // `+`, `.`, `(`, `)`, `$`, `^`, `|`, `{`, `}`, `\` must be escaped so - // `a+b.c$d(e)` in a glob pattern matches that literal filename. - const regex = globPatternToRegex('a+b.c$d(e)', true); - expect(regex.test('a+b.c$d(e)')).toBe(true); - // Without escaping, `+` would make `ab` match via one-or-more repetition. - expect(regex.test('ab.c$d(e)')).toBe(false); - }); - - it('treats backslash as an escape character for the next literal', () => { - // `\[` and `\]` should match literal brackets in the filename. - const regex = globPatternToRegex('special \\[bracket\\].ts', true); - expect(regex.test('special [bracket].ts')).toBe(true); - expect(regex.test('special bracket.ts')).toBe(false); - }); - - it('escapes backslashes inside character classes to avoid an unterminated regex', () => { - // A trailing backslash inside a glob char class (`[abc\\]`) would - // escape the closing `]` in the resulting regex. We escape it so the - // regex stays valid and the backslash is treated as a literal member. - const regex = globPatternToRegex('file[abc\\].txt', true); - expect(regex.test('filea.txt')).toBe(true); - expect(regex.test('file\\.txt')).toBe(true); - expect(regex.test('filez.txt')).toBe(false); - }); -}); - -describe('globPatternToRegex', () => { - it('treats [!...] as a negated character class', () => { - const regex = globPatternToRegex('[!a].txt', true); - - expect(regex.test('a.txt')).toBe(false); - expect(regex.test('b.txt')).toBe(true); - }); - - it('treats ^ as a literal character inside glob character classes', () => { - const regex = globPatternToRegex('[^a].txt', true); - - expect(regex.test('^.txt')).toBe(true); - expect(regex.test('a.txt')).toBe(true); - expect(regex.test('b.txt')).toBe(false); - }); - - describe('glob semantic compatibility (Python parity)', () => { - it('treats brace expansion syntax as literal characters', () => { - const regex = globPatternToRegex('*.{js,ts}', true); - - expect(regex.test('file.js')).toBe(false); - expect(regex.test('file.ts')).toBe(false); - expect(regex.test('file.{js,ts}')).toBe(true); - }); - - it('treats leading ^ as a literal character inside character classes', () => { - const regex = globPatternToRegex('[^a].txt', true); - - expect(regex.test('^.txt')).toBe(true); - expect(regex.test('a.txt')).toBe(true); - expect(regex.test('b.txt')).toBe(false); - }); - - it('uses ! for character class negation', () => { - const regex = globPatternToRegex('[!a].txt', true); - - expect(regex.test('b.txt')).toBe(true); - expect(regex.test('a.txt')).toBe(false); - }); - - it('matches hidden files with dot-prefixed patterns', () => { - const regex = globPatternToRegex('.*', true); - - expect(regex.test('.hidden')).toBe(true); - expect(regex.test('.config')).toBe(true); - }); - - it.skip('Python treats **/foo.txt as recursive; current helper is segment-based and does not implement zero-or-more directories', () => { - const regex = globPatternToRegex('**/foo.txt', true); - - expect(regex.test('foo.txt')).toBe(true); - expect(regex.test('a/foo.txt')).toBe(true); - expect(regex.test('a/b/foo.txt')).toBe(true); - }); - - it('keeps single-star matching to a single path segment', () => { - const regex = globPatternToRegex('*/foo.txt', true); - - expect(regex.test('a/foo.txt')).toBe(true); - expect(regex.test('foo.txt')).toBe(false); - expect(regex.test('a/b/foo.txt')).toBe(false); - }); - }); -}); diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts deleted file mode 100644 index acaa13ba1..000000000 --- a/packages/kaos/test/local.test.ts +++ /dev/null @@ -1,1059 +0,0 @@ -import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; -import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { KaosFileExistsError } from '#/errors'; -import { LocalKaos } from '#/local'; -import { afterEach, beforeEach, describe, expect, it, test } from 'vitest'; - -// LocalKaos normalizes every path to forward slashes (pathe). Mirror that in -// path assertions so they hold on Windows, where node:path/node:os produce -// backslashes. -const toPosix = (p: string): string => p.replaceAll('\\', '/'); - -function nodeArgs(code: string): string[] { - return ['node', '-e', code]; -} - -describe('LocalKaos', () => { - let kaos: LocalKaos; - let tempDir: string; - - beforeEach(async () => { - kaos = await LocalKaos.create(); - tempDir = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-')))); - await kaos.chdir(tempDir); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('pathClass, gethome, getcwd', () => { - it('should return posix or win32 pathClass', () => { - const cls = kaos.pathClass(); - if (process.platform === 'win32') { - expect(cls).toBe('win32'); - } else { - expect(cls).toBe('posix'); - } - }); - - it('should return the home directory', () => { - // Python test_local_kaos.py pins `str(gethome()) == str(Path.home())`; - // asserting length > 0 alone was too weak — a stub returning any - // non-empty string would pass. - const home = kaos.gethome(); - expect(home).toBe(toPosix(homedir())); - }); - - it('should return the current working directory', () => { - const cwd = kaos.getcwd(); - expect(cwd).toBe(tempDir); - }); - }); - - describe('chdir + stat', () => { - it('should change directory and stat a file', async () => { - const nested = toPosix(join(tempDir, 'nested')); - await kaos.mkdir(nested); - - await kaos.chdir(nested); - expect(kaos.getcwd()).toBe(nested); - - const filePath = join(nested, 'file.txt'); - await kaos.writeText(filePath, 'hello world'); - - const statResult = await kaos.stat(filePath); - expect(statResult.stSize).toBe(Buffer.byteLength('hello world', 'utf-8')); - }); - - it('should reject chdir into a regular file', async () => { - // chdir must explicitly refuse targets that resolve to non-directories - // so relative I/O calls after the chdir do not silently treat a file - // path as a working directory. - const filePath = join(tempDir, 'not-a-dir.txt'); - await kaos.writeText(filePath, 'content'); - await expect(kaos.chdir(filePath)).rejects.toThrow(/Not a directory/); - }); - - it('should accept backslashes as path separators', async () => { - const nested = join(tempDir, 'backslash-test'); - await kaos.mkdir(nested); - const filePath = join(nested, 'file.txt'); - await kaos.writeText(filePath, 'hello'); - - // Use backslashes — they should be treated as forward slashes. - const backslashPath = filePath.replaceAll('/', '\\'); - const statResult = await kaos.stat(backslashPath); - expect(statResult.stSize).toBe(Buffer.byteLength('hello', 'utf-8')); - }); - }); - - describe('iterdir path normalization', () => { - it('should produce normalized paths even when the argument has a trailing separator', async () => { - // Regression: previously, iterdir manually concatenated `resolved + sep - // + entry`, which produced `//entry` for roots like `/` and `C:\\entry` - // for Windows drives. Using pathJoin correctly collapses the extra - // separator. - await kaos.writeText(join(tempDir, 'file.txt'), 'x'); - - const entries: string[] = []; - // Pass tempDir with an explicit trailing slash to simulate the root - // edge case without needing a writable filesystem root in the test. - for await (const entry of kaos.iterdir(tempDir + '/')) { - entries.push(entry); - } - - expect(entries).toContain(toPosix(join(tempDir, 'file.txt'))); - // No entry should contain duplicated separators. - expect(entries.every((e) => !e.includes('//'))).toBe(true); - }); - }); - - describe('iterdir + glob', () => { - it('should list directory entries and match glob patterns', async () => { - await kaos.mkdir(join(tempDir, 'alpha')); - await kaos.writeText(join(tempDir, 'bravo.txt'), 'bravo'); - await kaos.writeText(join(tempDir, 'charlie.TXT'), 'charlie'); - - const entries: string[] = []; - for await (const entry of kaos.iterdir(tempDir)) { - entries.push(entry); - } - const names = entries.map((e) => e.split('/').pop()!); - expect(new Set(names)).toEqual(new Set(['alpha', 'bravo.txt', 'charlie.TXT'])); - - const matched: string[] = []; - for await (const entry of kaos.glob(tempDir, '*.txt')) { - matched.push(entry); - } - const matchedNames = matched.map((e) => e.split('/').pop()!); - expect(new Set(matchedNames)).toEqual(new Set(['bravo.txt'])); - }); - }); - - describe('glob hidden files', () => { - it('should include hidden files in glob results', async () => { - await kaos.writeText(join(tempDir, '.gitlab-ci.yml'), 'stages: [build]'); - await kaos.writeText(join(tempDir, 'config.yml'), 'key: value'); - - const matched: string[] = []; - for await (const entry of kaos.glob(tempDir, '*.yml')) { - matched.push(entry); - } - const names = matched.map((e) => e.split('/').pop()!); - expect(names).toContain('.gitlab-ci.yml'); - expect(names).toContain('config.yml'); - }); - - it('should glob through hidden directories with ** pattern', async () => { - await kaos.mkdir(join(tempDir, 'src')); - await kaos.mkdir(join(tempDir, 'src', '.config')); - await kaos.writeText(join(tempDir, 'src', '.config', 'settings.yml'), 'debug: true'); - await kaos.writeText(join(tempDir, 'src', 'main.ts'), 'pass'); - - const deepMatched: string[] = []; - for await (const entry of kaos.glob(tempDir, 'src/**/*.yml')) { - deepMatched.push(entry); - } - expect(deepMatched.some((p) => p.includes('.config'))).toBe(true); - }); - }); - - describe('readText/writeText', () => { - it('should write, read, append, and readLines', async () => { - const filePath = join(tempDir, 'note.txt'); - - const written = await kaos.writeText(filePath, 'line1'); - expect(written).toBe('line1'.length); - - const content = await kaos.readText(filePath); - expect(content).toBe('line1'); - - await kaos.writeText(filePath, '\nline2', { mode: 'a' }); - - const lines: string[] = []; - for await (const line of kaos.readLines(filePath)) { - lines.push(line); - } - expect(lines.join('')).toBe('line1\nline2'); - }); - }); - - describe('readLines streaming', () => { - async function collectLines(path: string, options?: Parameters[1]) { - const lines: string[] = []; - for await (const line of kaos.readLines(path, options)) { - lines.push(line); - } - return lines; - } - - it('preserves content exactly across representative line endings', async () => { - const fixtures: Array<[string, string]> = [ - ['multiline', 'line1\nline2\nline3\n'], - ['no trailing newline', 'line1\nline2'], - ['single line', 'only'], - ['single newline', '\n'], - ['empty', ''], - ['crlf', 'a\r\nb\r\n'], - ['lone cr', 'a\rB\n'], - ]; - for (const [name, content] of fixtures) { - const filePath = join(tempDir, `${name}.txt`); - await kaos.writeText(filePath, content); - expect((await collectLines(filePath)).join('')).toBe(content); - } - }); - - it('preserves multibyte characters and long single lines across chunk boundaries', async () => { - const filePath = join(tempDir, 'boundary.txt'); - const content = `${'a'.repeat(65535)}😀\n${'x'.repeat(200000)}`; - await kaos.writeText(filePath, content); - await expect(collectLines(filePath)).resolves.toEqual([ - `${'a'.repeat(65535)}😀\n`, - 'x'.repeat(200000), - ]); - }); - - it('preserves U+FEFF at the start of a non-first line', async () => { - const filePath = join(tempDir, 'bom-line.txt'); - const content = 'a\n\uFEFFb\n'; - await kaos.writeText(filePath, content); - await expect(collectLines(filePath)).resolves.toEqual(['a\n', '\uFEFFb\n']); - }); - - it('keeps utf16le and hex on the decode-then-split path', async () => { - const utf16Path = join(tempDir, 'utf16le.txt'); - await kaos.writeBytes(utf16Path, Buffer.from('a\n\u0A41\n', 'utf16le')); - await expect(collectLines(utf16Path, { encoding: 'utf16le' })).resolves.toEqual([ - 'a\n', - 'ੁ\n', - ]); - - const hexPath = join(tempDir, 'hex.txt'); - await kaos.writeBytes(hexPath, Buffer.from('a\nb')); - await expect(collectLines(hexPath, { encoding: 'hex' })).resolves.toEqual(['610a62']); - }); - - it('throws lazily when strict UTF-8 errors appear after the first line', async () => { - const filePath = join(tempDir, 'invalid-after-first-line.txt'); - await kaos.writeBytes(filePath, Buffer.concat([Buffer.from('ok\n', 'utf-8'), Buffer.from([0xff])])); - const gen = kaos.readLines(filePath); - await expect(gen.next()).resolves.toMatchObject({ value: 'ok\n', done: false }); - await expect(gen.next()).rejects.toThrow(); - }); - }); - - describe('scanTextFile', () => { - it('counts lines and classifies line endings', async () => { - const lf = join(tempDir, 'lf.txt'); - await kaos.writeText(lf, 'a\nb'); - await expect(kaos.scanTextFile(lf)).resolves.toMatchObject({ - totalLines: 2, - endsWithNewline: false, - hasNul: false, - lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false }, - }); - - const crlf = join(tempDir, 'crlf.txt'); - await kaos.writeText(crlf, 'a\r\nb\r\n'); - await expect(kaos.scanTextFile(crlf)).resolves.toMatchObject({ - totalLines: 2, - endsWithNewline: true, - lineEndingFlags: { hasCrLf: true, hasLf: false, hasLoneCr: false }, - }); - - const loneCr = join(tempDir, 'lone-cr.txt'); - await kaos.writeText(loneCr, 'a\rB\n'); - await expect(kaos.scanTextFile(loneCr)).resolves.toMatchObject({ - totalLines: 1, - lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: true }, - }); - }); - - it('detects NUL and invalid UTF-8', async () => { - const nul = join(tempDir, 'nul.txt'); - await kaos.writeBytes(nul, Buffer.from('a\u0000b\n', 'utf-8')); - await expect(kaos.scanTextFile(nul)).resolves.toMatchObject({ hasNul: true }); - - const invalid = join(tempDir, 'invalid.txt'); - await kaos.writeBytes(invalid, Buffer.from([0xff])); - await expect(kaos.scanTextFile(invalid)).rejects.toThrow(); - }); - }); - - describe('readLineRange', () => { - async function collectRange(path: string, startLine: number, maxLines: number) { - const lines: string[] = []; - for await (const line of kaos.readLineRange(path, { startLine, maxLines })) { - lines.push(line); - } - return lines; - } - - it('reads only the requested line window', async () => { - const filePath = join(tempDir, 'range.txt'); - await kaos.writeText(filePath, 'a\nb\nc\nd\n'); - await expect(collectRange(filePath, 2, 2)).resolves.toEqual(['b\n', 'c\n']); - await expect(collectRange(filePath, 5, 2)).resolves.toEqual([]); - }); - - it('preserves U+FEFF at the start of a ranged non-first line', async () => { - const filePath = join(tempDir, 'range-bom.txt'); - await kaos.writeText(filePath, 'a\n\uFEFFb\n'); - await expect(collectRange(filePath, 2, 1)).resolves.toEqual(['\uFEFFb\n']); - }); - }); - - describe('readTailLines', () => { - async function collectTail(path: string, tailCount: number) { - const lines: string[] = []; - for await (const line of kaos.readTailLines(path, { tailCount })) { - lines.push(line); - } - return lines; - } - - it('reads last lines with and without trailing newline', async () => { - const trailing = join(tempDir, 'tail-trailing.txt'); - await kaos.writeText(trailing, 'a\nb\nc\n'); - await expect(collectTail(trailing, 2)).resolves.toEqual(['b\n', 'c\n']); - - const noTrailing = join(tempDir, 'tail-no-trailing.txt'); - await kaos.writeText(noTrailing, 'a\nb\nc'); - await expect(collectTail(noTrailing, 2)).resolves.toEqual(['b\n', 'c']); - }); - - it('returns the whole file when tailCount exceeds line count', async () => { - const filePath = join(tempDir, 'tail-short.txt'); - await kaos.writeText(filePath, 'a\nb\n'); - await expect(collectTail(filePath, 5)).resolves.toEqual(['a\n', 'b\n']); - }); - - it('preserves CRLF and U+FEFF in tail lines', async () => { - const filePath = join(tempDir, 'tail-crlf-bom.txt'); - await kaos.writeText(filePath, 'a\r\n\uFEFFb\r\n'); - await expect(collectTail(filePath, 1)).resolves.toEqual(['\uFEFFb\r\n']); - }); - }); - - describe('readText errors parameter (Python compat)', () => { - // A file with a valid UTF-8 prefix "中", an invalid standalone byte 0xff, - // and a valid UTF-8 suffix "文". Under strict decoding this throws. - const invalidBytes = Buffer.concat([ - Buffer.from([0xe4, 0xb8, 0xad]), // 中 - Buffer.from([0xff]), - Buffer.from([0xe6, 0x96, 0x87]), // 文 - ]); - - it('throws on invalid utf-8 with errors="strict" (default)', async () => { - const filePath = join(tempDir, 'invalid.txt'); - await kaos.writeBytes(filePath, invalidBytes); - - await expect(kaos.readText(filePath)).rejects.toThrow(); - await expect(kaos.readText(filePath, { errors: 'strict' })).rejects.toThrow(); - }); - - it('returns U+FFFD replacement characters with errors="replace"', async () => { - const filePath = join(tempDir, 'replace.txt'); - await kaos.writeBytes(filePath, invalidBytes); - - const content = await kaos.readText(filePath, { errors: 'replace' }); - expect(content).toContain('\uFFFD'); - expect(content).toContain('中'); - expect(content).toContain('文'); - }); - - it('drops invalid bytes with errors="ignore"', async () => { - const filePath = join(tempDir, 'ignore.txt'); - await kaos.writeBytes(filePath, invalidBytes); - - const content = await kaos.readText(filePath, { errors: 'ignore' }); - expect(content).toBe('中文'); - expect(content).not.toContain('\uFFFD'); - }); - - it('preserves valid U+FFFD characters with errors="ignore"', async () => { - const filePath = join(tempDir, 'ignore-valid-replacement.txt'); - const data = Buffer.concat([ - Buffer.from('A\uFFFDB', 'utf-8'), - Buffer.from([0xff]), - Buffer.from('C', 'utf-8'), - ]); - await kaos.writeBytes(filePath, data); - - const content = await kaos.readText(filePath, { errors: 'ignore' }); - expect(content).toBe('A\uFFFDBC'); - }); - }); - - describe('LF preservation', () => { - it('should not convert LF to CRLF', async () => { - const filePath = join(tempDir, 'lf.txt'); - await kaos.writeText(filePath, 'hello\nworld\n'); - - const raw = await kaos.readBytes(filePath); - expect(raw).toEqual(Buffer.from('hello\nworld\n')); - }); - }); - - describe('CRLF preservation', () => { - it('should preserve CRLF line endings', async () => { - const filePath = join(tempDir, 'crlf.txt'); - await kaos.writeText(filePath, 'hello\r\nworld\r\n'); - - const raw = await kaos.readBytes(filePath); - expect(raw).toEqual(Buffer.from('hello\r\nworld\r\n')); - }); - }); - - describe('mkdir recursive', () => { - it('should create nested directories with parents option', async () => { - const nested = join(tempDir, 'a', 'b', 'c'); - await kaos.mkdir(nested, { parents: true }); - - const s = await kaos.stat(nested); - // Check it's a directory (mode has the directory bit set) - // S_IFDIR = 0o040000 - expect(s.stMode & 0o170000).toBe(0o040000); - }); - - it('should throw when parents:true + existOk:false on existing dir', async () => { - // Regression: fs.mkdir({ recursive: true }) silently succeeds on an - // existing directory. mkdir({ parents: true, existOk: false }) must - // still reject to match the advertised semantics. - const existing = join(tempDir, 'existing'); - await kaos.mkdir(existing); - - await expect(kaos.mkdir(existing, { parents: true, existOk: false })).rejects.toThrow(); - }); - - it('should succeed when parents:true + existOk:true on existing dir', async () => { - const existing = join(tempDir, 'existing'); - await kaos.mkdir(existing); - - await expect(kaos.mkdir(existing, { parents: true, existOk: true })).resolves.toBeUndefined(); - }); - - it('should throw when existOk:true but conflicting path is a file', async () => { - // If the target path already exists as a regular file, `existOk` must - // not silently "succeed" because there is still no directory there. - const filePath = join(tempDir, 'not-a-dir.txt'); - await kaos.writeText(filePath, 'hello'); - - await expect(kaos.mkdir(filePath, { existOk: true })).rejects.toBeInstanceOf( - KaosFileExistsError, - ); - }); - }); - - describe('glob character class negation', () => { - it('[!a] should match non-a files (glob negation, not literal `!`)', async () => { - // Glob character classes use `!` for negation, unlike JavaScript regex. - await kaos.writeText(join(tempDir, 'a.txt'), ''); - await kaos.writeText(join(tempDir, 'b.txt'), ''); - await kaos.writeText(join(tempDir, '!.txt'), ''); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '[!a].txt')) { - matches.push(m); - } - const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); - - expect(names.has('a.txt')).toBe(false); - expect(names.has('b.txt')).toBe(true); - expect(names.has('!.txt')).toBe(true); - }); - }); - - describe('glob ** pattern', () => { - it('should not return duplicates when ** matches nested paths', async () => { - // Regression for the `**` double-recursion bug: a single file nested - // two levels deep must appear exactly once, not 2^depth times. - await kaos.mkdir(join(tempDir, 'a', 'b'), { parents: true }); - await kaos.writeText(join(tempDir, 'a', 'b', 'file.txt'), 'content'); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - } - - expect(matches).toHaveLength(1); - expect(new Set(matches).size).toBe(matches.length); - }); - - it('should match ** at multiple depths without duplicates', async () => { - await kaos.mkdir(join(tempDir, 'a', 'b', 'c'), { parents: true }); - await kaos.writeText(join(tempDir, 'root.txt'), ''); - await kaos.writeText(join(tempDir, 'a', 'mid.txt'), ''); - await kaos.writeText(join(tempDir, 'a', 'b', 'c', 'deep.txt'), ''); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - } - - expect(matches).toHaveLength(3); - expect(new Set(matches).size).toBe(3); - const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); - expect(names).toEqual(new Set(['root.txt', 'mid.txt', 'deep.txt'])); - }); - - it('should not duplicate matches for deep ** patterns', async () => { - // Before the fix, each added depth level doubled the duplicate count. - // Build a 4-level-deep tree with a file at the bottom. - await kaos.mkdir(join(tempDir, 'l1', 'l2', 'l3', 'l4'), { parents: true }); - await kaos.writeText(join(tempDir, 'l1', 'l2', 'l3', 'l4', 'deep.txt'), 'x'); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - } - - expect(matches).toHaveLength(1); - }); - - it('should yield basePath and every recursive entry when the whole pattern is **', async () => { - // A bare `**` pattern enters the final-segment branch of _globWalk and - // must (a) emit basePath itself for the zero-directory match and - // (b) walk every file/dir below it as additional matches. - await kaos.mkdir(join(tempDir, 'sub')); - await kaos.writeText(join(tempDir, 'root.txt'), 'r'); - await kaos.writeText(join(tempDir, 'sub', 'nested.txt'), 'n'); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**')) { - matches.push(m); - } - - const names = new Set(matches.map((p) => p.split(/[/\\]/).pop() ?? '')); - expect(matches).toContain(tempDir); - expect(names.has('root.txt')).toBe(true); - expect(names.has('nested.txt')).toBe(true); - expect(names.has('sub')).toBe(true); - }); - }); - - // ── Symlink cycle safety ──────────────────────────────────────────── - // - // These tests use real filesystem symlinks. Note: macOS/Linux apply - // SYMLOOP_MAX (~40 components) at the kernel level, so an unfixed - // walker doesn't hang forever — it yields a bounded-but-large number - // of cyclic paths (observed ~16 for a self-loop) before ELOOP. The - // assertions here are therefore tight (single-digit expected counts) - // so they distinguish "OS-ELOOP bailout" (buggy) from "app-level - // cycle detection" (fixed). HARD_STOP is a final safety belt in case - // a future kernel allows deeper symlink chains — tests shouldn't hang. - describe('glob symlink cycle safety', () => { - const HARD_STOP = 1000; - - it('T-C1 self-symlink cycle yields exactly one match', async () => { - const { symlink, writeFile, mkdir } = await import('node:fs/promises'); - // tempDir/ring/self → tempDir/ring (self-loop dir) - // tempDir/ring/leaf.txt (the only real file) - const ring = join(tempDir, 'ring'); - await mkdir(ring); - await writeFile(join(ring, 'leaf.txt'), 'real'); - await symlink(ring, join(ring, 'self')); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - if (matches.length >= HARD_STOP) break; - } - // Fixed: visited-inode detects the self-loop on the first recurse - // into `ring/self` (whose resolved inode matches `ring`'s), so the - // walker yields `ring/leaf.txt` exactly once. - // Unfixed: ~16 copies like `ring/self/self/.../leaf.txt` before - // the kernel's SYMLOOP_MAX trips. - expect(matches).toHaveLength(1); - expect(matches[0]!.endsWith('leaf.txt')).toBe(true); - }); - - it('T-C2 mutual cycle (A/to_b→B, B/to_a→A) yields only finite real reaches', async () => { - const { symlink, writeFile, mkdir } = await import('node:fs/promises'); - const a = join(tempDir, 'A'); - const b = join(tempDir, 'B'); - await mkdir(a); - await mkdir(b); - await writeFile(join(a, 'aleaf.txt'), 'a'); - await writeFile(join(b, 'bleaf.txt'), 'b'); - await symlink(b, join(a, 'to_b')); - await symlink(a, join(b, 'to_a')); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - if (matches.length >= HARD_STOP) break; - } - // Fixed: path-local visited allows each legitimate descent (A→to_b - // which hits a fresh B, and B→to_a which hits a fresh A) but - // blocks the close-the-loop second step. Expected exactly 4: - // root/A/aleaf.txt, root/A/to_b/bleaf.txt, - // root/B/bleaf.txt, root/B/to_a/aleaf.txt. - // Unfixed: kernel ELOOP bailout yields many more (observed ~66 - // on macOS). A shared-visited (non-path-local) impl would yield - // only 2 because entering B from the walker's root already marks - // B as visited before A→to_b is traversed. - expect(matches).toHaveLength(4); - expect(matches.filter((p) => p.endsWith('aleaf.txt'))).toHaveLength(2); - expect(matches.filter((p) => p.endsWith('bleaf.txt'))).toHaveLength(2); - }); - - it('T-C3 legit non-cyclic symlink to a sibling tree is followed', async () => { - const { symlink, writeFile, mkdir } = await import('node:fs/promises'); - // target/ is a sibling not under root/, reached only via root/shortcut. - const root = join(tempDir, 'root'); - const target = join(tempDir, 'target'); - await mkdir(root); - await mkdir(target); - await writeFile(join(target, 'reachable.txt'), 'hi'); - await symlink(target, join(root, 'shortcut')); - - const matches: string[] = []; - for await (const m of kaos.glob(root, '**/*.txt')) { - matches.push(m); - } - // User-created symlinks to legitimate subtrees should still be followed; - // cycle detection only trips on actual cycles. - expect(matches.some((p) => p.endsWith('reachable.txt'))).toBe(true); - }); - - it('T-C4 broken symlink does not crash the walk', async () => { - const { symlink, writeFile, mkdir } = await import('node:fs/promises'); - const root = join(tempDir, 'broken-root'); - await mkdir(root); - await writeFile(join(root, 'real.txt'), 'r'); - // Points at a path that doesn't exist. - await symlink(join(tempDir, 'does-not-exist'), join(root, 'dangling')); - - const matches: string[] = []; - // Pattern must not match "dangling" (no .txt); we want the real - // file yielded and the walker to not throw on the broken symlink - // (whose stat() rejects). - for await (const m of kaos.glob(root, '**/*.txt')) { - matches.push(m); - } - expect(matches.some((p) => p.endsWith('real.txt'))).toBe(true); - }); - - it('T-C5 regression — non-symlink tree results are unchanged', async () => { - // Plain, non-symlink trees should not be filtered by cycle tracking. - await kaos.mkdir(join(tempDir, 'a', 'b', 'c'), { parents: true }); - await kaos.writeText(join(tempDir, 'r1.txt'), ''); - await kaos.writeText(join(tempDir, 'a', 'r2.txt'), ''); - await kaos.writeText(join(tempDir, 'a', 'b', 'c', 'r3.txt'), ''); - - const matches: string[] = []; - for await (const m of kaos.glob(tempDir, '**/*.txt')) { - matches.push(m); - } - expect(matches).toHaveLength(3); - const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); - expect(names).toEqual(new Set(['r1.txt', 'r2.txt', 'r3.txt'])); - }); - - it('T-C6 two non-cyclic symlinks to same target both traverse (path-local visited)', async () => { - const { symlink, writeFile, mkdir } = await import('node:fs/promises'); - // root/a → target, root/b → target. Both are legitimate (not - // cycles) — the user deliberately aliased the target twice. - const root = join(tempDir, 'root-aliases'); - const target = join(tempDir, 'aliased-target'); - await mkdir(root); - await mkdir(target); - await writeFile(join(target, 'shared.txt'), 'x'); - await symlink(target, join(root, 'a')); - await symlink(target, join(root, 'b')); - - const matches: string[] = []; - for await (const m of kaos.glob(root, '**/*.txt')) { - matches.push(m); - } - // Each alias branch has its own visited set copy, so both aliased paths - // surface. A shared visited set would yield only one of them. - expect(matches.some((p) => p.endsWith(`a/shared.txt`) || p.endsWith(`a\\shared.txt`))).toBe( - true, - ); - expect(matches.some((p) => p.endsWith(`b/shared.txt`) || p.endsWith(`b\\shared.txt`))).toBe( - true, - ); - }); - }); - - describe('readBytes/writeBytes', () => { - it('should round-trip binary data', async () => { - const filePath = join(tempDir, 'data.bin'); - const data = Buffer.from([0x00, 0x01, 0x02, 0xff]); - - const written = await kaos.writeBytes(filePath, data); - expect(written).toBe(4); - - const read = await kaos.readBytes(filePath); - expect(Buffer.compare(read, data)).toBe(0); - }); - }); - - describe('exec streaming', () => { - it('should run a command and stream stdout/stderr', async () => { - const code = `process.stdout.write('hello\\n'); process.stderr.write('stderr line\\n');`; - const proc = await kaos.exec(...nodeArgs(code)); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - const stdoutDone = new Promise((resolve) => { - proc.stdout.on('data', (chunk: Buffer) => { - stdoutChunks.push(chunk); - }); - proc.stdout.on('end', () => { - resolve(); - }); - }); - - const stderrDone = new Promise((resolve) => { - proc.stderr.on('data', (chunk: Buffer) => { - stderrChunks.push(chunk); - }); - proc.stderr.on('end', () => { - resolve(); - }); - }); - - const exitCode = await proc.wait(); - await stdoutDone; - await stderrDone; - - expect(exitCode).toBe(0); - expect(Buffer.concat(stdoutChunks).toString('utf-8').trim()).toBe('hello'); - expect(Buffer.concat(stderrChunks).toString('utf-8').trim()).toBe('stderr line'); - }); - }); - - describe('exec wait-before-read', () => { - it('should buffer output and allow reading after wait', async () => { - const code = `process.stdout.write('hello\\n'); process.stderr.write('stderr line\\n');`; - const proc = await kaos.exec(...nodeArgs(code)); - - const exitCode = await proc.wait(); - expect(exitCode).toBe(0); - - // Read streams after process has already exited - const stdoutData = await streamToBuffer(proc.stdout); - const stderrData = await streamToBuffer(proc.stderr); - - expect(stdoutData.toString('utf-8').trim()).toBe('hello'); - expect(stderrData.toString('utf-8').trim()).toBe('stderr line'); - }); - }); - - describe('exec non-zero exit', () => { - it('should return the correct exit code', async () => { - const proc = await kaos.exec(...nodeArgs('process.exit(7)')); - const exitCode = await proc.wait(); - expect(exitCode).toBe(7); - expect(proc.exitCode).toBe(7); - }); - }); - - describe('exec spawn failure', () => { - it('should reject when the binary does not exist', async () => { - await expect(kaos.exec('/absolutely/non-existent/binary')).rejects.toThrow(); - }); - - it('should reject exec() with no arguments', async () => { - // exec(...args) requires at least one argument (the command name). - // Cast through the loose signature so the call even compiles. - await expect((kaos.exec as () => Promise)()).rejects.toThrow( - /at least one argument/, - ); - }); - - it('should reject execWithEnv() with an empty args array', async () => { - // Mirrors the exec() guard: execWithEnv must also demand at least - // one argument (the command itself). - await expect(kaos.execWithEnv([])).rejects.toThrow(/at least one argument/); - }); - }); - - describe('exec timeout', () => { - it('dispose destroys process stdio without killing the process', async () => { - const proc = await kaos.exec(...nodeArgs('setTimeout(() => {}, 10000);')); - - await proc.dispose(); - await proc.dispose(); - - expect(proc.exitCode).toBeNull(); - expect(proc.stdout.destroyed).toBe(true); - expect(proc.stderr.destroyed).toBe(true); - - await proc.kill('SIGKILL'); - await proc.wait(); - }); - - it('should allow killing a long-running process', async () => { - const code = `setTimeout(() => {}, 10000);`; - const proc = await kaos.exec(...nodeArgs(code)); - - expect(proc.pid).toBeGreaterThan(0); - - // Use a short timeout via Promise.race - const result = await Promise.race([ - proc.wait().then((exitCode) => ({ kind: 'exited' as const, code: exitCode })), - new Promise<{ kind: 'timeout' }>((resolve) => - setTimeout(() => { - resolve({ kind: 'timeout' }); - }, 50), - ), - ]); - - expect(result.kind).toBe('timeout'); - - // Kill the process - await proc.kill('SIGKILL'); - const exitCode = await proc.wait(); - // On Unix, killed processes typically have negative exit or 137 - expect(exitCode).not.toBe(0); - }); - }); - - describe('withEnv', () => { - it('overlays every spawned process and can be updated in place', async () => { - const env = { - KAOS_BASE_ENV: 'initial', - KAOS_COLLISION_ENV: 'configured', - }; - const envKaos = kaos.withEnv(env); - const printEnv = - 'process.stdout.write(`${process.env.KAOS_BASE_ENV}|${process.env.KAOS_COLLISION_ENV}|${process.env.KAOS_CALL_ENV}`)'; - - const first = await envKaos.exec('node', '-e', printEnv); - expect(await first.wait()).toBe(0); - expect((await streamToBuffer(first.stdout)).toString('utf-8')).toBe('initial|configured|undefined'); - - const second = await envKaos.execWithEnv(['node', '-e', printEnv], { - ...(process.env as Record), - KAOS_COLLISION_ENV: 'host', - KAOS_CALL_ENV: 'call', - }); - expect(await second.wait()).toBe(0); - expect((await streamToBuffer(second.stdout)).toString('utf-8')).toBe('initial|configured|call'); - - env.KAOS_BASE_ENV = 'updated'; - const third = await envKaos.exec('node', '-e', printEnv); - expect(await third.wait()).toBe(0); - expect((await streamToBuffer(third.stdout)).toString('utf-8')).toBe('updated|configured|undefined'); - }); - }); -}); - -describe('LocalKaos instance isolation', () => { - test('instances have isolated cwds (no process.cwd pollution)', async () => { - const kaosA = await LocalKaos.create(); - const kaosB = await LocalKaos.create(); - - const tmpA = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-')))); - const tmpB = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-')))); - - try { - await kaosA.chdir(tmpA); - await kaosB.chdir(tmpB); - - // kaosA.chdir must not affect kaosB's cwd (no process.chdir pollution). - expect(kaosA.getcwd()).toBe(tmpA); - expect(kaosB.getcwd()).toBe(tmpB); - - // Write a file named "marker.txt" in each cwd using a relative path. - await kaosA.writeText('marker.txt', 'A'); - await kaosB.writeText('marker.txt', 'B'); - - // Read back via each kaos — each should get its own version. - expect(await kaosA.readText('marker.txt')).toBe('A'); - expect(await kaosB.readText('marker.txt')).toBe('B'); - - // exec() should also honour the instance cwd. - const procA = await kaosA.exec('node', '-e', 'process.stdout.write(process.cwd())'); - const procB = await kaosB.exec('node', '-e', 'process.stdout.write(process.cwd())'); - await procA.wait(); - await procB.wait(); - const outA = await streamToBuffer(procA.stdout); - const outB = await streamToBuffer(procB.stdout); - expect(toPosix(outA.toString('utf-8'))).toBe(tmpA); - expect(toPosix(outB.toString('utf-8'))).toBe(tmpB); - } finally { - await rm(tmpA, { recursive: true, force: true }); - await rm(tmpB, { recursive: true, force: true }); - } - }); -}); - -describe('LocalProcess.kill safety', () => { - test('kill() is safe when spawn failed (pid -1 must not signal process group)', async () => { - const kaos = await LocalKaos.create(); - - // Try to spawn a nonexistent command. Node's spawn() returns a - // ChildProcess immediately with pid=undefined; the "error" event - // arrives asynchronously. - let proc; - try { - proc = await kaos.exec('this-command-does-not-exist-xyz123'); - } catch { - // If the environment threw synchronously, there's nothing to kill. - return; - } - - // If pid is -1, kill must be a no-op and must NOT call - // process.kill(-1, ...) which would signal the entire process group. - if (proc.pid <= 0) { - await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); - } - - // Drain error event so the test runner doesn't leak unhandled errors. - try { - await proc.wait(); - } catch { - // Expected for nonexistent commands. - } - }); - - test('kill() handles already-exited process gracefully (ESRCH ignored)', async () => { - const kaos = await LocalKaos.create(); - const proc = await kaos.exec('node', '-e', 'process.exit(0)'); - await proc.wait(); - - // Calling kill after exit should not throw — ESRCH is ignored. - await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); - }); - - // ── Windows process-tree kill ─────────────────────────────────────── - // - // On Windows the Node default kills only the shell parent; grandchildren can - // leak and run beyond the two-phase kill grace window. - // - // This test boots a nested process chain (parent → child → grandchild) - // and asserts that `proc.kill('SIGTERM')` tears the whole tree down. - // The grandchild is probed via its pidfile: if the file still names a - // live pid after the kill, the tree leaked. - test.skipIf(process.platform !== 'win32')( - 'kill() terminates the grandchild on Windows (process tree)', - async () => { - const kaos = await LocalKaos.create(); - const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-'))); - try { - // Run the parent → child → grandchild chain from a real script file - // (see test/fixtures/killtree.cjs) with the pidfile path passed via - // argv. Inline multi-line `node -e` strings get mangled on Windows by - // Node's arg-quoting and by JS string escapes, so the pidfile was - // never written and the test read ENOENT. - const pidPath = join(tmp, 'grandchild.pid'); - const scriptPath = fileURLToPath(new URL('./fixtures/killtree.cjs', import.meta.url)); - const proc = await kaos.exec('node', scriptPath, pidPath); - const start = Date.now(); - while (Date.now() - start < 5000) { - try { - if ((await stat(pidPath)).isFile()) break; - } catch { - /* not yet */ - } - await new Promise((r) => setTimeout(r, 50)); - } - const grandchildPid = Number.parseInt((await readFile(pidPath, 'utf-8')).trim(), 10); - expect(Number.isNaN(grandchildPid)).toBe(false); - - // Kill parent — on Windows this currently leaks the grandchild - // unless `taskkill /T /F` (or equivalent) is used. - await proc.kill('SIGTERM'); - await proc.wait(); - - // Give the OS up to 2s to reap the grandchild. - const reaped = await (async (): Promise => { - for (let i = 0; i < 40; i += 1) { - try { - process.kill(grandchildPid, 0); // "is it still alive?" - } catch { - return true; // ESRCH — grandchild gone - } - await new Promise((r) => setTimeout(r, 50)); - } - return false; - })(); - - expect(reaped).toBe(true); - } finally { - await rm(tmp, { recursive: true, force: true }); - } - }, - 30_000, - ); - - // ── POSIX process-group kill ──────────────────────────────────────── - // - // Structure mirrors the Windows grandchild test above: a Node parent - // spawns a child that spawns a long-running grandchild and writes its - // pid to a file. After `proc.kill('SIGTERM')`, the grandchild must - // be gone within a generous (~2 s) reap window. - test.skipIf(process.platform === 'win32')( - 'kill() terminates the grandchild on POSIX (process tree)', - async () => { - const kaos = await LocalKaos.create(); - const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-posix-'))); - try { - const pidFile = join(tmp, 'grandchild.pid'); - // `exec('bash', '-c', …)` spawns bash as the direct child; the - // embedded node chain spawns a long-running grandchild under it. - // The grandchild writes its pid so we can poll liveness. - const script = ` - node -e 'const { spawn } = require("node:child_process"); - const { writeFileSync } = require("node:fs"); - const g = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"]); - writeFileSync(${JSON.stringify(pidFile)}, String(g.pid)); - setInterval(() => {}, 1000);' - `; - const proc = await kaos.exec('bash', '-c', script); - - const { stat, readFile } = await import('node:fs/promises'); - const start = Date.now(); - while (Date.now() - start < 5000) { - try { - if ((await stat(pidFile)).isFile()) break; - } catch { - /* not yet */ - } - await new Promise((r) => setTimeout(r, 50)); - } - const grandchildPid = Number.parseInt((await readFile(pidFile, 'utf-8')).trim(), 10); - expect(Number.isNaN(grandchildPid)).toBe(false); - - await proc.kill('SIGTERM'); - await proc.wait(); - - const reaped = await (async (): Promise => { - for (let i = 0; i < 40; i += 1) { - try { - process.kill(grandchildPid, 0); - } catch { - return true; - } - await new Promise((r) => setTimeout(r, 50)); - } - return false; - })(); - - expect(reaped).toBe(true); - } finally { - await rm(tmp, { recursive: true, force: true }); - } - }, - 30_000, - ); -}); - -async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk as Buffer)); - } - return Buffer.concat(chunks); -} diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts deleted file mode 100644 index 1b2d6fbde..000000000 --- a/packages/kaos/test/login-shell-path.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Login-shell PATH enrichment. - * - * Reproduces the "Bash tool can't find local `gh`" report: when kimi-code - * is launched from a context that skipped the user's shell profile (GUI - * launcher, non-login parent shell), `process.env.PATH` misses entries - * like `/opt/homebrew/bin`, so every command spawned by the Bash tool - * inherits the impoverished PATH. - * - * `LocalKaos.create()` must probe the user's login shell (`$SHELL -l -c - * /usr/bin/env`, falling back to the OS account's login shell when $SHELL - * is unset or blank) once and append the missing PATH entries to - * `process.env.PATH` — without reordering or overriding what is already - * there. Probe failures (no resolvable shell, hung or broken profile) - * must leave PATH untouched. - * - * The probe/merge unit tests are pure (injected deps) and run on every - * platform. The end-to-end LocalKaos suite spawns a stub shell and is - * skipped on Windows: the problem is specific to POSIX login-shell - * profiles, and the probe must not run there. - */ - -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - applyLoginShellPath, - type LoginShellPathDeps, - mergeLoginShellPath, - probeLoginShellPath, -} from '#/login-shell-path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -interface StubOpts { - readonly platform?: string; - readonly env?: Record; - readonly execFileResult?: string | undefined; - readonly execFileText?: LoginShellPathDeps['execFileText']; - readonly userShell?: string | undefined; -} - -/** Build a stub deps bag; records `execFileText` invocations in `calls`. */ -function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][] } { - const calls: unknown[][] = []; - return { - calls, - deps: { - platform: opts.platform ?? 'darwin', - env: opts.env ?? { SHELL: '/bin/zsh' }, - userShell: () => opts.userShell, - execFileText: - opts.execFileText ?? - (async (file, args, timeoutMs) => { - calls.push([file, args, timeoutMs]); - return opts.execFileResult; - }), - }, - }; -} - -describe('probeLoginShellPath', () => { - it('runs $SHELL -l -c /usr/bin/env and returns its PATH', async () => { - const { deps, calls } = stubDeps({ - execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n', - }); - await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin'); - // env must be invoked by absolute path: a bare `env` resolves through - // the inherited (possibly cwd-dependent) PATH from the workspace cwd, - // so a repo-planted `env` binary could run at session startup. - expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); - }); - - it('keeps the last PATH= line, ignoring profile noise printed earlier', async () => { - const { deps } = stubDeps({ - execFileResult: 'PATH=/from-profile-echo\nsome profile banner\nPATH=/real/bin:/usr/bin\n', - }); - await expect(probeLoginShellPath(deps)).resolves.toBe('/real/bin:/usr/bin'); - }); - - it('returns undefined on Windows without spawning anything', async () => { - const { deps, calls } = stubDeps({ platform: 'win32', execFileResult: 'PATH=/x' }); - await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); - expect(calls).toEqual([]); - }); - - it('falls back to the account login shell when SHELL is unset or blank', async () => { - // launchd/daemon launches can leave $SHELL unset or blank (the very - // contexts whose PATH is impoverished); the probe must then use the - // OS account's login shell instead of giving up. - for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { - const { deps, calls } = stubDeps({ - env, - userShell: '/bin/zsh', - execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n', - }); - await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin'); - expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); - } - }); - - it('returns undefined when SHELL is unset and no account shell is available', async () => { - for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { - const { deps, calls } = stubDeps({ env, execFileResult: 'PATH=/x' }); - await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); - expect(calls).toEqual([]); - } - }); - - it('returns undefined when the shell fails or times out', async () => { - const { deps } = stubDeps({ execFileResult: undefined }); - await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); - }); - - it('returns undefined when the output has no PATH line', async () => { - const { deps } = stubDeps({ execFileResult: 'HOME=/Users/u\nTERM=dumb\n' }); - await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); - }); -}); - -describe('mergeLoginShellPath', () => { - it('appends entries the current PATH lacks, keeping current priority', () => { - expect(mergeLoginShellPath('/usr/bin:/bin', '/opt/homebrew/bin:/usr/bin:/extra')).toBe( - '/usr/bin:/bin:/opt/homebrew/bin:/extra', - ); - }); - - it('returns the current PATH string verbatim when nothing is missing', () => { - // Strict identity, including empty components and duplicates the user - // already has — a no-op merge must not normalize anything. - expect(mergeLoginShellPath('/a::/b:/a:', '/b:/a')).toBe('/a::/b:/a:'); - }); - - it('preserves empty components (cwd lookup) in the current PATH while appending', () => { - // POSIX treats a leading colon, trailing colon, or double colon as - // "search the current directory"; merging must not strip that. - expect(mergeLoginShellPath(':/usr/bin', '/new')).toBe(':/usr/bin:/new'); - expect(mergeLoginShellPath('/usr/bin:', '/new')).toBe('/usr/bin::/new'); - expect(mergeLoginShellPath('/a::/b', '/c')).toBe('/a::/b:/c'); - // A set-but-empty PATH is cwd-only lookup; the empty component stays first. - expect(mergeLoginShellPath('', '/a')).toBe(':/a'); - }); - - it('handles an unset current PATH', () => { - expect(mergeLoginShellPath(undefined, '/a:/b')).toBe('/a:/b'); - }); - - it('skips empty and duplicate login-shell entries', () => { - // Empty login-shell components are never imported: appending a cwd - // lookup the user did not already have would widen their search path. - expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b'); - }); - - it('skips relative login-shell entries', () => { - // `.` and relative components are cwd-dependent lookup with another - // spelling — LocalKaos runs commands from arbitrary workspace - // directories, so importing one would let a command name resolve from - // an untrusted project cwd. Only absolute entries may be appended. - expect(mergeLoginShellPath('/a', '.:bin:../x:/b')).toBe('/a:/b'); - }); -}); - -describe('applyLoginShellPath', () => { - it('merges the probed PATH into the env bag', async () => { - const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; - const { deps } = stubDeps({ env, execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n' }); - await applyLoginShellPath(deps); - expect(env['PATH']).toBe('/usr/bin:/opt/homebrew/bin'); - }); - - it('leaves PATH untouched when the probe fails', async () => { - const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; - const { deps } = stubDeps({ env, execFileResult: undefined }); - await applyLoginShellPath(deps); - expect(env['PATH']).toBe('/usr/bin'); - }); - - it('does not set an unset PATH when the login shell contributes nothing', async () => { - // Pathological but possible: the login-shell PATH holds only empty - // components. Writing '' back would turn "unset" (implementation - // default search path) into "cwd-only lookup". - const env: Record = { SHELL: '/bin/zsh' }; - const { deps } = stubDeps({ env, execFileResult: 'PATH=:::\n' }); - await applyLoginShellPath(deps); - expect('PATH' in env).toBe(false); - }); -}); - -describe.skipIf(process.platform === 'win32')('LocalKaos login-shell PATH enrichment', () => { - let tempDir: string; - let originalPath: string | undefined; - let originalShell: string | undefined; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'kaos-login-path-')); - originalPath = process.env['PATH']; - originalShell = process.env['SHELL']; - }); - - afterEach(async () => { - restoreEnv('PATH', originalPath); - restoreEnv('SHELL', originalShell); - await rm(tempDir, { recursive: true, force: true }); - }); - - it('appends login-shell PATH entries missing from process.env.PATH', async () => { - const extraDir = join(tempDir, 'login-only-bin'); - const stubShell = join(tempDir, 'stub-shell.sh'); - // Stands in for the user's login shell: whatever flags it is invoked - // with, it reports an environment whose PATH carries an entry the - // kimi-code process does not have. - await writeFile(stubShell, `#!/bin/sh\necho "HOME=$HOME"\necho "PATH=${extraDir}:/usr/bin:/bin"\n`); - await chmod(stubShell, 0o755); - process.env['SHELL'] = stubShell; - - // The suite's setup.ts already ran LocalKaos.create() with the real - // $SHELL, consuming the memoised probe. Import a fresh module graph so - // this create() probes the stub shell instead. - vi.resetModules(); - const { LocalKaos } = await import('#/local'); - await LocalKaos.create(); - - const entries = (process.env['PATH'] ?? '').split(':'); - expect(entries).toContain(extraDir); - // Existing entries keep priority: the login-shell extras are appended. - expect(process.env['PATH']?.startsWith(originalPath ?? '')).toBe(true); - }); -}); - -function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} diff --git a/packages/kaos/test/setup.ts b/packages/kaos/test/setup.ts deleted file mode 100644 index ac3953270..000000000 --- a/packages/kaos/test/setup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { beforeEach } from 'vitest'; - -import { setCurrentKaos } from '#/current'; -import { LocalKaos } from '#/local'; - -const kaos = await LocalKaos.create(); - -// Bind synchronously in `beforeEach`. `enterWith` mutates the running async -// context; vitest's test body is awaited next from the same chain, so it -// inherits the binding. An `await` inside `beforeEach` would push the bind -// into a child context that the test body wouldn't see. -beforeEach(() => { - setCurrentKaos(kaos); -}); diff --git a/packages/kaos/test/shell-path-bridge.test.ts b/packages/kaos/test/shell-path-bridge.test.ts deleted file mode 100644 index d1ae756dd..000000000 --- a/packages/kaos/test/shell-path-bridge.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Shell path bridge — drives `createShellPathBridge` with injected - * `execFileSync` / `isFile` fakes (no real processes): lexical drive forms, - * pass-through tiers, cygpath resolution and caching, `toShellPath`. - */ - -import { describe, expect, it, vi } from 'vitest'; - -import { - createShellPathBridge, - type ShellPathBridgeDeps, - type ShellPathBridgeEnv, -} from '#/shell-path-bridge'; - -const WINDOWS_ENV: ShellPathBridgeEnv = { - osKind: 'Windows', - shellName: 'bash', - shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', -}; - -const POSIX_ENV: ShellPathBridgeEnv = { - osKind: 'Linux', - shellName: 'bash', - shellPath: '/bin/bash', -}; - -// cygpath.exe candidates probed for `C:\Program Files\Git\bin\bash.exe`. -const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe'; -const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe'; - -interface StubOpts { - readonly existingPaths?: readonly string[]; - readonly execFileResults?: Readonly>; - readonly execFileSync?: ShellPathBridgeDeps['execFileSync']; -} - -function stubDeps(opts: StubOpts = {}) { - const existing = new Set(opts.existingPaths ?? []); - const execFileSync = vi.fn( - opts.execFileSync ?? - ((file: string, args: readonly string[]): string => { - const result = opts.execFileResults?.[[file, ...args].join(' ')]; - if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`); - return result; - }), - ); - const deps: ShellPathBridgeDeps = { - execFileSync, - isFile: (path: string) => existing.has(path), - }; - return { deps, execFileSync }; -} - -function cygpathKey(firstSegment: string): string { - return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`; -} - -describe('fromShellPath lexical drive forms', () => { - const cases: ReadonlyArray = [ - ['/c:/Users/foo', 'C:/Users/foo'], - ['/c:', 'C:/'], - ['/cygdrive/c/Users/foo', 'C:/Users/foo'], - ['/cygdrive/d', 'D:/'], - ['/c/Users/foo', 'C:/Users/foo'], - ['/C/Users/foo', 'C:/Users/foo'], - ['/c/', 'C:/'], - ['/c', 'C:/'], - ]; - - for (const [input, expected] of cases) { - it(`rewrites "${input}"`, () => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - expect(bridge.fromShellPath(input)).toBe(expected); - expect(execFileSync).not.toHaveBeenCalled(); - }); - } -}); - -describe('fromShellPath pass-through', () => { - it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])( - 'leaves virtual-fs path %s unchanged', - (input) => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - expect(bridge.fromShellPath(input)).toBe(input); - expect(execFileSync).not.toHaveBeenCalled(); - }, - ); - - it.each([ - '/', - '//server/share', - '//server/share/file.txt', - 'relative/path', - 'relative\\path', - 'file.txt', - 'C:\\Users\\foo', - 'C:/Users/foo', - '~/Documents', - ])('leaves %s unchanged without consulting cygpath', (input) => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - expect(bridge.fromShellPath(input)).toBe(input); - expect(execFileSync).not.toHaveBeenCalled(); - }); -}); - -describe('fromShellPath cygpath resolution', () => { - it('resolves a root-relative path through cygpath and caches per first segment', () => { - const { deps, execFileSync } = stubDeps({ - existingPaths: [USR_BIN_CYGPATH], - execFileResults: { - [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n', - }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe( - 'C:/Users/me/AppData/Local/Temp/scratch/a.txt', - ); - expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other'); - expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp'); - expect(execFileSync).toHaveBeenCalledTimes(1); - expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [ - '-w', - '-C', - 'UTF8', - '--', - '/tmp', - ]); - }); - - it('folds dot segments before resolving the mount segment', () => { - const { deps, execFileSync } = stubDeps({ - existingPaths: [USR_BIN_CYGPATH], - execFileResults: { - [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n', - [cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n', - }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/./tmp/note.txt')).toBe( - 'C:/Users/me/AppData/Local/Temp/note.txt', - ); - expect(bridge.fromShellPath('/../tmp/note.txt')).toBe( - 'C:/Users/me/AppData/Local/Temp/note.txt', - ); - expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt'); - expect(execFileSync).toHaveBeenCalledTimes(2); - }); - - it('folds dot segments before lexical drive translation', () => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - - it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath(input)).toBe('/'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - - it('resolves a drive-root mount and keeps it absolute', () => { - const { deps, execFileSync } = stubDeps({ - existingPaths: [USR_BIN_CYGPATH], - execFileResults: { [cygpathKey('work')]: 'D:\\\n' }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/work/app')).toBe('D:/app'); - expect(bridge.fromShellPath('/work')).toBe('D:/'); - expect(execFileSync).toHaveBeenCalledTimes(1); - }); - - it('prefers cygpath.exe next to bash.exe when present', () => { - const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`; - const { deps, execFileSync } = stubDeps({ - existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH], - execFileResults: { [key]: 'C:\\Users\n' }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt'); - expect(execFileSync).toHaveBeenCalledTimes(1); - expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']); - }); - - it('passes through and retries on the next access when cygpath fails', () => { - const { deps, execFileSync } = stubDeps({ - existingPaths: [USR_BIN_CYGPATH], - execFileSync: () => { - throw new Error('cygpath exited 1'); - }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); - expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); - expect(execFileSync).toHaveBeenCalledTimes(2); - }); - - it('passes through and retries when cygpath output is not an absolute win32 path', () => { - const { deps, execFileSync } = stubDeps({ - existingPaths: [USR_BIN_CYGPATH], - execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' }, - }); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); - expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); - expect(execFileSync).toHaveBeenCalledTimes(2); - }); - - it('passes through without spawning when cygpath.exe is missing', () => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - - expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); - expect(bridge.fromShellPath('/home/u')).toBe('/home/u'); - expect(execFileSync).not.toHaveBeenCalled(); - }); -}); - -describe('identity outside win32 bash', () => { - it('is identity on posix', () => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge(POSIX_ENV, deps); - expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); - expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); - expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); - expect(execFileSync).not.toHaveBeenCalled(); - }); - - it('is identity on Windows without bash', () => { - const { deps, execFileSync } = stubDeps(); - const bridge = createShellPathBridge( - { osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' }, - deps, - ); - expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); - expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); - expect(execFileSync).not.toHaveBeenCalled(); - }); -}); - -describe('toShellPath', () => { - it.each([ - ['C:\\Users\\foo', '/c/Users/foo'], - ['C:/Users/foo', '/c/Users/foo'], - ['C:\\', '/c/'], - ['D:\\Projects', '/d/Projects'], - ['\\\\server\\share\\dir', '//server/share/dir'], - ['relative\\path', 'relative/path'], - ['already/posix', 'already/posix'], - ])('maps %s → %s', (input, expected) => { - const { deps } = stubDeps(); - const bridge = createShellPathBridge(WINDOWS_ENV, deps); - expect(bridge.toShellPath(input)).toBe(expected); - }); -}); diff --git a/packages/kaos/test/shell.test.ts b/packages/kaos/test/shell.test.ts deleted file mode 100644 index 35cbd33ab..000000000 --- a/packages/kaos/test/shell.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import type { Kaos } from '#/kaos'; -import { LocalKaos } from '#/local'; -import type { KaosProcess } from '#/process'; - -/** - * Helper to run a shell command via /bin/sh -c and collect stdout/stderr/exitCode. - * Since the new Kaos.exec(...args) doesn't take options, timeout is implemented - * by killing the process after the given duration. - */ -async function runSh( - kaos: Kaos, - command: string, - options?: { timeout?: number; stdinData?: string }, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const proc: KaosProcess = await kaos.exec('/bin/sh', '-c', command); - - // Set up timeout if requested - let timedOut = false; - let timer: ReturnType | undefined; - if (options?.timeout !== undefined) { - timer = setTimeout(() => { - timedOut = true; - void proc.kill('SIGKILL'); - }, options.timeout); - } - - // If stdinData is provided, write it and close stdin - if (options?.stdinData !== undefined) { - proc.stdin.write(options.stdinData); - proc.stdin.end(); - } else { - proc.stdin.end(); - } - - // Collect stdout and stderr concurrently with waiting for process exit - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - const stdoutDone = new Promise((resolve) => { - proc.stdout.on('data', (chunk: Buffer) => { - stdoutChunks.push(chunk); - }); - proc.stdout.on('end', () => { - resolve(); - }); - }); - - const stderrDone = new Promise((resolve) => { - proc.stderr.on('data', (chunk: Buffer) => { - stderrChunks.push(chunk); - }); - proc.stderr.on('end', () => { - resolve(); - }); - }); - - const exitCode = await proc.wait(); - await stdoutDone; - await stderrDone; - - if (timer !== undefined) { - clearTimeout(timer); - } - - return { - stdout: Buffer.concat(stdoutChunks).toString('utf-8'), - stderr: Buffer.concat(stderrChunks).toString('utf-8'), - exitCode: timedOut ? -1 : exitCode, - }; -} - -describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => { - let kaos: Kaos; - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-')); - kaos = await LocalKaos.create(); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - // NOTE: These tests mirror Python test_local_kaos_sh.py one-for-one. - // Python pins stderr to '' on every non-error case and uses inline_snapshot - // for exact stdout comparisons — the TS side now matches that strength so - // any future drift (e.g. a rogue newline or a leaked warning) is caught. - - it('should run a simple command', async () => { - const result = await runSh(kaos, "echo 'Hello World'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('Hello World\n'); - expect(result.stderr).toBe(''); - }); - - it('should handle command with error', async () => { - const result = await runSh(kaos, 'ls /nonexistent/directory'); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(''); - expect(result.stderr).toContain('No such file or directory'); - }); - - it('should support command chaining with &&', async () => { - const result = await runSh(kaos, "echo 'First' && echo 'Second'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('First\nSecond\n'); - expect(result.stderr).toBe(''); - }); - - it('should support command pipe', async () => { - const result = await runSh(kaos, "echo 'Hello World' | wc -w"); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('2'); - expect(result.stderr).toBe(''); - }); - - it('should handle command with timeout (completes before timeout)', async () => { - // Python asserts stdout='' for `sleep 0.1` so pin that exactly — if the - // helper ever introduces its own chatter we want to hear about it. - const result = await runSh(kaos, 'sleep 0.1', { timeout: 5000 }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(''); - expect(result.stderr).toBe(''); - }); - - it('should handle timeout expiration', async () => { - // Python raises TimeoutError from its helper; the TS helper surfaces the - // same condition as exitCode === -1 after force-killing the process. - // The contract pinned here is "super-short timeout kills a long sleep". - const result = await runSh(kaos, 'sleep 60', { timeout: 100 }); - expect(result.exitCode).toBe(-1); - }); - - it('should pass environment variables to shell', async () => { - const result = await runSh(kaos, 'TEST_VAR=\'test_value\'; export TEST_VAR; echo "$TEST_VAR"'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('test_value\n'); - expect(result.stderr).toBe(''); - }); - - it('should perform file operations', async () => { - // Mirror Python test_file_operations: two separate kaos.exec calls so - // that the "file lands on disk between calls" invariant is actually - // exercised, plus explicit stat() check. - const filePath = join(tmpDir, 'test_file.txt'); - - const write = await runSh(kaos, `echo 'Test content' > "${filePath}"`); - expect(write.exitCode).toBe(0); - expect(write.stdout).toBe(''); - expect(write.stderr).toBe(''); - - const statInfo = await fsStat(filePath); - expect(statInfo.isFile()).toBe(true); - - const read = await runSh(kaos, `cat "${filePath}"`); - expect(read.exitCode).toBe(0); - expect(read.stdout).toBe('Test content\n'); - expect(read.stderr).toBe(''); - }); - - it('should handle stdin data', async () => { - // Mirror Python test_command_reads_stdin: use the shell `read` builtin, - // which requires a newline-terminated input. Previously the TS version - // was a trivial `cat` passthrough that did not exercise `read`. - const result = await runSh(kaos, 'read value; printf \'%s\\n\' "$value"', { - stdinData: 'from stdin\n', - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('from stdin\n'); - expect(result.stderr).toBe(''); - }); - - it('should execute commands sequentially with ;', async () => { - const result = await runSh(kaos, "echo 'One'; echo 'Two'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('One\nTwo\n'); - expect(result.stderr).toBe(''); - }); - - it('should support conditional execution with ||', async () => { - const result = await runSh(kaos, "false || echo 'Success'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('Success\n'); - expect(result.stderr).toBe(''); - }); - - it('should support multiple pipes', async () => { - const result = await runSh(kaos, "printf '1\\n2\\n3\\n' | grep '2' | wc -l"); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('1'); - expect(result.stderr).toBe(''); - }); - - it('should handle text processing with sed', async () => { - const result = await runSh(kaos, "echo 'apple banana cherry' | sed 's/banana/orange/'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('apple orange cherry\n'); - expect(result.stderr).toBe(''); - }); - - it('should support command substitution', async () => { - const result = await runSh(kaos, 'echo "Result: $(echo hello)"'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('Result: hello\n'); - expect(result.stderr).toBe(''); - }); - - it('should support arithmetic substitution', async () => { - const result = await runSh(kaos, 'echo "Answer: $((2 + 2))"'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('Answer: 4\n'); - expect(result.stderr).toBe(''); - }); - - it('should handle very long output', async () => { - const result = await runSh(kaos, 'seq 1 100 | head -50'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('1'); - expect(result.stdout).toContain('50'); - expect(result.stdout).not.toContain('51'); - expect(result.stderr).toBe(''); - }); - - it('should read multiple lines from stdin', async () => { - const result = await runSh( - kaos, - 'count=0; while IFS= read -r _; do count=$((count+1)); done; printf \'%s\\n\' "$count"', - { stdinData: 'alpha\nbeta\ngamma\n' }, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('3'); - expect(result.stderr).toBe(''); - }); -}); diff --git a/packages/kaos/test/spawn-options.test.ts b/packages/kaos/test/spawn-options.test.ts deleted file mode 100644 index 321372280..000000000 --- a/packages/kaos/test/spawn-options.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { buildLocalSpawnOptions } from '#/local'; - -// Regression coverage for the "every command pops an empty console window on -// Windows" bug. `child_process.spawn` defaults `windowsHide` to `false`; on -// Windows that makes Node allocate a *visible* console for each child process -// the agent spawns through `BashTool` → `LocalKaos.exec`/`execWithEnv`. The -// fix is to pass `windowsHide: true`. The flag is only observable on Windows, -// so we assert the spawn options builder directly. - -describe('buildLocalSpawnOptions (Windows console-window regression)', () => { - it('sets windowsHide:true on Windows so commands do not flash a console', () => { - const options = buildLocalSpawnOptions(true, 'C:\\repo', undefined); - expect(options.windowsHide).toBe(true); - }); - - it('sets windowsHide:true on POSIX too (it is ignored there, kept unconditional)', () => { - const options = buildLocalSpawnOptions(false, '/repo', undefined); - expect(options.windowsHide).toBe(true); - }); - - it('keeps detached platform-conditional (POSIX tree-kill vs Windows taskkill /T)', () => { - expect(buildLocalSpawnOptions(true, 'C:\\repo', undefined).detached).toBe(false); - expect(buildLocalSpawnOptions(false, '/repo', undefined).detached).toBe(true); - }); - - it('pipes stdin/stdout/stderr and forwards cwd + env', () => { - const env = { FOO: 'bar' }; - const options = buildLocalSpawnOptions(false, '/repo', env); - expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']); - expect(options.cwd).toBe('/repo'); - expect(options.env).toBe(env); - }); -}); diff --git a/packages/kaos/test/ssh-create.test.ts b/packages/kaos/test/ssh-create.test.ts deleted file mode 100644 index 72135eaf3..000000000 --- a/packages/kaos/test/ssh-create.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { EventEmitter } from 'node:events'; - -import type { AnyAuthMethod, ConnectConfig, SFTPWrapper, Stats as SFTPStats } from 'ssh2'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { SSHKaos as SSHKaosType } from '#/ssh'; - -interface CreateHarnessState { - attemptedKeys: string[]; - connectConfigs: ConnectConfig[]; - endCalls: number; - readFileCalls: string[]; -} - -interface CreateHarnessOptions { - onConnect?: (client: EventEmitter, config: ConnectConfig, state: CreateHarnessState) => void; - readFileValues?: Record; - sftp?: SFTPWrapper; - sftpError?: Error; -} - -function makeStats(isDirectory: boolean): SFTPStats { - return { - mode: isDirectory ? 0o040755 : 0o100644, - uid: 1000, - gid: 1000, - size: 0, - atime: 0, - mtime: 0, - isDirectory: () => isDirectory, - isFile: () => !isDirectory, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isSymbolicLink: () => false, - isFIFO: () => false, - isSocket: () => false, - }; -} - -function createSuccessfulSftp(): SFTPWrapper { - return { - realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void { - if (path === '.') { - callback(undefined, '/home/tester'); - return; - } - callback(undefined, path); - }, - stat(path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void { - callback(undefined, makeStats(path !== '/home/tester/file.txt')); - }, - end(): void { - // no-op - }, - } as SFTPWrapper; -} - -async function loadSSHModule(options: CreateHarnessOptions = {}): Promise<{ - SSHKaos: typeof SSHKaosType; - state: CreateHarnessState; -}> { - vi.resetModules(); - - const state: CreateHarnessState = { - attemptedKeys: [], - connectConfigs: [], - endCalls: 0, - readFileCalls: [], - }; - - class MockClient extends EventEmitter { - connect(config: ConnectConfig): void { - state.connectConfigs.push(config); - if (options.onConnect) { - options.onConnect(this, config, state); - return; - } - queueMicrotask(() => { - this.emit('ready'); - }); - } - - end(): void { - state.endCalls += 1; - queueMicrotask(() => { - this.emit('close'); - }); - } - - sftp(callback: (err?: Error, sftp?: SFTPWrapper) => void): void { - if (options.sftpError) { - callback(options.sftpError); - return; - } - callback(undefined, options.sftp ?? createSuccessfulSftp()); - } - } - - vi.doMock('ssh2', () => ({ - Client: MockClient, - utils: { - sftp: { - STATUS_CODE: { - NO_SUCH_FILE: 2, - PERMISSION_DENIED: 3, - FAILURE: 4, - NO_CONNECTION: 6, - CONNECTION_LOST: 7, - OP_UNSUPPORTED: 8, - }, - }, - }, - })); - - vi.doMock('node:fs/promises', () => ({ - readFile: vi.fn(async (path: string) => { - state.readFileCalls.push(path); - const value = options.readFileValues?.[path]; - if (value === undefined) { - throw new Error(`Unexpected readFile(${path})`); - } - return value; - }), - })); - - const { SSHKaos } = await import('#/ssh'); - return { SSHKaos, state }; -} - -afterEach(() => { - vi.doUnmock('ssh2'); - vi.restoreAllMocks(); - vi.resetModules(); -}); - -describe('SSHKaos.create()', () => { - it('initializes cwd equal to gethome() when no cwd option is passed', async () => { - // Pins the Python test_ssh_kaos.py::test_pathclass_home_and_cwd invariant: - // on a fresh SSH connection without an explicit cwd, `getcwd()` must equal - // `gethome()`. The smoke-level SSH suite can't cover this because its - // beforeEach chdirs into a per-test remote dir; this mock harness lets us - // check the invariant without a live SSH server. - const { SSHKaos } = await loadSSHModule(); - - const ssh = await SSHKaos.create({ - host: 'example.com', - username: 'tester', - }); - - expect(ssh.pathClass()).toBe('posix'); - expect(ssh.gethome()).toBe('/home/tester'); - expect(ssh.getcwd()).toBe('/home/tester'); - expect(ssh.getcwd()).toBe(ssh.gethome()); - }); - - it('rejects a cwd that resolves to a regular file', async () => { - const sftp = { - realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void { - if (path === '.') { - callback(undefined, '/home/tester'); - return; - } - callback(undefined, '/home/tester/file.txt'); - }, - stat(_path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void { - callback(undefined, makeStats(false)); - }, - end(): void { - // no-op - }, - } as SFTPWrapper; - - const { SSHKaos, state } = await loadSSHModule({ sftp }); - - const error = await SSHKaos.create({ - host: 'example.com', - username: 'tester', - cwd: 'file.txt', - }).catch((error: unknown) => error); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).name).toBe('KaosValueError'); - expect((error as Error).message).toMatch(/not a directory/); - expect(state.endCalls).toBe(1); - }); - - it('tries multiple private keys via authHandler until one succeeds', async () => { - const { SSHKaos, state } = await loadSSHModule({ - readFileValues: { - '/keys/second': 'second-key', - }, - onConnect(client, config, harnessState) { - const authHandler = config.authHandler; - if (typeof authHandler !== 'function') { - client.emit('error', new Error('missing authHandler')); - return; - } - - const requestNextKey = (): void => { - authHandler([], false, (auth: string | AnyAuthMethod | false) => { - expect(auth).not.toBe(false); - expect(typeof auth).toBe('object'); - if (auth === false || typeof auth !== 'object') { - client.emit('error', new Error('authHandler returned no auth method')); - return; - } - if (auth.type !== 'publickey') { - client.emit('error', new Error(`unexpected auth type: ${auth.type}`)); - return; - } - const key = - typeof auth.key === 'string' - ? auth.key - : Buffer.isBuffer(auth.key) - ? auth.key.toString('utf-8') - : JSON.stringify(auth.key); - harnessState.attemptedKeys.push(key); - if (key === 'first-key') { - requestNextKey(); - return; - } - queueMicrotask(() => { - client.emit('ready'); - }); - }); - }; - - requestNextKey(); - }, - }); - - const ssh = await SSHKaos.create({ - host: 'example.com', - username: 'tester', - keyContents: ['first-key'], - keyPaths: ['/keys/second'], - }); - - expect(ssh.getcwd()).toBe('/home/tester'); - expect(state.readFileCalls).toEqual(['/keys/second']); - expect(state.attemptedKeys).toEqual(['first-key', 'second-key']); - expect(state.connectConfigs[0]?.authHandler).toBeTypeOf('function'); - expect(state.connectConfigs[0]?.privateKey).toBeUndefined(); - }); - - it('ends the client when opening SFTP fails after connect succeeds', async () => { - const { SSHKaos, state } = await loadSSHModule({ - sftpError: new Error('sftp open failed'), - }); - - await expect( - SSHKaos.create({ - host: 'example.com', - username: 'tester', - }), - ).rejects.toThrow(/sftp open failed/); - expect(state.endCalls).toBe(1); - }); - - it('merges extraOptions into the ssh2 ConnectConfig', async () => { - const { SSHKaos, state } = await loadSSHModule(); - - await SSHKaos.create({ - host: 'example.com', - username: 'tester', - extraOptions: { - keepaliveInterval: 10_000, - readyTimeout: 5_000, - // Cast to the ssh2 Algorithms shape — deliberately narrow for the test - // harness so we can assert pass-through without pulling the full ssh2 - // type graph. - algorithms: { cipher: ['aes256-ctr'] } as never, - }, - }); - - const cfg = state.connectConfigs[0]; - expect(cfg).toBeDefined(); - expect(cfg?.keepaliveInterval).toBe(10_000); - expect(cfg?.readyTimeout).toBe(5_000); - expect(cfg?.algorithms).toEqual({ cipher: ['aes256-ctr'] }); - // Managed fields must still come from top-level options. - expect(cfg?.host).toBe('example.com'); - expect(cfg?.username).toBe('tester'); - }); - - it('managed fields override extraOptions when both are specified', async () => { - const { SSHKaos, state } = await loadSSHModule(); - - await SSHKaos.create({ - host: 'managed.example.com', - username: 'managed', - extraOptions: { - // Malicious / accidental attempts to override managed fields must - // NOT win — the top-level values take precedence. - host: 'attacker.example.com', - username: 'attacker', - port: 1234, - } as never, - }); - - const cfg = state.connectConfigs[0]; - expect(cfg?.host).toBe('managed.example.com'); - expect(cfg?.username).toBe('managed'); - expect(cfg?.port).toBe(22); // default, not the 1234 from extraOptions - }); - - it('forwards a password-only connection without building an authHandler', async () => { - // Password auth without any private keys must wire ssh2 ConnectConfig.password - // directly rather than constructing an authHandler (the handler is only - // needed when we're rotating through multiple private keys). - const { SSHKaos, state } = await loadSSHModule(); - - const ssh = await SSHKaos.create({ - host: 'example.com', - username: 'tester', - password: 'hunter2', - }); - - const cfg = state.connectConfigs[0]; - expect(cfg?.password).toBe('hunter2'); - expect(cfg?.authHandler).toBeUndefined(); - expect(ssh.getcwd()).toBe('/home/tester'); - }); - - it('queues password after private keys when both are provided', async () => { - // When the user supplies both keyContents and a password, buildAuthHandler - // should try every private key first, then fall back to password auth. - // We observe this by walking the handler to exhaustion and recording - // which auth entries it yields. - const yielded: string[] = []; - const { SSHKaos } = await loadSSHModule({ - onConnect(client, config) { - const handler = config.authHandler; - if (typeof handler !== 'function') { - client.emit('error', new Error('missing authHandler')); - return; - } - - // Drain the handler — it yields one auth method per call until it - // runs out, at which point it must emit `false` to signal "no more - // auth methods" (but we stop as soon as we see the password entry). - const pump = (): void => { - handler([], false, (auth: string | AnyAuthMethod | false) => { - if (auth === false) { - client.emit('error', new Error('unexpected end of auth queue')); - return; - } - if (typeof auth !== 'object') { - client.emit('error', new Error(`unexpected auth: ${auth}`)); - return; - } - yielded.push(auth.type); - if (auth.type === 'password') { - queueMicrotask(() => { - client.emit('ready'); - }); - return; - } - pump(); - }); - }; - pump(); - }, - }); - - await SSHKaos.create({ - host: 'example.com', - username: 'tester', - keyContents: ['only-key'], - password: 'hunter2', - }); - - expect(yielded).toEqual(['publickey', 'password']); - }); -}); diff --git a/packages/kaos/test/ssh-process.test.ts b/packages/kaos/test/ssh-process.test.ts deleted file mode 100644 index 010ecf49f..000000000 --- a/packages/kaos/test/ssh-process.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { spawn } from 'node:child_process'; -import { PassThrough } from 'node:stream'; - -import { describe, expect, test } from 'vitest'; - -import { SSHProcess } from '#/ssh'; - -/** - * Build a minimal fake ssh2 ClientChannel that satisfies SSHProcess's needs: - * - * - behaves as a Readable (for stdout) via PassThrough - * - exposes .stderr as a Readable via a second PassThrough - * - records .signal() and .close() calls - * - emits 'close' / 'exit' on-demand - */ -function createFakeChannel(): { - channel: unknown; - signalCalls: string[]; - closeCalls: number; - emitClose: () => void; - emitExit: (code: number) => void; -} { - const stdout = new PassThrough(); - const stderr = new PassThrough(); - const signalCalls: string[] = []; - let closeCalls = 0; - - // Listeners registered via channel.on(...) - const listeners = new Map void>>(); - - const channel = Object.assign(stdout, { - stderr, - signal(name: string): void { - signalCalls.push(name); - }, - close(): void { - closeCalls++; - }, - // Override .on to capture lifecycle listeners ('close', 'exit') while - // still letting the underlying Readable receive 'data'/'end'/'error'. - on(event: string, cb: (...args: unknown[]) => void): unknown { - if (event === 'close' || event === 'exit') { - let arr = listeners.get(event); - if (!arr) { - arr = []; - listeners.set(event, arr); - } - arr.push(cb); - return channel; - } - return PassThrough.prototype.on.call(stdout, event, cb); - }, - }); - - function emit(event: string, ...args: unknown[]): void { - const arr = listeners.get(event); - if (!arr) return; - for (const cb of arr) { - cb(...args); - } - } - - return { - channel, - signalCalls, - get closeCalls() { - return closeCalls; - }, - emitClose: () => { - emit('close'); - }, - emitExit: (code: number) => { - emit('exit', code); - }, - }; -} - -function createChildBackedChannel(): { channel: unknown } { - const child = spawn(process.execPath, [ - '-e', - [ - "process.on('SIGTERM', () => {", - " console.log('cleanup done');", - ' process.exit(42);', - '});', - "console.log('ready');", - 'setInterval(() => {}, 1000);', - ].join('\n'), - ]); - - const stdout = new PassThrough(); - child.stdout.pipe(stdout); - - const channel = Object.assign(stdout, { - stderr: child.stderr, - signal(name: string): void { - child.kill(`SIG${name}` as NodeJS.Signals); - }, - close(): void { - child.kill('SIGTERM'); - }, - on(event: string, listener: (...args: unknown[]) => void): unknown { - if (event === 'exit' || event === 'close') { - child.on(event, listener as (...args: [number | null]) => void); - return channel; - } - return PassThrough.prototype.on.call(stdout, event, listener); - }, - }); - - return { channel }; -} - -describe('SSHProcess.kill()', () => { - test('kill("SIGTERM") sends "TERM" to channel.signal (strips SIG prefix)', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - await proc.kill('SIGTERM'); - - expect(fake.signalCalls).toEqual(['TERM']); - expect(fake.closeCalls).toBe(0); - }); - - test('kill("SIGINT") sends "INT"', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - await proc.kill('SIGINT'); - - expect(fake.signalCalls).toEqual(['INT']); - }); - - test('kill("SIGKILL") sends "KILL"', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - await proc.kill('SIGKILL'); - - expect(fake.signalCalls).toEqual(['KILL']); - }); - - test('kill() with no signal defaults to "TERM"', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - await proc.kill(); - - expect(fake.signalCalls).toEqual(['TERM']); - }); - - test('kill() with a signal that does not start with "SIG" is passed through unchanged', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - // Cast through unknown because NodeJS.Signals is a string-literal type. - await proc.kill('USR1' as unknown as NodeJS.Signals); - - expect(fake.signalCalls).toEqual(['USR1']); - }); - - test('wait() resolves with the exit code emitted before close', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - // Fire exit first, then close. - fake.emitExit(42); - fake.emitClose(); - - const code = await proc.wait(); - expect(code).toBe(42); - expect(proc.exitCode).toBe(42); - }); - - test('wait() resolves with 1 (abnormal) when close arrives without exit', async () => { - const fake = createFakeChannel(); - const proc = new SSHProcess(fake.channel as never); - - fake.emitClose(); - - const code = await proc.wait(); - expect(code).toBe(1); - expect(proc.exitCode).toBe(1); - }); - - test.skipIf(process.platform === 'win32')('kill(SIGTERM) preserves cleanup output and the real exit status', async () => { - const { channel } = createChildBackedChannel(); - const proc = new SSHProcess(channel as never); - const stdoutChunks: Buffer[] = []; - - proc.stdout.on('data', (chunk: Buffer) => { - stdoutChunks.push(Buffer.from(chunk)); - }); - const stdoutEnded = new Promise((resolve) => { - proc.stdout.on('end', () => { - resolve(); - }); - }); - - const firstChunk = await new Promise((resolve) => { - proc.stdout.once('data', (chunk: Buffer) => { - resolve(chunk); - }); - }); - expect(firstChunk.toString()).toContain('ready'); - - await proc.kill('SIGTERM'); - - const exitCode = await proc.wait(); - await Promise.race([ - stdoutEnded, - new Promise((resolve) => { - setTimeout(resolve, 250); - }), - ]); - const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); - - expect(exitCode).toBe(42); - expect(proc.exitCode).toBe(42); - expect(stdout).toContain('ready'); - expect(stdout).toContain('cleanup done'); - }, 10000); -}); diff --git a/packages/kaos/test/ssh.test.ts b/packages/kaos/test/ssh.test.ts deleted file mode 100644 index e506e3f1f..000000000 --- a/packages/kaos/test/ssh.test.ts +++ /dev/null @@ -1,1472 +0,0 @@ -import { EventEmitter } from 'node:events'; - -import { KaosFileExistsError, KaosValueError } from '#/errors'; -import { - KaosConnectionError, - KaosFileNotFoundError, - KaosPermissionError, - KaosSSHError, - SSHKaos, -} from '#/ssh'; -import type { StatResult } from '#/types'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from 'vitest'; - -// Environment variable configuration for SSH connection -const SSH_SMOKE = process.env['KAOS_SSH_SMOKE'] === '1'; -const SSH_HOST = process.env['KAOS_SSH_HOST'] ?? '127.0.0.1'; -const SSH_PORT = Number(process.env['KAOS_SSH_PORT'] ?? '22'); -const SSH_USERNAME = process.env['KAOS_SSH_USERNAME']; -const SSH_PASSWORD = process.env['KAOS_SSH_PASSWORD']; -const SSH_KEY_PATHS = process.env['KAOS_SSH_KEY_PATHS']?.split(',').filter(Boolean); -const SSH_KEY_CONTENTS = process.env['KAOS_SSH_KEY_CONTENTS']?.split('|||').filter(Boolean); - -// S_IFMT mask and file type constants -const S_IFMT = 0o170000; -const S_IFDIR = 0o040000; -const S_IFREG = 0o100000; - -async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk as Buffer)); - } - return Buffer.concat(chunks); -} - -// Explicit opt-in smoke: set KAOS_SSH_SMOKE=1 plus SSH credentials. -describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => { - let sshKaos: SSHKaos; - let remoteBase = ''; - - beforeAll(async () => { - if (SSH_USERNAME === undefined) { - throw new Error('KAOS_SSH_SMOKE=1 requires KAOS_SSH_USERNAME'); - } - - // Dynamic import to avoid compilation errors when ssh2 is not available - const { SSHKaos: SSHKaosClass } = await import('#/ssh'); - sshKaos = await SSHKaosClass.create({ - host: SSH_HOST, - port: SSH_PORT, - username: SSH_USERNAME, - password: SSH_PASSWORD, - keyPaths: SSH_KEY_PATHS, - keyContents: SSH_KEY_CONTENTS, - }); - }); - - beforeEach(async () => { - // Create an isolated remote directory for each test - const uuid = Math.random().toString(36).slice(2); - remoteBase = `${sshKaos.gethome()}/.kaos_test_${process.pid}_${uuid}`; - await sshKaos.mkdir(remoteBase, { parents: true, existOk: true }); - await sshKaos.chdir(remoteBase); - }); - - afterEach(async () => { - // Cleanup the remote directory best-effort, but always restore cwd. - if (remoteBase.length > 0) { - try { - const proc = await sshKaos.exec('rm', '-rf', remoteBase); - await proc.wait(); - } finally { - remoteBase = ''; - await sshKaos.chdir(sshKaos.gethome()); - } - } - }); - - afterAll(async () => { - if (sshKaos) await sshKaos.close(); - }); - - test('pathClass, home, and cwd', () => { - const home = sshKaos.gethome(); - const cwd = sshKaos.getcwd(); - - expect(sshKaos.pathClass()).toBe('posix'); - expect(home.length).toBeGreaterThan(0); - expect(cwd.length).toBeGreaterThan(0); - // Home should be absolute - expect(home.startsWith('/')).toBe(true); - // cwd should be absolute - expect(cwd.startsWith('/')).toBe(true); - }); - - test('cwd defaults to home immediately after connect', () => { - // A freshly connected session sets cwd to the remote home directory, - // so the two must be string-equal before any chdir. - expect(sshKaos.getcwd()).toBe(sshKaos.gethome()); - }); - - test('chdir updates real path', async () => { - await sshKaos.chdir(remoteBase); - expect(sshKaos.getcwd()).toBe(remoteBase); - - await sshKaos.mkdir(remoteBase + '/child', { existOk: true }); - await sshKaos.chdir('child'); - expect(sshKaos.getcwd()).toBe(remoteBase + '/child'); - - await sshKaos.chdir('..'); - expect(sshKaos.getcwd()).toBe(remoteBase); - }); - - test('exec respects cwd', async () => { - await sshKaos.chdir(remoteBase); - - const proc = await sshKaos.exec('pwd'); - const out = (await streamToBuffer(proc.stdout)).toString().trim(); - const code = await proc.wait(); - - expect(code).toBe(0); - expect(out).toBe(remoteBase); - }); - - test('exec wait before read', async () => { - const proc = await sshKaos.exec('echo', 'output'); - - const exitCode = await proc.wait(); - const output = (await streamToBuffer(proc.stdout)).toString().trim(); - - expect(exitCode).toBe(0); - expect(output).toBe('output'); - }); - - test('mkdir respects existOk', async () => { - const nestedDir = remoteBase + '/deep/level'; - - await sshKaos.mkdir(nestedDir, { parents: true, existOk: false }); - - // Python test_mkdir_respects_exist_ok pins `pytest.raises(FileExistsError)` - // — match that strength by asserting the specific KaosFileExistsError class - // rather than any throwable. - await expect(sshKaos.mkdir(nestedDir, { existOk: false })).rejects.toBeInstanceOf( - KaosFileExistsError, - ); - - await sshKaos.mkdir(nestedDir, { parents: true, existOk: true }); - }); - - test('stat reports directory and file metadata', async () => { - const dirStat = await sshKaos.stat(remoteBase, { followSymlinks: false }); - expect((dirStat.stMode & S_IFMT) === S_IFDIR).toBe(true); - - const filePath = remoteBase + '/payload.txt'; - const payload = 'metadata'; - await sshKaos.writeText(filePath, payload); - - const fileStat = await sshKaos.stat(filePath); - expect((fileStat.stMode & S_IFMT) === S_IFREG).toBe(true); - expect(fileStat.stSize).toBe(payload.length); - expect(fileStat.stNlink).toBeGreaterThanOrEqual(0); - }); - - test('file roundtrip via SSH', async () => { - await sshKaos.chdir(remoteBase); - - const textPath = remoteBase + '/text.txt'; - const bytesPath = remoteBase + '/blob.bin'; - - const textPayload = 'Hello SSH\n'; - const appended = 'More data\n'; - const written = await sshKaos.writeText(textPath, textPayload); - expect(written).toBe(textPayload.length); - - const appendedLen = await sshKaos.writeText(textPath, appended, { mode: 'a' }); - expect(appendedLen).toBe(appended.length); - - const fullText = await sshKaos.readText(textPath); - expect(fullText).toBe(textPayload + appended); - - const lines: string[] = []; - for await (const line of sshKaos.readLines(textPath)) { - lines.push(line); - } - expect(lines).toEqual(['Hello SSH', 'More data']); - - const bytesPayload = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); - const bytesWritten = await sshKaos.writeBytes(bytesPath, bytesPayload); - expect(bytesWritten).toBe(bytesPayload.length); - - const roundtrip = await sshKaos.readBytes(bytesPath); - expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0); - - expect(sshKaos.getcwd()).toBe(remoteBase); - }); - - test('iterdir lists child entries', async () => { - await sshKaos.writeText(remoteBase + '/file1.txt', '1'); - await sshKaos.writeText(remoteBase + '/file2.log', '2'); - await sshKaos.mkdir(remoteBase + '/subdir', { existOk: true }); - - const entries: string[] = []; - for await (const entry of sshKaos.iterdir(remoteBase)) { - entries.push(entry); - } - const names = new Set(entries.map((e) => e.split('/').pop()!)); - - expect(names).toEqual(new Set(['file1.txt', 'file2.log', 'subdir'])); - }); - - test('glob is case sensitive', async () => { - await sshKaos.writeText(remoteBase + '/file.log', 'lowercase'); - await sshKaos.writeText(remoteBase + '/FILE.LOG', 'uppercase'); - - const matches = new Set(); - for await (const path of sshKaos.glob(remoteBase, '*.log')) { - matches.add(path); - } - expect(matches.has(remoteBase + '/file.log')).toBe(true); - expect(matches.has(remoteBase + '/FILE.LOG')).toBe(false); - - await expect(async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of sshKaos.glob(remoteBase, '*.log', { caseSensitive: false })) { - // should throw before yielding - } - }).rejects.toThrow('Case insensitive glob is not supported'); - }); - - test('exec streams stdout and stderr', async () => { - const proc = await sshKaos.exec('sh', '-c', "printf 'out\\n' && printf 'err\\n' 1>&2"); - - const [stdoutData, stderrData] = await Promise.all([ - streamToBuffer(proc.stdout), - streamToBuffer(proc.stderr), - ]); - const exitCode = await proc.wait(); - - expect(exitCode).toBe(0); - expect(proc.exitCode).toBe(0); - expect(stdoutData.toString().trim()).toBe('out'); - expect(stderrData.toString().trim()).toBe('err'); - }); - - // NOTE: these execWithEnv tests require the remote sshd to accept the - // injected variable names via its `AcceptEnv` directive (or an equivalent - // mechanism). Stock OpenSSH only whitelists LANG/LC_*; if the test server - // is not configured to accept KAOS_TEST_*, these tests will fail — which - // is exactly the signal we want (it reveals the silent env-drop bug that - // the Python version has). - test('execWithEnv delivers a single env var to the remote process', async () => { - const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_MARKER}"'], { - KAOS_TEST_MARKER: 'beacon42', - }); - const out = (await streamToBuffer(proc.stdout)).toString(); - const code = await proc.wait(); - - expect(code).toBe(0); - expect(out).toBe('beacon42'); - }); - - test('execWithEnv delivers multiple env vars', async () => { - const proc = await sshKaos.execWithEnv( - ['sh', '-c', 'printf "%s|%s" "${KAOS_TEST_A}" "${KAOS_TEST_B}"'], - { KAOS_TEST_A: 'hello', KAOS_TEST_B: 'world' }, - ); - const out = (await streamToBuffer(proc.stdout)).toString(); - const code = await proc.wait(); - - expect(code).toBe(0); - expect(out).toBe('hello|world'); - }); - - test('execWithEnv preserves values with shell metacharacters', async () => { - // Single quotes, dollar signs, backticks, pipes, ampersands, redirects, - // double quotes, and a backslash — anything an unsafe impl might mangle. - const value = `it's $HOME \`id\`; | & < > " \\`; - const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_VALUE}"'], { - KAOS_TEST_VALUE: value, - }); - const out = (await streamToBuffer(proc.stdout)).toString(); - const code = await proc.wait(); - - expect(code).toBe(0); - expect(out).toBe(value); - }); - - test('exec rejects empty command', async () => { - await expect((sshKaos.exec as (...args: string[]) => Promise)()).rejects.toThrow(); - }); - - test('process kill updates returncode', async () => { - const proc = await sshKaos.exec('sh', '-c', 'echo ready; sleep 30'); - - // Read the first line to know the process has started - const firstChunk = await new Promise((resolve) => { - proc.stdout.once('data', (chunk: Buffer) => { - resolve(chunk); - }); - }); - expect(firstChunk.toString().trim()).toBe('ready'); - expect(proc.exitCode).toBeNull(); - - await proc.kill(); - const exitCode = await proc.wait(); - - expect(exitCode).not.toBe(0); - expect(proc.exitCode).toBe(exitCode); - expect(proc.pid).toBe(-1); - }); -}); - -// These tests don't need a live SSH connection — they exercise the -// argument-validation guards that run before any network I/O. We invoke the -// methods through the prototype so no real instance is constructed. -describe('SSHKaos argument validation', () => { - it('exec() throws with the correct class name when args is empty', () => { - const fakeThis = {} as SSHKaos; - expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(KaosValueError); - expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(/SSHKaos\.exec\(\)/); - }); - - it('execWithEnv() throws with the correct class name when args is empty', () => { - const fakeThis = {} as SSHKaos; - expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow(KaosValueError); - expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow( - /SSHKaos\.execWithEnv\(\)/, - ); - }); - - // glob() is an async generator, so the caseSensitive=false guard fires on - // the first pull rather than at call-time. We verify both the error class - // (KaosValueError) and the fact that it rejects before touching SFTP. - it('glob(caseSensitive: false) rejects with KaosValueError', async () => { - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _cwd: string; _sftp: unknown }; - internal._cwd = '/tmp'; - internal._sftp = {}; - - const gen = instance.glob('/some/path', '*', { caseSensitive: false }); - await expect(gen.next()).rejects.toBeInstanceOf(KaosValueError); - }); -}); - -// chdir should refuse to treat a regular file (or anything that isn't a -// directory) as the new working directory. Without this guard, `sftp.realpath` -// happily returns file paths and later relative reads/writes/execs would -// resolve against a file — silently wrong. We exercise this by constructing -// a fake SFTP that returns a file stat so the test needs no live SSH. -describe('SSHKaos.chdir directory validation', () => { - // Minimal SFTPWrapper stub with only the methods chdir needs. - function makeFakeSftp(target: string, isDir: boolean): unknown { - return { - realpath(_path: string, cb: (err: Error | null | undefined, absPath: string) => void): void { - cb(null, target); - }, - stat( - _path: string, - cb: (err: Error | null | undefined, stats: Record) => void, - ): void { - cb(null, { - mode: isDir ? 0o040755 : 0o100644, - size: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: () => isDir, - isFile: () => !isDir, - isSymbolicLink: () => false, - isSocket: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - }); - }, - }; - } - - function makeFakeInstance(sftp: unknown, cwd: string): SSHKaos { - // Bypass the real constructor (which requires a live ssh2 client) and - // populate just the private fields that chdir touches. - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _sftp: unknown; _cwd: string }; - internal._sftp = sftp; - internal._cwd = cwd; - return instance; - } - - it('rejects a target that resolves to a regular file', async () => { - const target = '/tmp/not-a-dir.txt'; - const sftp = makeFakeSftp(target, /*isDir=*/ false); - const kaos = makeFakeInstance(sftp, '/tmp'); - - await expect(kaos.chdir(target)).rejects.toThrow(KaosValueError); - await expect(kaos.chdir(target)).rejects.toThrow(/not a directory/); - // cwd must remain unchanged on failure. - expect(kaos.getcwd()).toBe('/tmp'); - }); - - it('accepts a target that resolves to a directory', async () => { - const target = '/tmp/real-dir'; - const sftp = makeFakeSftp(target, /*isDir=*/ true); - const kaos = makeFakeInstance(sftp, '/tmp'); - - await kaos.chdir(target); - expect(kaos.getcwd()).toBe(target); - }); -}); - -// These tests pin the SFTPError → KaosError mapping contract. They use a -// fake SFTPWrapper that invokes callbacks with errors carrying the standard -// SFTP status codes (NO_SUCH_FILE=2, PERMISSION_DENIED=3), so they run in -// CI without needing a live SSH connection. -// -// The mapping lives in the promisified SFTP helpers in ssh.ts, so every -// SSHKaos method that touches SFTP automatically throws a KaosSSHError -// subclass (KaosFileNotFoundError / KaosPermissionError / …) instead of -// the raw ssh2 error. -describe('SSHKaos SFTP error mapping', () => { - const NO_SUCH_FILE = 2; - const PERMISSION_DENIED = 3; - - interface FailingMethods { - stat?: boolean; - lstat?: boolean; - readFile?: boolean; - writeFile?: boolean; - appendFile?: boolean; - mkdir?: boolean; - readdir?: boolean; - } - - function makeSftpError(errorCode: number): Error { - const err = new Error('simulated SFTP error'); - (err as unknown as { code: number }).code = errorCode; - return err; - } - - // Minimal SFTPWrapper stub. For each I/O method, when `failing[method]` is - // true the callback is invoked with an error carrying `code`; otherwise - // a harmless default is returned. Only the methods that SSHKaos actually - // calls need to be stubbed. - function makeFakeSftp(errorCode: number, failing: FailingMethods): unknown { - const dirStats = { - mode: 0o040755, - size: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: (): boolean => true, - isFile: (): boolean => false, - isSymbolicLink: (): boolean => false, - isSocket: (): boolean => false, - isCharacterDevice: (): boolean => false, - isBlockDevice: (): boolean => false, - isFIFO: (): boolean => false, - }; - - return { - realpath(path: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, path); - }, - stat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { - if (failing.stat === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null, dirStats); - }, - lstat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { - if (failing.lstat === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null, dirStats); - }, - readFile(_path: string, cb: (err: Error | null, data?: Buffer) => void): void { - if (failing.readFile === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null, Buffer.alloc(0)); - }, - writeFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { - if (failing.writeFile === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null); - }, - appendFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { - if (failing.appendFile === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null); - }, - mkdir(_path: string, cb: (err: Error | null) => void): void { - if (failing.mkdir === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null); - }, - readdir(_path: string, cb: (err: Error | null, list?: unknown[]) => void): void { - if (failing.readdir === true) { - cb(makeSftpError(errorCode)); - return; - } - cb(null, []); - }, - // exists() always reports the file is absent so plain mkdir takes the - // create path (where `failing.mkdir` decides the outcome). - exists(_path: string, cb: (exists: boolean) => void): void { - cb(false); - }, - }; - } - - function makeFakeKaos(sftp: unknown): SSHKaos { - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; - internal._sftp = sftp; - internal._cwd = '/'; - internal._home = '/'; - return instance; - } - - // ── stat(): the one method that already wraps errors. ──────────────── - - it('stat() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { stat: true })); - await expect(kaos.stat('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); - }); - - it('stat() maps PERMISSION_DENIED → KaosPermissionError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { stat: true })); - await expect(kaos.stat('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); - }); - - it('stat({ followSymlinks: false }) wraps lstat errors the same way', async () => { - const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { lstat: true })); - await expect(kaos.stat('/missing', { followSymlinks: false })).rejects.toBeInstanceOf( - KaosFileNotFoundError, - ); - }); - - it('stat() wraps unmapped failures as the base KaosSSHError', async () => { - // FAILURE=4 is not specifically mapped → generic KaosSSHError. - const kaos = makeFakeKaos(makeFakeSftp(4, { stat: true })); - await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosSSHError); - }); - - it('stat() maps NO_CONNECTION → KaosConnectionError', async () => { - // SFTP STATUS_CODE.NO_CONNECTION = 6 - const kaos = makeFakeKaos(makeFakeSftp(6, { stat: true })); - await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); - }); - - it('stat() maps CONNECTION_LOST → KaosConnectionError', async () => { - // SFTP STATUS_CODE.CONNECTION_LOST = 7 - const kaos = makeFakeKaos(makeFakeSftp(7, { stat: true })); - await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); - }); - - it('stat() wraps errors without a numeric code as generic KaosSSHError', async () => { - // An error object whose `.code` is not a number (or absent entirely) - // must still be wrapped — the mapSftpError fallback should kick in. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - stat(_p: string, cb: (err: Error | null) => void): void { - // Error with no .code field - cb(new Error('no code here')); - }, - }; - const kaos = makeFakeKaos(sftp); - const err = await kaos.stat('/x').catch((error: unknown) => error); - expect(err).toBeInstanceOf(KaosSSHError); - expect((err as KaosSSHError).message).toContain('no code here'); - }); - - it('stat() wraps non-Error rejections by stringifying them', async () => { - // The helper's getErrorMessage fallback handles non-Error values by - // calling String(error). Reject with a plain string and verify the - // wrap still produces a KaosSSHError with the string in the message. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - stat(_p: string, cb: (err: unknown) => void): void { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - cb('raw-string-rejection'); - }, - }; - const kaos = makeFakeKaos(sftp); - const err = await kaos.stat('/x').catch((error: unknown) => error); - expect(err).toBeInstanceOf(KaosSSHError); - expect((err as KaosSSHError).message).toContain('raw-string-rejection'); - }); - - it('chdir() propagates a realpath failure as a KaosSSHError', async () => { - // sftpRealpath now runs through mapSftpError too — pin that contract. - const sftp = { - realpath(_p: string, cb: (err: Error) => void): void { - const err = new Error('realpath broke'); - (err as unknown as { code: number }).code = 2; - cb(err); - }, - stat(_p: string, _cb: unknown): void { - throw new Error('should not be called'); - }, - }; - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _sftp: unknown; _cwd: string }; - internal._sftp = sftp; - internal._cwd = '/'; - await expect(instance.chdir('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); - }); - - // ── Other I/O methods: mapping is pushed into the promisified helpers - // in ssh.ts so every method gets the same wrapping for free. ───────── - - it('readText() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); - await expect(kaos.readText('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); - }); - - it('readBytes() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); - await expect(kaos.readBytes('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); - }); - - it('writeText() maps PERMISSION_DENIED → KaosPermissionError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); - await expect(kaos.writeText('/forbidden', 'data')).rejects.toBeInstanceOf(KaosPermissionError); - }); - - it('writeText(append) maps PERMISSION_DENIED → KaosPermissionError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { appendFile: true })); - await expect(kaos.writeText('/forbidden', 'data', { mode: 'a' })).rejects.toBeInstanceOf( - KaosPermissionError, - ); - }); - - it('writeBytes() maps PERMISSION_DENIED → KaosPermissionError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); - await expect(kaos.writeBytes('/forbidden', Buffer.from('x'))).rejects.toBeInstanceOf( - KaosPermissionError, - ); - }); - - it('mkdir() maps PERMISSION_DENIED → KaosPermissionError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { mkdir: true })); - await expect(kaos.mkdir('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); - }); - - it('iterdir() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { - const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readdir: true })); - const gen = kaos.iterdir('/missing'); - await expect(gen.next()).rejects.toBeInstanceOf(KaosFileNotFoundError); - }); -}); - -// These tests exercise the pure command-building logic behind execWithEnv -// without needing a live SSH connection. The actual end-to-end delivery of -// env vars is validated by the smoke tests above when KAOS_SSH_SMOKE=1. -describe('SSHKaos._buildExecCommand', () => { - // Bracket access so we can reach the private static helper from tests - // without changing its visibility in the public API. - const build = ( - SSHKaos as unknown as { - _buildExecCommand: (args: string[], cwd: string, env?: Record) => string; - } - )._buildExecCommand; - - it('cd prefix + bare command when no env is supplied', () => { - expect(build(['ls', '-la'], '/home/user')).toBe('cd /home/user && ls -la'); - }); - - it('injects inline assignments before the command', () => { - expect(build(['echo', 'x'], '/home/user', { FOO: 'bar' })).toBe( - 'cd /home/user && FOO=bar echo x', - ); - }); - - it('injects multiple env vars in declaration order', () => { - const out = build(['sh', '-c', 'echo $A $B'], '/home/user', { A: '1', B: '2' }); - expect(out).toBe("cd /home/user && A=1 B=2 sh -c 'echo $A $B'"); - }); - - it('quotes values containing shell metacharacters', () => { - // Single quote in value → shellQuote escapes via the '"'"' trick. - expect(build(['cmd'], '/home/user', { V: "it's" })).toBe(`cd /home/user && V='it'"'"'s' cmd`); - // Dollar sign, backticks, pipe, ampersand → single-quoted wholesale. - expect(build(['cmd'], '/home/user', { V: '$HOME `id` | &' })).toBe( - `cd /home/user && V='$HOME \`id\` | &' cmd`, - ); - }); - - it('quotes an empty value as empty single quotes', () => { - expect(build(['cmd'], '/home/user', { V: '' })).toBe("cd /home/user && V='' cmd"); - }); - - it('rejects env var names that are not valid POSIX identifiers', () => { - expect(() => build(['cmd'], '/home/user', { '1BAD': 'x' })).toThrow(KaosValueError); - expect(() => build(['cmd'], '/home/user', { 'WITH SPACE': 'x' })).toThrow(KaosValueError); - expect(() => build(['cmd'], '/home/user', { 'WITH=EQUALS': 'x' })).toThrow(KaosValueError); - expect(() => build(['cmd'], '/home/user', { '': 'x' })).toThrow(KaosValueError); - }); - - it('accepts underscored and mixed-case identifiers', () => { - expect(build(['cmd'], '/home/user', { _UNDER: '1', camelCase: '2' })).toBe( - 'cd /home/user && _UNDER=1 camelCase=2 cmd', - ); - }); - - it('skips the cd prefix when cwd is the empty string', () => { - expect(build(['cmd', 'arg'], '', { FOO: 'bar' })).toBe('FOO=bar cmd arg'); - }); - - it('quotes cwd paths with spaces and special characters', () => { - expect(build(['cmd'], "/home/u ser's dir")).toBe(`cd '/home/u ser'"'"'s dir' && cmd`); - }); - - it('omits the assignment section entirely when env is an empty object', () => { - // Matches the behavior of plain exec() — no leading space, no KEY=... - expect(build(['cmd', 'arg'], '/home/user', {})).toBe('cd /home/user && cmd arg'); - }); -}); - -// These tests drive the SSHKaos read/stat/glob/iterdir happy paths via -// fake SFTP wrappers, so they run in CI without a live SSH server. Without -// them the smoke block is the only route to those code paths, which means -// CI only sees the error branches. -describe('SSHKaos mock success paths', () => { - interface TreeNode { - type: 'dir' | 'file'; - children?: Record; - content?: Buffer; - // Permission bits only — forces buildStMode to derive the type bits - // from the is* helpers (the "mode without type bits" branch) when true. - stripTypeBits?: boolean; - } - - function makeStats(node: TreeNode): unknown { - const isDir = node.type === 'dir'; - // Either include type bits (0o040000/0o100000) or strip them to force - // the buildStMode helper to derive them via isDirectory()/isFile(). - const baseMode = isDir ? 0o040755 : 0o100644; - const mode = node.stripTypeBits === true ? baseMode & 0o7777 : baseMode; - return { - mode, - size: node.content ? node.content.length : 0, - uid: 1000, - gid: 1000, - atime: 100, - mtime: 200, - isDirectory: () => isDir, - isFile: () => !isDir, - isSymbolicLink: () => false, - isSocket: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - }; - } - - function lookup(root: TreeNode, path: string): TreeNode | undefined { - if (path === '/') return root; - const parts = path.split('/').filter(Boolean); - let current: TreeNode | undefined = root; - for (const part of parts) { - if (!current?.children?.[part]) return undefined; - current = current.children[part]; - } - return current; - } - - // Fake SFTP that exposes a tree and implements the handful of callbacks - // SSHKaos actually calls. Anything not needed is left unimplemented. - function makeTreeSftp(root: TreeNode): unknown { - return { - realpath(path: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, path); - }, - stat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { - const node = lookup(root, path); - if (!node) { - const err = new Error(`no such file: ${path}`); - (err as unknown as { code: number }).code = 2; - cb(err); - return; - } - cb(null, makeStats(node)); - }, - lstat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { - const node = lookup(root, path); - if (!node) { - const err = new Error(`no such file: ${path}`); - (err as unknown as { code: number }).code = 2; - cb(err); - return; - } - cb(null, makeStats(node)); - }, - readdir(path: string, cb: (err: Error | null, list?: unknown[]) => void): void { - const node = lookup(root, path); - if (!node || node.type !== 'dir' || !node.children) { - const err = new Error(`not a directory: ${path}`); - (err as unknown as { code: number }).code = 2; - cb(err); - return; - } - cb( - null, - Object.entries(node.children).map(([filename, child]) => ({ - filename, - attrs: makeStats(child), - })), - ); - }, - readFile(path: string, cb: (err: Error | null, data?: Buffer) => void): void { - const node = lookup(root, path); - if (!node || node.type !== 'file') { - const err = new Error(`no such file: ${path}`); - (err as unknown as { code: number }).code = 2; - cb(err); - return; - } - cb(null, node.content ?? Buffer.alloc(0)); - }, - }; - } - - function makeFakeKaos(sftp: unknown, cwd = '/'): SSHKaos { - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; - internal._sftp = sftp; - internal._cwd = cwd; - internal._home = '/home/tester'; - return instance; - } - - // ── path helpers ──────────────────────────────────────────────────── - - it('normpath delegates to posix.normalize', () => { - // No I/O — just the pure path function. Pins that normpath collapses - // `..` segments. - const kaos = makeFakeKaos(makeTreeSftp({ type: 'dir', children: {} })); - expect(kaos.normpath('/a/b/../c')).toBe('/a/c'); - }); - - // ── stat + buildStMode variants ───────────────────────────────────── - - it('stat() returns a StatResult with file-type bits preserved', async () => { - const root: TreeNode = { - type: 'dir', - children: { - 'file.txt': { type: 'file', content: Buffer.from('hi') }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const fileStat: StatResult = await kaos.stat('/file.txt'); - expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); - expect(fileStat.stSize).toBe(2); - expect(fileStat.stUid).toBe(1000); - }); - - it('stat() derives type bits from is* helpers when mode lacks them', async () => { - // mode = 0o755 has zero in the S_IFMT bits, so buildStMode must fall - // back to isDirectory()/isFile() to fill them in. - const root: TreeNode = { - type: 'dir', - stripTypeBits: true, - children: { - 'bare.txt': { type: 'file', stripTypeBits: true, content: Buffer.from('x') }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const dirStat = await kaos.stat('/'); - expect((dirStat.stMode & 0o170000) === 0o040000).toBe(true); - - const fileStat = await kaos.stat('/bare.txt'); - expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); - }); - - it('stat({ followSymlinks: false }) uses the lstat branch', async () => { - // No actual symlinks — just verify the alternate code path is taken - // (lstat callback is wired up, it returns stats without throwing). - const root: TreeNode = { - type: 'dir', - children: { 'a.txt': { type: 'file', content: Buffer.from('hi') } }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - const result = await kaos.stat('/a.txt', { followSymlinks: false }); - expect(result.stSize).toBe(2); - }); - - // ── iterdir ──────────────────────────────────────────────────────── - - it('iterdir yields directory entries', async () => { - const root: TreeNode = { - type: 'dir', - children: { - tree: { - type: 'dir', - children: { - 'a.txt': { type: 'file', content: Buffer.from('a') }, - 'b.txt': { type: 'file', content: Buffer.from('b') }, - sub: { type: 'dir', children: {} }, - }, - }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const entries: string[] = []; - for await (const entry of kaos.iterdir('/tree')) { - entries.push(entry); - } - expect(new Set(entries)).toEqual(new Set(['/tree/a.txt', '/tree/b.txt', '/tree/sub'])); - }); - - // ── glob ──────────────────────────────────────────────────────────── - - it('glob matches flat patterns against the directory', async () => { - const root: TreeNode = { - type: 'dir', - children: { - tree: { - type: 'dir', - children: { - 'root.txt': { type: 'file', content: Buffer.from('r') }, - 'root.log': { type: 'file', content: Buffer.from('l') }, - }, - }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const matches: string[] = []; - for await (const m of kaos.glob('/tree', '*.txt')) { - matches.push(m); - } - expect(matches).toEqual(['/tree/root.txt']); - }); - - it('glob recurses with **/pattern to match nested files', async () => { - const root: TreeNode = { - type: 'dir', - children: { - tree: { - type: 'dir', - children: { - 'root.txt': { type: 'file', content: Buffer.from('r') }, - sub: { - type: 'dir', - children: { - 'nested.txt': { type: 'file', content: Buffer.from('n') }, - deep: { - type: 'dir', - children: { - 'deeper.txt': { type: 'file', content: Buffer.from('d') }, - }, - }, - }, - }, - }, - }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const matches: string[] = []; - for await (const m of kaos.glob('/tree', '**/*.txt')) { - matches.push(m); - } - const names = new Set(matches); - expect(names).toEqual( - new Set(['/tree/root.txt', '/tree/sub/nested.txt', '/tree/sub/deep/deeper.txt']), - ); - // Pin the de-dup invariant — no single file should appear twice. - expect(matches.length).toBe(new Set(matches).size); - }); - - it('glob with bare ** yields basePath and every nested entry', async () => { - const root: TreeNode = { - type: 'dir', - children: { - tree: { - type: 'dir', - children: { - 'root.txt': { type: 'file', content: Buffer.from('r') }, - sub: { - type: 'dir', - children: { - 'nested.txt': { type: 'file', content: Buffer.from('n') }, - }, - }, - }, - }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const matches: string[] = []; - for await (const m of kaos.glob('/tree', '**')) { - matches.push(m); - } - const set = new Set(matches); - expect(set.has('/tree')).toBe(true); - expect(set.has('/tree/root.txt')).toBe(true); - expect(set.has('/tree/sub')).toBe(true); - expect(set.has('/tree/sub/nested.txt')).toBe(true); - }); - - it('glob with a nested literal path recurses only into the named dir', async () => { - // Pattern `sub/*.txt` → literal `sub` segment, then `*.txt` inside it. - // Exercises the non-`**` recursive branch. - const root: TreeNode = { - type: 'dir', - children: { - tree: { - type: 'dir', - children: { - 'root.txt': { type: 'file', content: Buffer.from('r') }, - sub: { - type: 'dir', - children: { - 'a.txt': { type: 'file', content: Buffer.from('a') }, - 'b.log': { type: 'file', content: Buffer.from('b') }, - }, - }, - }, - }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const matches: string[] = []; - for await (const m of kaos.glob('/tree', 'sub/*.txt')) { - matches.push(m); - } - expect(matches).toEqual(['/tree/sub/a.txt']); - }); - - it('glob silently skips unreadable directories', async () => { - // If readdir() fails the generator should return without throwing — - // makes glob tolerant of permission-limited subtrees. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { - const err = new Error('permission denied'); - (err as unknown as { code: number }).code = 3; - cb(err); - }, - }; - const kaos = makeFakeKaos(sftp); - - const matches: string[] = []; - for await (const m of kaos.glob('/locked', '*.txt')) { - matches.push(m); - } - expect(matches).toEqual([]); - }); - - // ── readLines empty-file early return ───────────────────────────── - - it('readLines yields nothing for an empty file', async () => { - // `readText` returns an empty string, so the generator must take the - // early-return branch instead of walking `splitlines()`. - const root: TreeNode = { - type: 'dir', - children: { 'empty.txt': { type: 'file', content: Buffer.alloc(0) } }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const lines: string[] = []; - for await (const line of kaos.readLines('/empty.txt')) { - lines.push(line); - } - expect(lines).toEqual([]); - }); - - it('readText preserves valid U+FFFD while ignoring invalid utf-8 bytes', async () => { - const data = Buffer.concat([ - Buffer.from('A\uFFFDB', 'utf-8'), - Buffer.from([0xff]), - Buffer.from('C', 'utf-8'), - ]); - const root: TreeNode = { - type: 'dir', - children: { 'mixed.txt': { type: 'file', content: data } }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - await expect(kaos.readText('/mixed.txt', { errors: 'ignore' })).resolves.toBe('A\uFFFDBC'); - }); - - // ── . / .. filter coverage ──────────────────────────────────────── - - it('iterdir at root "/" does not produce double-slash paths', async () => { - // Regression: `basePath + '/' + entry.filename` produced `//foo` when - // basePath was the filesystem root. Now uses `posix.join` to collapse. - const root: TreeNode = { - type: 'dir', - children: { - 'a.txt': { type: 'file', content: Buffer.from('a') }, - sub: { type: 'dir', children: {} }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root), '/'); - - const entries: string[] = []; - for await (const entry of kaos.iterdir('/')) { - entries.push(entry); - } - expect(new Set(entries)).toEqual(new Set(['/a.txt', '/sub'])); - expect(entries.every((e) => !e.includes('//'))).toBe(true); - }); - - it('glob at root "/" does not produce double-slash paths', async () => { - const root: TreeNode = { - type: 'dir', - children: { - 'file.txt': { type: 'file', content: Buffer.from('x') }, - }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root), '/'); - - const matches: string[] = []; - for await (const m of kaos.glob('/', '*.txt')) { - matches.push(m); - } - expect(matches).toEqual(['/file.txt']); - expect(matches.every((p) => !p.includes('//'))).toBe(true); - }); - - it('iterdir filters out "." and ".." entries from readdir output', async () => { - // Some SFTP servers include `.` / `..` in readdir results, so the - // filter in SSHKaos.iterdir must skip them unconditionally. Inject - // those entries into the fake to exercise the filter branch. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { - cb(null, [ - { filename: '.', attrs: { isDirectory: (): boolean => true } }, - { filename: '..', attrs: { isDirectory: (): boolean => true } }, - { filename: 'real.txt', attrs: { isDirectory: (): boolean => false } }, - ]); - }, - }; - const kaos = makeFakeKaos(sftp, '/tree'); - - const entries: string[] = []; - for await (const e of kaos.iterdir('/tree')) { - entries.push(e); - } - // Only the real entry survives — the `.` / `..` are silently dropped. - expect(entries).toEqual(['/tree/real.txt']); - }); - - it('glob filters "." and ".." entries in both the ** and literal branches', async () => { - // Same fake readdir in two walks: one with `**` (covers the `**` - // branch filter) and one with a literal pattern (covers the non-`**` - // branch filter). - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { - cb(null, [ - { - filename: '.', - attrs: { - isDirectory: (): boolean => true, - isFile: (): boolean => false, - }, - }, - { - filename: '..', - attrs: { - isDirectory: (): boolean => true, - isFile: (): boolean => false, - }, - }, - { - filename: 'keeper.txt', - attrs: { - isDirectory: (): boolean => false, - isFile: (): boolean => true, - }, - }, - ]); - }, - }; - const kaos = makeFakeKaos(sftp, '/tree'); - - const viaStar: string[] = []; - for await (const m of kaos.glob('/tree', '*.txt')) viaStar.push(m); - expect(viaStar).toEqual(['/tree/keeper.txt']); - - const viaStarStar: string[] = []; - for await (const m of kaos.glob('/tree', '**')) viaStarStar.push(m); - // The recursion into `.` / `..` must be skipped — we should see only - // the base dir and the keeper file, never an infinite loop. - expect(new Set(viaStarStar)).toEqual(new Set(['/tree', '/tree/keeper.txt'])); - }); - - // ── glob error handling in the ** branch ────────────────────────── - - it('glob ** silently aborts when readdir fails', async () => { - // Similar to the non-`**` readdir-error test, but forcing the code - // through the `**` case's own try/catch. Passing a bare `**` pattern - // takes the `currentPattern === '**'` branch, and the failing - // readdir exercises the swallow-and-return path inside it. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { - const err = new Error('denied'); - (err as unknown as { code: number }).code = 3; - cb(err); - }, - }; - const kaos = makeFakeKaos(sftp, '/tree'); - - const matches: string[] = []; - for await (const m of kaos.glob('/tree', '**')) { - matches.push(m); - } - // Pattern `**` with zero-directory match yields basePath itself - // BEFORE the readdir even runs, so we still see `/tree`. The - // important bit is that no exception propagates out. - expect(matches).toEqual(['/tree']); - }); - - // ── readBytes ────────────────────────────────────────────────────── - - it('readBytes(n) returns only the first n bytes of the file', async () => { - const content = Buffer.from('0123456789'); - const root: TreeNode = { - type: 'dir', - children: { 'data.bin': { type: 'file', content } }, - }; - const kaos = makeFakeKaos(makeTreeSftp(root)); - - const full = await kaos.readBytes('/data.bin'); - expect(Buffer.compare(full, content)).toBe(0); - - const first4 = await kaos.readBytes('/data.bin', 4); - expect(first4.toString()).toBe('0123'); - }); - - // ── execWithEnv body ─────────────────────────────────────────────── - - it('execWithEnv calls clientExec with the env-prefixed command', async () => { - // We only exercise the _execInternal path via execWithEnv here — the - // command-building detail is already covered by the _buildExecCommand - // describe block. This test just pins that execWithEnv() with a valid - // non-empty args array routes through the internal helper. - const commands: string[] = []; - const fakeClient = { - exec(cmd: string, _a: unknown, _b?: unknown): void { - commands.push(cmd); - // Mimic a channel that never emits — we just want to observe the - // command string, not wait for real I/O. - throw new Error('stop'); - }, - }; - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { - _client: unknown; - _cwd: string; - _home: string; - _envLayers: readonly Record[]; - }; - internal._client = fakeClient; - internal._cwd = '/home/tester'; - internal._home = '/home/tester'; - internal._envLayers = []; - - await expect(instance.execWithEnv(['echo', 'hi'], { FOO: 'bar' })).rejects.toThrow('stop'); - - expect(commands).toHaveLength(1); - expect(commands[0]).toBe('cd /home/tester && FOO=bar echo hi'); - }); -}); - -// These tests pin the non-race mkdir error branches in SSHKaos — the -// "path already exists but is a file" and "parents=true with a final -// directory already present under existOk=false" cases. All driven via -// fake SFTP wrappers so no live server is required. -describe('SSHKaos mkdir existOk edge cases', () => { - function makeFakeKaos(sftp: unknown): SSHKaos { - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internal = instance as unknown as { _sftp: unknown; _cwd: string }; - internal._sftp = sftp; - internal._cwd = '/'; - return instance; - } - - function makeFileStats(): unknown { - return { - mode: 0o100644, - size: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: (): boolean => false, - isFile: (): boolean => true, - isSymbolicLink: (): boolean => false, - isSocket: (): boolean => false, - isCharacterDevice: (): boolean => false, - isBlockDevice: (): boolean => false, - isFIFO: (): boolean => false, - }; - } - - function makeDirStats(): unknown { - return { - mode: 0o040755, - size: 0, - uid: 0, - gid: 0, - atime: 0, - mtime: 0, - isDirectory: (): boolean => true, - isFile: (): boolean => false, - isSymbolicLink: (): boolean => false, - isSocket: (): boolean => false, - isCharacterDevice: (): boolean => false, - isBlockDevice: (): boolean => false, - isFIFO: (): boolean => false, - }; - } - - it('mkdir (non-parents) rejects when existOk=true but path is a file', async () => { - // simple mkdir branch: exists=true + existOk=true + not a directory - // must throw instead of silently accepting the file collision. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - exists(_p: string, cb: (exists: boolean) => void): void { - cb(true); - }, - stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { - cb(null, makeFileStats()); - }, - }; - const kaos = makeFakeKaos(sftp); - await expect(kaos.mkdir('/existing-file', { existOk: true })).rejects.toBeInstanceOf( - KaosFileExistsError, - ); - }); - - it('mkdir(parents=true) rejects when the final path exists and existOk=false', async () => { - // Recursive branch: walks to the final component, finds it already - // exists (as a directory, even), and since existOk=false the call - // must surface a KaosFileExistsError instead of silently succeeding. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - exists(_p: string, cb: (exists: boolean) => void): void { - cb(true); - }, - stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { - cb(null, makeDirStats()); - }, - }; - const kaos = makeFakeKaos(sftp); - await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: false })).rejects.toBeInstanceOf( - KaosFileExistsError, - ); - }); - - it('mkdir(parents=true) rejects when an intermediate path is a regular file', async () => { - // Recursive branch: during the walk, an intermediate component - // already exists but is a regular file (not a directory). That must - // be a hard failure regardless of existOk, because the next sftpMkdir - // would otherwise fail with a confusing ENOTDIR. - const sftp = { - realpath(p: string, cb: (err: Error | null, abs: string) => void): void { - cb(null, p); - }, - exists(_p: string, cb: (exists: boolean) => void): void { - cb(true); - }, - stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { - cb(null, makeFileStats()); - }, - }; - const kaos = makeFakeKaos(sftp); - await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: true })).rejects.toBeInstanceOf( - KaosFileExistsError, - ); - }); -}); - -describe('SSHKaos.close lifecycle', () => { - class FakeClient extends EventEmitter { - closed = false; - - end(): void { - queueMicrotask(() => { - this.closed = true; - this.emit('close'); - }); - } - - exec( - _command: string, - optionsOrCallback: - | ((err: Error | undefined, channel: never) => void) - | Record, - maybeCallback?: (err: Error | undefined, channel: never) => void, - ): void { - const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; - if (callback === undefined) { - return; - } - if (this.closed) { - callback(new Error('channel closed'), undefined as never); - return; - } - callback(undefined, undefined as never); - } - } - - function createCloseableKaos(): SSHKaos { - const instance = Object.create(SSHKaos.prototype) as SSHKaos; - const internals = instance as unknown as { - _client: FakeClient; - _cwd: string; - _home: string; - _sftp: { end(): void }; - _envLayers: readonly Record[]; - }; - internals._client = new FakeClient(); - internals._cwd = '/tmp'; - internals._home = '/tmp'; - internals._envLayers = []; - internals._sftp = { - end(): void { - // no-op - }, - }; - return instance; - } - - it('awaits the close event before allowing follow-up execs to observe the closed state', async () => { - const kaos = createCloseableKaos(); - - await kaos.close(); - - await expect(kaos.exec('pwd')).rejects.toThrow(/channel closed/); - }); -}); diff --git a/packages/kaos/tsconfig.json b/packages/kaos/tsconfig.json deleted file mode 100644 index ef502e89c..000000000 --- a/packages/kaos/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "test"] -} diff --git a/packages/kaos/tsdown.config.ts b/packages/kaos/tsdown.config.ts deleted file mode 100644 index f7c3080d5..000000000 --- a/packages/kaos/tsdown.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - entry: ['./src/index.ts'], - format: ['esm'], - dts: true, - outDir: 'dist', - clean: true, - banner: { - js: [ - "import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';", - "import { dirname as __cjsShimDirname } from 'node:path';", - 'const __filename = __cjsShimFileURLToPath(import.meta.url);', - 'const __dirname = __cjsShimDirname(__filename);', - ].join('\n'), - }, - deps: { - alwaysBundle: [], - neverBundle: [], - }, -}); diff --git a/packages/kaos/vitest.config.ts b/packages/kaos/vitest.config.ts deleted file mode 100644 index 0f8c355bf..000000000 --- a/packages/kaos/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - name: 'kaos', - include: ['test/**/*.test.ts'], - setupFiles: ['./test/setup.ts'], - }, -}); diff --git a/packages/klient/scripts/run-docker-e2e.sh b/packages/klient/scripts/run-docker-e2e.sh index 66945afbc..f7d563d8d 100755 --- a/packages/klient/scripts/run-docker-e2e.sh +++ b/packages/klient/scripts/run-docker-e2e.sh @@ -53,7 +53,6 @@ workspace_node_modules=( "docs:/workspace/kimi-code/docs/node_modules" "pkg_kap-server:/workspace/kimi-code/packages/kap-server/node_modules" "pkg_server-e2e:/workspace/kimi-code/packages/klient/node_modules" - "pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules" "pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules" "pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules" "pkg_node-sdk:/workspace/kimi-code/packages/node-sdk/node_modules" diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts index 674e0f05c..580f83b27 100644 --- a/packages/klient/src/contract/mcp.ts +++ b/packages/klient/src/contract/mcp.ts @@ -27,7 +27,7 @@ export const mcpServerStdioConfigSchema = z.object({ args: z.array(z.string()).optional(), env: stringRecordSchema.optional(), cwd: z.string().optional(), - executor: z.enum(['local', 'kaos']).optional(), + executor: z.enum(['local']).optional(), ...mcpServerCommonFields, }); diff --git a/packages/node-sdk/api-extractor.json b/packages/node-sdk/api-extractor.json index f198b8334..c03762579 100644 --- a/packages/node-sdk/api-extractor.json +++ b/packages/node-sdk/api-extractor.json @@ -2,7 +2,6 @@ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "mainEntryPointFilePath": "/.tmp-api-extractor/dts/node-sdk/src/index.d.ts", "bundledPackages": [ - "@moonshot-ai/kaos", "@moonshot-ai/kimi-code-oauth", "@moonshot-ai/kosong" ], diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 756a96cf2..1b1cf4a79 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -60,7 +60,6 @@ }, "devDependencies": { "@moonshot-ai/agent-core-v2": "workspace:^", - "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/klient": "workspace:^", "@moonshot-ai/kosong": "workspace:^", diff --git a/packages/node-sdk/scripts/build-dts.mjs b/packages/node-sdk/scripts/build-dts.mjs index bff57cfaf..49650a3b0 100644 --- a/packages/node-sdk/scripts/build-dts.mjs +++ b/packages/node-sdk/scripts/build-dts.mjs @@ -12,10 +12,9 @@ const providerClientShimPath = path.join(dtsRoot, 'provider-clients.d.ts'); const tscBinPath = packageBinPath('typescript', 'bin/tsc'); const apiExtractorBinPath = packageBinPath('@microsoft/api-extractor', 'bin/api-extractor'); -const packageDirs = new Set(['agent-core-v2', 'kaos', 'klient', 'kosong', 'node-sdk', 'oauth']); +const packageDirs = new Set(['agent-core-v2', 'klient', 'kosong', 'node-sdk', 'oauth']); const workspacePackages = new Map([ ['@moonshot-ai/agent-core-v2', 'agent-core-v2'], - ['@moonshot-ai/kaos', 'kaos'], ['@moonshot-ai/kimi-code-oauth', 'oauth'], ['@moonshot-ai/klient', 'klient'], ['@moonshot-ai/kosong', 'kosong'], @@ -106,7 +105,7 @@ async function rewriteWorkspaceSpecifiers() { `import { GoogleGenAI as GenAIClient } from '${providerClientSpecifier}';`, ); const updated = providerClientText.replaceAll( - /(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core-v2|kaos|kimi-code-oauth|klient|kosong)(?:\/[^"']+)?)\1/g, + /(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core-v2|kimi-code-oauth|klient|kosong)(?:\/[^"']+)?)\1/g, (_match, quote, specifier) => { const resolved = resolveSpecifier({ currentFile: file, diff --git a/packages/node-sdk/src/config/schema.ts b/packages/node-sdk/src/config/schema.ts index 4a19d4401..1c745406e 100644 --- a/packages/node-sdk/src/config/schema.ts +++ b/packages/node-sdk/src/config/schema.ts @@ -232,7 +232,7 @@ export const McpServerStdioConfigSchema = z.object({ args: z.array(z.string()).optional(), env: StringRecordSchema.optional(), cwd: z.string().optional(), - executor: z.enum(['local', 'kaos']).optional(), + executor: z.enum(['local']).optional(), ...McpServerCommonFields, }); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 288b4efc1..84231bbe7 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -1,5 +1,3 @@ -import type { Kaos } from '@moonshot-ai/kaos'; - import { ErrorCodes, KimiError } from '#/errors'; import type { ExperimentalFeatureState } from '#/flag'; import type { ImageLimits } from '#/image'; @@ -140,11 +138,8 @@ export class KimiHarness { } async createSession(options: CreateSessionOptions): Promise { - const { planMode, kaos, persistenceKaos, sessionStartedProperties, ...coreOptions } = options; - const summary = - kaos === undefined && persistenceKaos === undefined - ? await this.rpc.createSession(coreOptions) - : await this.rpc.createSessionWithKaos(coreOptions, kaos ?? persistenceKaos as Kaos, persistenceKaos); + const { planMode, sessionStartedProperties, ...coreOptions } = stripLegacyKaosOptions(options); + const summary = await this.rpc.createSession(coreOptions); const session = new Session({ id: summary.id, workDir: summary.workDir, @@ -169,18 +164,14 @@ export class KimiHarness { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); const { - kaos, - persistenceKaos, sessionStartedProperties: _sessionStartedProperties, ...resumeInput - } = input; + } = stripLegacyKaosOptions(input); // A session whose close is in flight (`isClosed` but not yet unmapped) // is not a valid resume target — fall through and re-resume fresh, which // the engine serializes behind that close. if (active !== undefined && !active.isClosed) { - if (kaos !== undefined || persistenceKaos !== undefined) { - await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); - } else if (input.agentProfile !== undefined) { + if (input.agentProfile !== undefined) { await this.rpc.resumeSession({ ...resumeInput, id }); } return active; @@ -188,7 +179,7 @@ export class KimiHarness { // Coalesce concurrent resumes of the same id onto one facade, keyed by // the full input so a caller with different options (dirs, replay, - // profile, kaos) never has them silently dropped; without this, + // profile) never has them silently dropped; without this, // parallel identical callers each build their own Session over the // shared engine handle, and one facade's close kills the engine handle // under the other. @@ -205,11 +196,8 @@ export class KimiHarness { } private async doResumeSession(input: ResumeSessionInput, id: string): Promise { - const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; - const summary = - kaos === undefined && persistenceKaos === undefined - ? await this.rpc.resumeSession({ ...resumeInput, id }) - : await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); + const { sessionStartedProperties, ...resumeInput } = stripLegacyKaosOptions(input); + const summary = await this.rpc.resumeSession({ ...resumeInput, id }); const session = new Session({ id: summary.id, workDir: summary.workDir, @@ -671,13 +659,20 @@ export class KimiHarness { const DEFAULT_SESSION_STARTED_UI_MODE = 'shell'; function resumeCoalesceKey(id: string, input: ResumeSessionInput): string { - const { kaos, persistenceKaos, ...rest } = input; - return JSON.stringify({ - ...rest, - id, - kaos: kaos !== undefined, - persistenceKaos: persistenceKaos !== undefined, - }); + return JSON.stringify({ ...stripLegacyKaosOptions(input), id }); +} + +function stripLegacyKaosOptions(input: T): T { + // JavaScript hosts may still pass the removed kaos options; keep them out of + // forwarded RPC inputs and serialized keys — custom implementations can hold + // circular runtime handles. + const { kaos, persistenceKaos, ...rest } = input as T & { + kaos?: unknown; + persistenceKaos?: unknown; + }; + void kaos; + void persistenceKaos; + return rest as T; } function normalizeSessionId(value: string): string { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 278cbe594..ae5e70872 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -1,7 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { SwarmModeTrigger } from '@moonshot-ai/agent-core-v2/features/swarm/agent/swarm'; -import type { Kaos } from '@moonshot-ai/kaos'; import type { AgentContextData } from '#/context'; import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; @@ -183,28 +182,8 @@ export abstract class SDKRpcClientBase { abstract createSession(input: CreateSessionOptions): Promise; - async createSessionWithKaos( - input: CreateSessionOptions, - kaos: Kaos, - persistenceKaos?: Kaos, - ): Promise { - void kaos; - void persistenceKaos; - return this.createSession(input); - } - abstract resumeSession(input: ResumeSessionInput): Promise; - async resumeSessionWithKaos( - input: ResumeSessionInput, - kaos: Kaos, - persistenceKaos?: Kaos, - ): Promise { - void kaos; - void persistenceKaos; - return this.resumeSession(input); - } - abstract reloadSession(input: ReloadSessionRpcInput): Promise; abstract forkSession(input: ForkSessionInput): Promise; diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 64d76e119..bb1eaae3e 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -122,9 +122,6 @@ * the agent scope's `IAgentTowerService`; `getTowerStatus` → the shared * tower status summary reader (v2-only — the base class throws * `not_implemented`). - * `createSessionWithKaos` / `resumeSessionWithKaos` deliberately keep the - * base class's kaos-ignoring degradation (the v2 engine has no kaos - * injection point — see the session-lifecycle section header), and * `toolCall` keeps the base class's "not supported" answer, which the * interaction bridge already relies on. */ @@ -930,15 +927,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // metadata mutations of a LIVE session; everything that needs an explicit // session id, a resume, or a workspace command goes through the // `engineAccessor` escape hatch (named per method below). - // - // `createSessionWithKaos` / `resumeSessionWithKaos` are deliberately NOT - // overridden: agent-core-v2 has no kaos injection point (its fs/process - // abstraction is the engine-internal hostFs domain, resolved at bootstrap), - // so the base class's degradation — ignore the kaos arguments and run a - // plain local create/resume — is the honest behavior, the same one every - // daemon-transport client settles for. Failing loudly instead would break - // hosts that pass kaos opportunistically (the harness forwards it whenever - // the host supplies one). // ----------------------------------------------------------------------- private liveSession(sessionId: string): ISessionScopeHandle | undefined { diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 67e4edff5..f8d9fadd7 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -3,7 +3,6 @@ import type { ExportSessionManifest, ShellEnvironment, } from '@moonshot-ai/agent-core-v2/app/sessionExport/sessionExport'; -import type { Kaos } from '@moonshot-ai/kaos'; import type { KimiHostIdentity, OAuthRefreshOutcome } from '@moonshot-ai/kimi-code-oauth'; import type { ContentPart } from '@moonshot-ai/kosong'; @@ -240,8 +239,6 @@ export interface CreateSessionOptions { readonly permission?: PermissionMode | undefined; readonly planMode?: boolean; readonly metadata?: JsonObject | undefined; - readonly kaos?: Kaos | undefined; - readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; /** * Main-agent profile name (`--agent`): a builtin profile or one defined by @@ -279,8 +276,6 @@ export interface GenerateSessionTitleInput { export interface ResumeSessionInput { readonly id: string; - readonly kaos?: Kaos | undefined; - readonly persistenceKaos?: Kaos | undefined; readonly additionalDirs?: readonly string[]; /** Re-select the session's already-bound main profile; a different name fails. */ readonly agentProfile?: string; diff --git a/packages/node-sdk/tsconfig.dts.json b/packages/node-sdk/tsconfig.dts.json index 2887509c7..149ed1071 100644 --- a/packages/node-sdk/tsconfig.dts.json +++ b/packages/node-sdk/tsconfig.dts.json @@ -12,7 +12,6 @@ "include": [ "src/**/*.ts", "../agent-core-v2/src/**/*.ts", - "../kaos/src/**/*.ts", "../klient/src/**/*.ts", "../kosong/src/**/*.ts", "../oauth/src/**/*.ts", diff --git a/packages/node-sdk/tsdown.config.ts b/packages/node-sdk/tsdown.config.ts index 04753276d..e94bce77e 100644 --- a/packages/node-sdk/tsdown.config.ts +++ b/packages/node-sdk/tsdown.config.ts @@ -20,7 +20,6 @@ export default defineConfig({ ].join('\n'), }, alias: { - '@moonshot-ai/kaos': fileURLToPath(new URL('../kaos/src/index.ts', import.meta.url)), '@moonshot-ai/kimi-code-oauth/provider-credential': fileURLToPath( new URL('../oauth/src/provider-credential.ts', import.meta.url), ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72aa08c5c..83e7dae07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,6 @@ catalogs: overrides: kimi-code>@tailwindcss/vite: 4.1.18 - ssh2@1.17.0>cpu-features: '-' - ssh2@1.17.0>nan: '-' kimi-code>tailwindcss: 4.1.18 importers: @@ -632,19 +630,6 @@ importers: specifier: ^4.21.0 version: 4.21.0 - packages/kaos: - dependencies: - pathe: - specifier: ^2.0.3 - version: 2.0.3 - ssh2: - specifier: ^1.17.0 - version: 1.17.0 - devDependencies: - '@types/ssh2': - specifier: ^1.15.5 - version: 1.15.5 - packages/kap-server: dependencies: '@fastify/multipart': @@ -789,9 +774,6 @@ importers: '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../agent-core-v2 - '@moonshot-ai/kaos': - specifier: workspace:^ - version: link:../kaos '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../oauth @@ -4368,9 +4350,6 @@ packages: '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} @@ -4424,9 +4403,6 @@ packages: '@types/sinonjs__fake-timers@15.0.1': resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} - '@types/ssh2@1.15.5': - resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} - '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -4796,9 +4772,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4861,9 +4834,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - bcryptjs@2.4.3: resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} @@ -8364,10 +8334,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - ssh2@1.17.0: - resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} - engines: {node: '>=10.16.0'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -8722,9 +8688,6 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -8783,9 +8746,6 @@ packages: underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -12908,10 +12868,6 @@ snapshots: '@types/node@16.9.1': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@22.19.17': dependencies: undici-types: 6.21.0 @@ -12964,10 +12920,6 @@ snapshots: '@types/sinonjs__fake-timers@15.0.1': {} - '@types/ssh2@1.15.5': - dependencies: - '@types/node': 18.19.130 - '@types/statuses@2.0.6': {} '@types/tar@7.0.87': @@ -13417,10 +13369,6 @@ snapshots: array-union@2.1.0: {} - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -13475,10 +13423,6 @@ snapshots: baseline-browser-mapping@2.10.20: {} - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - bcryptjs@2.4.3: {} better-path-resolve@1.0.0: @@ -17623,11 +17567,6 @@ snapshots: sprintf-js@1.0.3: {} - ssh2@1.17.0: - dependencies: - asn1: 0.2.6 - bcrypt-pbkdf: 1.0.2 - stackback@0.0.2: {} standardwebhooks@1.0.0: @@ -17959,8 +17898,6 @@ snapshots: tw-animate-css@1.4.0: {} - tweetnacl@0.14.5: {} - type-detect@4.0.8: {} type-detect@4.1.0: {} @@ -18004,8 +17941,6 @@ snapshots: underscore@1.13.8: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici@7.27.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 46d24706f..05863c6b8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,4 @@ catalog: overrides: "kimi-code>@tailwindcss/vite": "4.1.18" - "ssh2@1.17.0>cpu-features": "-" - "ssh2@1.17.0>nan": "-" "kimi-code>tailwindcss": "4.1.18"