mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-10-19 09:13:24 +00:00
chore: remove unused kaos package and MCP kaos executor option (#4184)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Publish VS Code extension / Publish VSIX to marketplaces (push) Waiting to run
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Publish VS Code extension / Publish VSIX to marketplaces (push) Waiting to run
* chore: remove unused kaos package and MCP kaos executor option * chore: update nix pnpmDeps hash after kaos removal * chore: harden resume coalesce key against legacy kaos arguments and condense changesets * chore: strip legacy kaos options before forwarding session RPCs * chore: drop ssh2 overrides left unused by kaos removal
This commit is contained in:
parent
c15638b0a1
commit
cbd754049a
67 changed files with 41 additions and 9873 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
5
.changeset/remove-kaos.md
Normal file
5
.changeset/remove-kaos.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/remove-mcp-kaos-executor.md
Normal file
5
.changeset/remove-mcp-kaos-executor.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const sourceDirectories = [
|
|||
join(extensionRoot, 'src'),
|
||||
join(extensionRoot, 'shared'),
|
||||
...[
|
||||
'kaos',
|
||||
'kosong',
|
||||
'migration-legacy',
|
||||
'node-sdk',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Kaos>();
|
||||
|
||||
/**
|
||||
* 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<T>(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<string> {
|
||||
return getCurrentKaos().readText(path, options);
|
||||
}
|
||||
|
||||
export function writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<number> {
|
||||
return getCurrentKaos().writeText(path, data, options);
|
||||
}
|
||||
|
||||
export function readLines(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
|
||||
): AsyncGenerator<string> {
|
||||
return getCurrentKaos().readLines(path, options);
|
||||
}
|
||||
|
||||
export function exec(...args: string[]): Promise<KaosProcess> {
|
||||
return getCurrentKaos().exec(...args);
|
||||
}
|
||||
|
||||
export function readBytes(path: string, n?: number): Promise<Buffer> {
|
||||
return getCurrentKaos().readBytes(path, n);
|
||||
}
|
||||
|
||||
export function writeBytes(path: string, data: Buffer): Promise<number> {
|
||||
return getCurrentKaos().writeBytes(path, data);
|
||||
}
|
||||
|
||||
export function stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult> {
|
||||
return getCurrentKaos().stat(path, options);
|
||||
}
|
||||
|
||||
export function mkdir(
|
||||
path: string,
|
||||
options?: { parents?: boolean; existOk?: boolean },
|
||||
): Promise<void> {
|
||||
return getCurrentKaos().mkdir(path, options);
|
||||
}
|
||||
|
||||
export function iterdir(path: string): AsyncGenerator<string> {
|
||||
return getCurrentKaos().iterdir(path);
|
||||
}
|
||||
|
||||
export function glob(
|
||||
path: string,
|
||||
pattern: string,
|
||||
options?: { caseSensitive?: boolean },
|
||||
): AsyncGenerator<string> {
|
||||
return getCurrentKaos().glob(path, pattern, options);
|
||||
}
|
||||
|
||||
export function chdir(path: string): Promise<void> {
|
||||
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<string, string>): Promise<KaosProcess> {
|
||||
return getCurrentKaos().execWithEnv(args, env);
|
||||
}
|
||||
|
|
@ -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<string, string | undefined>;
|
||||
readonly isFile: (path: string) => Promise<boolean>;
|
||||
readonly execFileText: (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const GIT_EXEC_PATH_TIMEOUT_MS = 5_000;
|
||||
|
||||
const MINGW_PREFIX_SET: ReadonlySet<string> = 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<Environment> {
|
||||
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<string> {
|
||||
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<string | undefined> {
|
||||
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 `<root>\cmd\git.exe`,
|
||||
// with bash at `<root>\bin\bash.exe`. Portable installs sometimes put
|
||||
// both in `<root>\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<string>();
|
||||
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<Environment> | undefined;
|
||||
|
||||
export function detectEnvironmentFromNode(): Promise<Environment> {
|
||||
if (detectedEnvironment !== undefined) return detectedEnvironment;
|
||||
const platform = process.platform;
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const isFile = async (path: string): Promise<boolean> => {
|
||||
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<boolean>,
|
||||
): Promise<readonly string[]> {
|
||||
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<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
nodeExecFile(
|
||||
file,
|
||||
[...args],
|
||||
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
|
||||
(error, stdout) => {
|
||||
if (error !== null) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
/** 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<string, string>): Kaos;
|
||||
/** Return stat metadata for `path`. */
|
||||
stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult>;
|
||||
/** Yield entry names in the directory at `path`. */
|
||||
iterdir(path: string): AsyncGenerator<string>;
|
||||
/** Yield paths matching `pattern` under `path`. */
|
||||
glob(
|
||||
path: string,
|
||||
pattern: string,
|
||||
options?: { caseSensitive?: boolean },
|
||||
): AsyncGenerator<string>;
|
||||
|
||||
// ── File operations (async) ─────────────────────────────────────────
|
||||
|
||||
/** Read up to `n` bytes from `path` (all bytes if `n` is omitted). */
|
||||
readBytes(path: string, n?: number): Promise<Buffer>;
|
||||
/**
|
||||
* 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<string>;
|
||||
/** Yield lines from the file at `path` one by one. */
|
||||
readLines(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
|
||||
): AsyncGenerator<string>;
|
||||
/** Write raw bytes to `path`, returning the number of bytes written. */
|
||||
writeBytes(path: string, data: Buffer): Promise<number>;
|
||||
/** Write text to `path`, returning the number of characters written. */
|
||||
writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<number>;
|
||||
/** Create a directory at `path`. */
|
||||
mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise<void>;
|
||||
|
||||
// ── Process execution ───────────────────────────────────────────────
|
||||
|
||||
/** Spawn a process with the given arguments. */
|
||||
exec(...args: string[]): Promise<KaosProcess>;
|
||||
/** Spawn a process with explicit environment variables. */
|
||||
execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess>;
|
||||
}
|
||||
|
|
@ -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 `"<dev>: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<string, string> | 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<number>;
|
||||
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<number>((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<number> {
|
||||
return this._exitPromise;
|
||||
}
|
||||
|
||||
kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
// 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<void>((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<string, string>[];
|
||||
|
||||
private constructor(
|
||||
osEnv: Environment,
|
||||
cwd?: string,
|
||||
envLayers: readonly Record<string, string>[] = [],
|
||||
) {
|
||||
// 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<LocalKaos> {
|
||||
// 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<string, string>): 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<void> {
|
||||
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<StatResult> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>();
|
||||
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 `"<dev>: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<string>,
|
||||
): AsyncGenerator<string> {
|
||||
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<Buffer> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<TextFileScan> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<KaosProcess> {
|
||||
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<string, string>): Promise<KaosProcess> {
|
||||
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<string, string>): Record<string, string> | undefined {
|
||||
if (this._envLayers.length === 0) return invocationEnv;
|
||||
const merged: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...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<string> {
|
||||
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<ReturnType<typeof open>>,
|
||||
start: number,
|
||||
length: number,
|
||||
): Promise<Buffer> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, string | undefined>;
|
||||
/** 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<string | undefined>;
|
||||
}
|
||||
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
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<void> | undefined;
|
||||
|
||||
export function applyLoginShellPathFromNode(): Promise<void> {
|
||||
if (appliedLoginShellPath !== undefined) return appliedLoginShellPath;
|
||||
appliedLoginShellPath = applyLoginShellPath({
|
||||
platform: process.platform,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
userShell: userShellFromNode,
|
||||
execFileText,
|
||||
});
|
||||
return appliedLoginShellPath;
|
||||
}
|
||||
|
|
@ -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<number>;
|
||||
/** Send a signal to the process (defaults to `SIGTERM`). */
|
||||
kill(signal?: NodeJS.Signals): Promise<void>;
|
||||
/** Release stdin/stdout/stderr resources owned by this process wrapper. */
|
||||
dispose(): Promise<void> | void;
|
||||
}
|
||||
|
|
@ -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
|
||||
* `<git-root>/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<Environment, 'osKind' | 'shellName' | 'shellPath'>;
|
||||
|
||||
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<string, string>();
|
||||
|
||||
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<string, ShellPathBridge>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -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<number>;
|
||||
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<number>((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<number> {
|
||||
return this._exitPromise;
|
||||
}
|
||||
|
||||
kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
// 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<Client> {
|
||||
const client = new ssh2.Client();
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
client.on('ready', () => {
|
||||
resolve(client);
|
||||
});
|
||||
client.on('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
client.connect(config);
|
||||
});
|
||||
}
|
||||
|
||||
function getSftp(client: Client): Promise<SFTPWrapper> {
|
||||
return new Promise<SFTPWrapper>((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<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
sftp.realpath(path, (err, absPath) => {
|
||||
if (err) {
|
||||
reject(mapSftpError('realpath', err));
|
||||
} else {
|
||||
resolve(absPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sftpStat(sftp: SFTPWrapper, path: string): Promise<SFTPStats> {
|
||||
return new Promise<SFTPStats>((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) {
|
||||
reject(mapSftpError('stat', err));
|
||||
} else {
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sftpLstat(sftp: SFTPWrapper, path: string): Promise<SFTPStats> {
|
||||
return new Promise<SFTPStats>((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<SFTPFileEntry[]> {
|
||||
return new Promise<SFTPFileEntry[]>((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<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
sftp.mkdir(path, (err) => {
|
||||
if (err) {
|
||||
reject(mapSftpError('mkdir', err));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sftpExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
sftp.exists(path, (exists) => {
|
||||
resolve(exists);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sftpReadFile(sftp: SFTPWrapper, path: string): Promise<Buffer> {
|
||||
return new Promise<Buffer>((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<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
sftp.appendFile(path, data, (err) => {
|
||||
if (err) {
|
||||
reject(mapSftpError('appendFile', err));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clientExec(client: Client, command: string): Promise<ClientChannel> {
|
||||
return new Promise<ClientChannel>((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<string, string>[];
|
||||
|
||||
// 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<string, string>[] = [],
|
||||
) {
|
||||
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<string, string>): 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<SSHKaos> {
|
||||
// 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<void> {
|
||||
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<StatResult> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<Buffer> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// 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<number> {
|
||||
await sftpWriteFile(this._sftp, this._resolvePath(path), data);
|
||||
return data.length;
|
||||
}
|
||||
|
||||
async writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<KaosProcess> {
|
||||
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<string, string>): Promise<KaosProcess> {
|
||||
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<string, string>): Record<string, string> | undefined {
|
||||
if (this._envLayers.length === 0) return invocationEnv;
|
||||
const merged: Record<string, string> = { ...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 '<cwd>' && KEY1='v1' KEY2='v2' <cmd> <arg1> <arg2> ...`
|
||||
*
|
||||
* 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, string>,
|
||||
): 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<string, string>): Promise<KaosProcess> {
|
||||
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<void> {
|
||||
this._sftp.end();
|
||||
return new Promise<void>((resolve) => {
|
||||
this._client.once('close', () => {
|
||||
resolve();
|
||||
});
|
||||
this._client.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void>((resolve) => {
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
proc.stdout.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const stderrDone = new Promise<void>((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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> => {
|
||||
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<number>[] = [];
|
||||
|
||||
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<string[]> => {
|
||||
const entries: string[] = [];
|
||||
for await (const entry of kaos.iterdir(tempDir)) {
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
})();
|
||||
|
||||
const creationPromise = (async (): Promise<void> => {
|
||||
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<string[]>[] = [];
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
const results: string[] = [];
|
||||
for await (const entry of kaos.iterdir(path)) {
|
||||
results.push(entry);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
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<number> {
|
||||
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<void>((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:<bytes>".
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Buffer> {
|
||||
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<string, string>),
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, Buffer>;
|
||||
dirs: Set<string>;
|
||||
mkdirFailures: Map<string, { error: Error; materializeAs: 'dir' | 'file' }>;
|
||||
}
|
||||
|
||||
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<string, Buffer>(),
|
||||
dirs: new Set<string>(),
|
||||
mkdirFailures: new Map<string, { error: Error; materializeAs: 'dir' | 'file' }>(),
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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')]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string | undefined>;
|
||||
readonly existingPaths?: readonly string[];
|
||||
readonly execFileResults?: Readonly<Record<string, string>>;
|
||||
readonly execFileText?: Parameters<typeof detectEnvironment>[0]['execFileText'];
|
||||
}
|
||||
|
||||
/** Build a stub deps bag mimicking Node's `os` + `process` surface. */
|
||||
function stubDeps(opts: StubOpts): Parameters<typeof detectEnvironment>[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');
|
||||
});
|
||||
});
|
||||
31
packages/kaos/test/fixtures/killtree.cjs
vendored
31
packages/kaos/test/fixtures/killtree.cjs
vendored
|
|
@ -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);
|
||||
|
|
@ -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<Uint8Array | string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
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<Error>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string, string | undefined>;
|
||||
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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = { 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<Record<string, string>>;
|
||||
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<readonly [string, string]> = [
|
||||
['/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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof setTimeout> | 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<void>((resolve) => {
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
proc.stdout.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const stderrDone = new Promise<void>((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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>;
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, Array<(...args: unknown[]) => 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<void>((resolve) => {
|
||||
proc.stdout.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const firstChunk = await new Promise<Buffer>((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<void>((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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'kaos',
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"mainEntryPointFilePath": "<projectFolder>/.tmp-api-extractor/dts/node-sdk/src/index.d.ts",
|
||||
"bundledPackages": [
|
||||
"@moonshot-ai/kaos",
|
||||
"@moonshot-ai/kimi-code-oauth",
|
||||
"@moonshot-ai/kosong"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:^",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Session> {
|
||||
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<Session> {
|
||||
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<T extends object>(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 {
|
||||
|
|
|
|||
|
|
@ -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<SessionSummary>;
|
||||
|
||||
async createSessionWithKaos(
|
||||
input: CreateSessionOptions,
|
||||
kaos: Kaos,
|
||||
persistenceKaos?: Kaos,
|
||||
): Promise<SessionSummary> {
|
||||
void kaos;
|
||||
void persistenceKaos;
|
||||
return this.createSession(input);
|
||||
}
|
||||
|
||||
abstract resumeSession(input: ResumeSessionInput): Promise<ResumedSessionSummary>;
|
||||
|
||||
async resumeSessionWithKaos(
|
||||
input: ResumeSessionInput,
|
||||
kaos: Kaos,
|
||||
persistenceKaos?: Kaos,
|
||||
): Promise<ResumedSessionSummary> {
|
||||
void kaos;
|
||||
void persistenceKaos;
|
||||
return this.resumeSession(input);
|
||||
}
|
||||
|
||||
abstract reloadSession(input: ReloadSessionRpcInput): Promise<ResumedSessionSummary>;
|
||||
|
||||
abstract forkSession(input: ForkSessionInput): Promise<SessionSummary>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../agent-core-v2/src/**/*.ts",
|
||||
"../kaos/src/**/*.ts",
|
||||
"../klient/src/**/*.ts",
|
||||
"../kosong/src/**/*.ts",
|
||||
"../oauth/src/**/*.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),
|
||||
),
|
||||
|
|
|
|||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue