mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
refactor(kaos): move Environment into kaos, slim package API (#147)
This commit is contained in:
parent
a6d379b2ce
commit
36add70d57
51 changed files with 392 additions and 2329 deletions
|
|
@ -15,13 +15,15 @@ export * from './types';
|
|||
export { resolveThinkingEffort, type ThinkingEffort } from './thinking';
|
||||
|
||||
export class ConfigState {
|
||||
private _cwd: string = '';
|
||||
private _cwd: string;
|
||||
private _modelAlias: string | undefined;
|
||||
private _profileName: string | undefined;
|
||||
private _thinkingLevel: ThinkingEffort = 'off';
|
||||
private _systemPrompt: string = '';
|
||||
|
||||
constructor(protected readonly agent: Agent) {}
|
||||
constructor(protected readonly agent: Agent) {
|
||||
this._cwd = agent.runtime.kaos.getcwd();
|
||||
}
|
||||
|
||||
update(input: AgentConfigUpdateData): void {
|
||||
const changed = { ...input };
|
||||
|
|
@ -41,7 +43,10 @@ export class ConfigState {
|
|||
type: 'config_updated',
|
||||
config: changed,
|
||||
});
|
||||
if (changed.cwd !== undefined) this._cwd = changed.cwd;
|
||||
if (changed.cwd) {
|
||||
this._cwd = changed.cwd;
|
||||
void this.agent.runtime.kaos.chdir(changed.cwd);
|
||||
}
|
||||
if (Object.hasOwn(changed, 'modelAlias')) {
|
||||
this._modelAlias = changed.modelAlias ?? undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ import {
|
|||
import type { EnabledPluginSessionStart } from '#/plugin';
|
||||
|
||||
import type { McpConnectionManager } from '../mcp';
|
||||
import {
|
||||
resolveSystemPromptCwd,
|
||||
type PreparedSystemPromptContext,
|
||||
type ResolvedAgentProfile,
|
||||
} from '../profile';
|
||||
import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile';
|
||||
import type { ProviderManager } from '../providers/provider-manager';
|
||||
import { withProviderRequestAuth } from '../providers/request-auth';
|
||||
import type { RuntimeConfig } from '../runtime-types';
|
||||
|
|
@ -247,10 +243,9 @@ export class Agent {
|
|||
}
|
||||
|
||||
useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void {
|
||||
const cwd = context?.cwd ?? resolveSystemPromptCwd(this.runtime.kaos, this.config.cwd);
|
||||
const systemPrompt = profile.systemPrompt({
|
||||
osEnv: this.runtime.osEnv,
|
||||
cwd,
|
||||
osEnv: this.runtime.kaos.osEnv,
|
||||
cwd: this.config.cwd,
|
||||
skills: this.skills?.registry,
|
||||
cwdListing: context?.cwdListing,
|
||||
agentsMd: context?.agentsMd,
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export class PlanMode {
|
|||
private planFilePathFor(id: string): string {
|
||||
const plansDir =
|
||||
this.agent.homedir === undefined
|
||||
? join(this.agent.config.cwd || this.agent.runtime.kaos.getcwd(), 'plan')
|
||||
? join(this.agent.config.cwd, 'plan')
|
||||
: join(this.agent.homedir, 'plans');
|
||||
return join(plansDir, `${id}.md`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ export class ToolManager {
|
|||
|
||||
initializeBuiltinTools(): void {
|
||||
const {
|
||||
runtime: { kaos, osEnv, urlFetcher, webSearcher },
|
||||
runtime: { kaos, urlFetcher, webSearcher },
|
||||
config: { cwd, provider, modelCapabilities },
|
||||
background,
|
||||
} = this.agent;
|
||||
|
|
@ -363,7 +363,7 @@ export class ToolManager {
|
|||
new b.EditTool(kaos, workspace),
|
||||
new b.GrepTool(kaos, workspace),
|
||||
new b.GlobTool(kaos, workspace),
|
||||
new b.BashTool(kaos, cwd, osEnv, background, {
|
||||
new b.BashTool(kaos, cwd, kaos.osEnv, background, {
|
||||
allowBackground,
|
||||
}),
|
||||
(modelCapabilities.image_in || modelCapabilities.video_in) &&
|
||||
|
|
|
|||
|
|
@ -9,33 +9,20 @@ const AGENTS_MD_MAX_BYTES = 32 * 1024;
|
|||
const S_IFMT = 0o170000;
|
||||
const S_IFREG = 0o100000;
|
||||
|
||||
export type PreparedSystemPromptContext = Pick<
|
||||
SystemPromptContext,
|
||||
'cwd' | 'cwdListing' | 'agentsMd'
|
||||
>;
|
||||
|
||||
export function resolveSystemPromptCwd(kaos: Kaos, cwd: string): string {
|
||||
return cwd === '' ? kaos.getcwd() : cwd;
|
||||
}
|
||||
export type PreparedSystemPromptContext = Pick<SystemPromptContext, 'cwdListing' | 'agentsMd'>;
|
||||
|
||||
export async function prepareSystemPromptContext(
|
||||
kaos: Kaos,
|
||||
cwd: string,
|
||||
): Promise<PreparedSystemPromptContext> {
|
||||
const resolvedCwd = resolveSystemPromptCwd(kaos, cwd);
|
||||
const [cwdListing, agentsMd] = await Promise.all([
|
||||
listDirectory(kaos, resolvedCwd),
|
||||
loadAgentsMd(kaos, resolvedCwd),
|
||||
listDirectory(kaos),
|
||||
loadAgentsMd(kaos),
|
||||
]);
|
||||
|
||||
return {
|
||||
cwd: resolvedCwd,
|
||||
cwdListing,
|
||||
agentsMd,
|
||||
};
|
||||
return { cwdListing, agentsMd };
|
||||
}
|
||||
|
||||
export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise<string> {
|
||||
export async function loadAgentsMd(kaos: Kaos): Promise<string> {
|
||||
const workDir = kaos.getcwd();
|
||||
const projectRoot = await findProjectRoot(kaos, workDir);
|
||||
const dirs = dirsRootToLeaf(kaos, workDir, projectRoot);
|
||||
const discovered: AgentFile[] = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Environment } from '@moonshot-ai/kaos';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { SkillRegistry } from '../skill';
|
||||
import type { Environment } from '../utils/environment';
|
||||
|
||||
export const RawSubagentProfileSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { KaosShellNotFoundError, LocalKaos } from '@moonshot-ai/kaos';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { getRootLogger, log } from '#/logging/logger';
|
||||
import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url';
|
||||
import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url';
|
||||
import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search';
|
||||
import { detectEnvironmentFromNode } from '#/utils/environment';
|
||||
import { getCoreVersion } from '#/version';
|
||||
import type {
|
||||
ActivateSkillPayload,
|
||||
|
|
@ -184,13 +183,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
|
||||
// Session ctor attaches its own log sink. If anything in the setup-after-
|
||||
// ctor block throws, `session.close()` releases the sink (and mcp).
|
||||
const runtime = await this.resolveRuntime(config);
|
||||
const session = new Session({
|
||||
runtime: await this.resolveRuntime(config),
|
||||
runtime: { ...runtime, kaos: runtime.kaos.withCwd(workDir) },
|
||||
id,
|
||||
homedir: summary.sessionDir,
|
||||
kimiHomeDir: this.homeDir,
|
||||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
cwd: workDir,
|
||||
providerManager: this.providerManager,
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
|
|
@ -215,7 +214,6 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
};
|
||||
const mainAgent = await session.createMain();
|
||||
mainAgent.config.update({
|
||||
cwd: workDir,
|
||||
modelAlias: modelName,
|
||||
thinkingLevel,
|
||||
});
|
||||
|
|
@ -264,13 +262,13 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
await this.pluginsReady;
|
||||
const pluginSessionStarts = this.plugins.enabledSessionStarts();
|
||||
const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig);
|
||||
const runtime = await this.resolveRuntime(config);
|
||||
const session = new Session({
|
||||
runtime: await this.resolveRuntime(config),
|
||||
runtime: { ...runtime, kaos: runtime.kaos.withCwd(summary.workDir) },
|
||||
id: summary.id,
|
||||
homedir: summary.sessionDir,
|
||||
kimiHomeDir: this.homeDir,
|
||||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
cwd: summary.workDir,
|
||||
providerManager: this.providerManager,
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
|
|
@ -743,9 +741,15 @@ async function createRuntimeConfig(input: {
|
|||
const searchService = input.config.services?.moonshotSearch;
|
||||
const fetchService = input.config.services?.moonshotFetch;
|
||||
|
||||
const kaos = await LocalKaos.create().catch((error: unknown) => {
|
||||
if (error instanceof KaosShellNotFoundError) {
|
||||
throw new KimiError(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, error.message);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return {
|
||||
kaos: localKaos,
|
||||
osEnv: await detectEnvironmentFromNode(),
|
||||
kaos,
|
||||
urlFetcher:
|
||||
fetchService?.baseUrl === undefined
|
||||
? localFetcher
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import type { UrlFetcher, WebSearchProvider } from './tools/builtin';
|
||||
import type { Environment } from './utils/environment';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
readonly kaos: Kaos;
|
||||
readonly osEnv: Environment;
|
||||
readonly urlFetcher?: UrlFetcher | undefined;
|
||||
readonly webSearcher?: WebSearchProvider | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export interface SessionConfig {
|
|||
readonly homedir: string;
|
||||
readonly kimiHomeDir?: string;
|
||||
readonly rpc: SDKSessionRPC;
|
||||
readonly cwd?: string;
|
||||
readonly initializeMainAgent?: boolean | undefined;
|
||||
readonly providerManager?: ProviderManager | undefined;
|
||||
readonly background?: BackgroundConfig | undefined;
|
||||
|
|
@ -122,7 +121,7 @@ export class Session {
|
|||
(config.id === undefined ? log : log.createChild({ sessionId: config.id }));
|
||||
this.rpc = config.rpc;
|
||||
this.hookEngine = new HookEngine(config.hooks, {
|
||||
cwd: config.cwd,
|
||||
cwd: config.runtime.kaos.getcwd(),
|
||||
sessionId: config.id,
|
||||
});
|
||||
this.telemetry = config.telemetry ?? noopTelemetryClient;
|
||||
|
|
@ -222,8 +221,6 @@ export class Session {
|
|||
const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null);
|
||||
if (profile) {
|
||||
await this.bootstrapAgentProfile(agent, profile);
|
||||
} else if (this.config.cwd !== undefined) {
|
||||
agent.config.update({ cwd: this.config.cwd });
|
||||
}
|
||||
|
||||
this.agents.set(id, agent);
|
||||
|
|
@ -246,10 +243,7 @@ export class Session {
|
|||
agent: Agent,
|
||||
profile: ResolvedAgentProfile,
|
||||
): Promise<void> {
|
||||
if (this.config.cwd !== undefined) {
|
||||
agent.config.update({ cwd: this.config.cwd });
|
||||
}
|
||||
const context = await prepareSystemPromptContext(this.config.runtime.kaos, agent.config.cwd);
|
||||
const context = await prepareSystemPromptContext(agent.runtime.kaos);
|
||||
agent.useProfile(profile, context);
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +262,7 @@ export class Session {
|
|||
});
|
||||
await handle.completion;
|
||||
|
||||
const agentsMd = await loadAgentsMd(this.config.runtime.kaos, mainAgent.config.cwd);
|
||||
const agentsMd = await loadAgentsMd(mainAgent.runtime.kaos);
|
||||
mainAgent.context.appendSystemReminder(initCompletionReminder(agentsMd), {
|
||||
kind: 'injection',
|
||||
variant: 'init',
|
||||
|
|
@ -325,7 +319,7 @@ export class Session {
|
|||
const roots = await resolveSkillRoots({
|
||||
paths: {
|
||||
userHomeDir: this.config.skills?.userHomeDir ?? homedir(),
|
||||
workDir: this.config.cwd ?? process.cwd(),
|
||||
workDir: this.config.runtime.kaos.getcwd(),
|
||||
},
|
||||
explicitDirs: this.config.skills?.explicitDirs,
|
||||
extraDirs: this.config.skills?.extraDirs,
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export class SessionSubagentHost {
|
|||
thinkingLevel: parent.config.thinkingLevel,
|
||||
});
|
||||
|
||||
const context = await prepareSystemPromptContext(child.runtime.kaos, child.config.cwd);
|
||||
const context = await prepareSystemPromptContext(child.runtime.kaos);
|
||||
child.useProfile(profile, context);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,11 @@
|
|||
import type { Readable } from 'node:stream';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { Environment, Kaos, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuiltinTool } from '../../../agent/tool';
|
||||
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
|
||||
import type { Environment } from '../../../utils/environment';
|
||||
import { renderPrompt } from '../../../utils/render-prompt';
|
||||
import type { BackgroundProcessManager } from '../../background/manager';
|
||||
import { toInputJsonSchema } from '../../support/input-schema';
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ async function collectEntries(
|
|||
* tool error message. Returns `"(empty directory)"` if the directory is
|
||||
* empty, or an error marker line if the directory itself is unreadable.
|
||||
*/
|
||||
export async function listDirectory(kaos: Kaos, workDir: string): Promise<string> {
|
||||
export async function listDirectory(kaos: Kaos, workDir: string = kaos.getcwd()): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
const { entries, total, readable } = await collectEntries(
|
||||
kaos,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
|
|||
import { Readable, type Writable } from 'node:stream';
|
||||
|
||||
import { createControlledPromise } from '@antfu/utils';
|
||||
import { localKaos, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { ModelCapability, ProviderConfig } from '@moonshot-ai/kosong';
|
||||
import { expect, vi } from 'vitest';
|
||||
|
||||
|
|
@ -26,9 +26,10 @@ import type { QuestionResult, RPCCallOptions, SDKAgentRPC } from '../../../src/r
|
|||
import type { AgentAPI } from '../../../src/rpc/core-api';
|
||||
import type { RuntimeConfig } from '../../../src/runtime-types';
|
||||
import type { TelemetryClient } from '../../../src/telemetry';
|
||||
import type { Environment } from '../../../src/utils/environment';
|
||||
import type { PromisifyMethods } from '../../../src/utils/types';
|
||||
import { createFakeKaos } from '../../tools/fixtures/fake-kaos';
|
||||
import { testKaos } from '../../fixtures/test-kaos';
|
||||
|
||||
import { createScriptedGenerate } from './scripted-generate';
|
||||
import {
|
||||
DEFAULT_TEST_SYSTEM_PROMPT,
|
||||
|
|
@ -160,8 +161,7 @@ export class AgentTestContext {
|
|||
const providerManager = options.providerManager ?? new ProviderManager({ config: emptyConfig() });
|
||||
|
||||
const runtime = options.runtime ?? {
|
||||
kaos: options.kaos ?? localKaos,
|
||||
osEnv: TEST_OS_ENV,
|
||||
kaos: options.kaos ?? testKaos,
|
||||
};
|
||||
const persistence = this.wrapPersistence(
|
||||
options.persistence ?? new InMemoryAgentRecordPersistence(),
|
||||
|
|
@ -708,8 +708,7 @@ export class AgentTestContext {
|
|||
async expectResumeMatches(): Promise<void> {
|
||||
const resumed = testAgent({
|
||||
runtime: {
|
||||
kaos: createResumeNoSideEffectKaos(),
|
||||
osEnv: this.agent.runtime.osEnv,
|
||||
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd),
|
||||
urlFetcher: this.agent.runtime.urlFetcher,
|
||||
webSearcher: this.agent.runtime.webSearcher,
|
||||
},
|
||||
|
|
@ -928,18 +927,26 @@ const failOnResumeGenerate: GenerateFn = async () => {
|
|||
throw new Error('Resume replay unexpectedly called the LLM');
|
||||
};
|
||||
|
||||
function createResumeNoSideEffectKaos(): Kaos {
|
||||
function createResumeNoSideEffectKaos(initialCwd: string): Kaos {
|
||||
const fail = (method: string): never => {
|
||||
throw new Error(`Resume replay unexpectedly called kaos.${method}`);
|
||||
};
|
||||
|
||||
// Replay may carry `config.update({cwd})` events that route through
|
||||
// `kaos.chdir(...)`; let those mutate an internal cwd field so replay
|
||||
// succeeds. Actual fs I/O methods remain forbidden.
|
||||
let cwd = initialCwd;
|
||||
return {
|
||||
name: 'resume-no-side-effects',
|
||||
osEnv: TEST_OS_ENV,
|
||||
pathClass: () => 'posix',
|
||||
normpath: (p: string) => p,
|
||||
gethome: () => '/home/test',
|
||||
getcwd: () => '/workspace',
|
||||
chdir: () => fail('chdir'),
|
||||
getcwd: () => cwd,
|
||||
withCwd: (next: string) => createResumeNoSideEffectKaos(next),
|
||||
chdir: async (next: string) => {
|
||||
cwd = next;
|
||||
},
|
||||
stat: () => fail('stat'),
|
||||
iterdir: () => fail('iterdir'),
|
||||
glob: () => fail('glob'),
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'pathe';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Agent, type AgentRecord } from '../../src/agent';
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
|
||||
import type { AgentRecordPersistence } from '../../src/agent/records';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
|
|
@ -13,16 +13,8 @@ import type { ApprovalResponse, SDKAgentRPC, SDKSessionRPC } from '../../src/rpc
|
|||
import { Session } from '../../src/session';
|
||||
import { SkillRegistry, type SkillDefinition } from '../../src/skill';
|
||||
import { SkillTool } from '../../src/tools/builtin/collaboration/skill-tool';
|
||||
import type { Environment } from '../../src/utils/environment';
|
||||
import { executeTool } from '../tools/fixtures/execute-tool';
|
||||
|
||||
const TEST_OS_ENV: Environment = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
};
|
||||
|
||||
const MOCK_PROVIDER = {
|
||||
type: 'kimi',
|
||||
|
|
@ -54,8 +46,7 @@ function makeAgent(
|
|||
} as unknown as SDKAgentRPC;
|
||||
const agent = new Agent({
|
||||
runtime: {
|
||||
kaos: localKaos,
|
||||
osEnv: TEST_OS_ENV,
|
||||
kaos: testKaos,
|
||||
},
|
||||
rpc,
|
||||
skills,
|
||||
|
|
@ -71,10 +62,9 @@ function makeAgent(
|
|||
return agent;
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
function runtime(cwd?: string) {
|
||||
return {
|
||||
kaos: localKaos,
|
||||
osEnv: TEST_OS_ENV,
|
||||
kaos: cwd === undefined ? testKaos : testKaos.withCwd(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -199,15 +189,13 @@ describe('ToolManager SkillTool registration', () => {
|
|||
|
||||
const session = new Session({
|
||||
id: 'test-skill-tool',
|
||||
runtime: runtime(),
|
||||
runtime: runtime(workDir),
|
||||
homedir: homeDir,
|
||||
cwd: workDir,
|
||||
rpc: sessionRpc(),
|
||||
providerManager: testProviderManager(),
|
||||
});
|
||||
const mainAgent = await session.createMain();
|
||||
mainAgent.config.update({
|
||||
cwd: workDir,
|
||||
modelAlias: MOCK_PROVIDER.model,
|
||||
});
|
||||
mainAgent.tools.initializeBuiltinTools();
|
||||
|
|
|
|||
16
packages/agent-core/test/fixtures/test-kaos.ts
vendored
Normal file
16
packages/agent-core/test/fixtures/test-kaos.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { LocalKaos, type Environment } from '@moonshot-ai/kaos';
|
||||
|
||||
export const TEST_OS_ENV: Environment = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
};
|
||||
|
||||
// `LocalKaos`'s constructor is `private` at the TS level only — at runtime
|
||||
// it's just a function. Skip the singleton/async detection path and build a
|
||||
// fresh instance with a stub `osEnv` so test helpers can hand a real Kaos
|
||||
// directly to `RuntimeConfig`.
|
||||
type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
|
||||
export const testKaos: LocalKaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
|
||||
|
|
@ -4,7 +4,7 @@ import { dirname, join } from 'pathe';
|
|||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import type { ProviderConfig } from '@moonshot-ai/kosong';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -27,9 +27,9 @@ import { JsonFileStore, McpOAuthService } from '../../src/mcp/oauth';
|
|||
import type { AgentEvent, SDKSessionRPC } from '../../src/rpc';
|
||||
import { Session } from '../../src/session';
|
||||
import { SessionAPIImpl } from '../../src/session/rpc';
|
||||
import { detectEnvironmentFromNode } from '../../src/utils/environment';
|
||||
import { createScriptedGenerate } from '../agent/harness';
|
||||
|
||||
|
||||
const here = import.meta.dirname;
|
||||
const stdioFixture = join(here, 'fixtures', 'mock-stdio-server.mjs');
|
||||
const slowStdioFixture = join(here, 'fixtures', 'slow-stdio-server.mjs');
|
||||
|
|
@ -663,13 +663,9 @@ describe('Session MCP startup', () => {
|
|||
|
||||
const session = new Session({
|
||||
id: 'test-mcp-oauth',
|
||||
runtime: {
|
||||
kaos: localKaos,
|
||||
osEnv: await detectEnvironmentFromNode(),
|
||||
},
|
||||
runtime: { kaos: testKaos.withCwd(tmp) },
|
||||
homedir: join(tmp, 'session'),
|
||||
kimiHomeDir: kimiHome,
|
||||
cwd: tmp,
|
||||
rpc: sessionRpc(),
|
||||
});
|
||||
|
||||
|
|
@ -708,12 +704,8 @@ describe('Session MCP startup', () => {
|
|||
const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-startup-'));
|
||||
const session = new Session({
|
||||
id: 'test-mcp-slow',
|
||||
runtime: {
|
||||
kaos: localKaos,
|
||||
osEnv: await detectEnvironmentFromNode(),
|
||||
},
|
||||
runtime: { kaos: testKaos.withCwd(tmp) },
|
||||
homedir: join(tmp, 'session'),
|
||||
cwd: tmp,
|
||||
rpc: sessionRpc(),
|
||||
mcpConfig: {
|
||||
servers: {
|
||||
|
|
@ -752,12 +744,8 @@ describe('Session MCP startup', () => {
|
|||
scripted.mockNextResponse({ type: 'text', text: 'ready' });
|
||||
const session = new Session({
|
||||
id: 'test-mcp-turn-ended',
|
||||
runtime: {
|
||||
kaos: localKaos,
|
||||
osEnv: await detectEnvironmentFromNode(),
|
||||
},
|
||||
runtime: { kaos: testKaos.withCwd(tmp) },
|
||||
homedir: join(tmp, 'session'),
|
||||
cwd: tmp,
|
||||
rpc: sessionRpc({
|
||||
events,
|
||||
onEvent: (event) => {
|
||||
|
|
@ -823,12 +811,8 @@ describe('Session MCP startup', () => {
|
|||
const events: SessionRpcEvent[] = [];
|
||||
const session = new Session({
|
||||
id: 'test-mcp-mixed',
|
||||
runtime: {
|
||||
kaos: localKaos,
|
||||
osEnv: await detectEnvironmentFromNode(),
|
||||
},
|
||||
runtime: { kaos: testKaos.withCwd(tmp) },
|
||||
homedir: join(tmp, 'session'),
|
||||
cwd: tmp,
|
||||
rpc: sessionRpc({ events }),
|
||||
mcpConfig: {
|
||||
servers: {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'pathe';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadAgentsMd } from '../../src/profile/context';
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
|
||||
let homeDir: string;
|
||||
let workDir: string;
|
||||
|
|
@ -13,7 +13,8 @@ let workDir: string;
|
|||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
|
||||
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
|
||||
vi.spyOn(localKaos, 'gethome').mockReturnValue(homeDir);
|
||||
vi.spyOn(testKaos, 'gethome').mockReturnValue(homeDir);
|
||||
vi.spyOn(testKaos, 'getcwd').mockReturnValue(workDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -30,7 +31,7 @@ describe('loadAgentsMd user-level discovery', () => {
|
|||
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'user generic', 'utf-8');
|
||||
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8');
|
||||
|
||||
const result = await loadAgentsMd(localKaos, workDir);
|
||||
const result = await loadAgentsMd(testKaos);
|
||||
|
||||
expect(result).toContain('user branded');
|
||||
expect(result).toContain('user generic');
|
||||
|
|
@ -43,7 +44,7 @@ describe('loadAgentsMd user-level discovery', () => {
|
|||
await mkdir(join(homeDir, '.agents'), { recursive: true });
|
||||
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8');
|
||||
|
||||
const result = await loadAgentsMd(localKaos, workDir);
|
||||
const result = await loadAgentsMd(testKaos);
|
||||
|
||||
expect(result).toContain('dot-agents generic');
|
||||
});
|
||||
|
|
@ -51,17 +52,18 @@ describe('loadAgentsMd user-level discovery', () => {
|
|||
it('falls back to project-level only when no user-level files exist', async () => {
|
||||
await writeFile(join(workDir, 'AGENTS.md'), 'project only', 'utf-8');
|
||||
|
||||
const result = await loadAgentsMd(localKaos, workDir);
|
||||
const result = await loadAgentsMd(testKaos);
|
||||
|
||||
expect(result).toContain('project only');
|
||||
expect(result).not.toContain(homeDir);
|
||||
});
|
||||
|
||||
it('does not load the same file twice when the work dir is the home dir', async () => {
|
||||
vi.spyOn(testKaos, 'getcwd').mockReturnValue(homeDir);
|
||||
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
|
||||
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'home branded', 'utf-8');
|
||||
|
||||
const result = await loadAgentsMd(localKaos, homeDir);
|
||||
const result = await loadAgentsMd(testKaos);
|
||||
|
||||
expect(result.split('home branded').length - 1).toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { dirname, join } from 'pathe';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import type { ProviderConfig } from '@moonshot-ai/kosong';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -21,13 +21,6 @@ const MOCK_PROVIDER = {
|
|||
model: 'mock-model',
|
||||
} as const satisfies ProviderConfig;
|
||||
|
||||
const OS_ENV = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
} as const;
|
||||
|
||||
const here = import.meta.dirname;
|
||||
const mcpStdioFixture = join(here, '..', 'mcp', 'fixtures', 'mock-stdio-server.mjs');
|
||||
|
|
@ -51,9 +44,8 @@ describe('Session.init', () => {
|
|||
const scripted = createScriptedGenerate();
|
||||
const session = new Session({
|
||||
id: 'test-init',
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(events),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
providerManager: testProviderManager(),
|
||||
|
|
@ -128,9 +120,8 @@ describe('Session.init', () => {
|
|||
const sessionDir = await makeTempDir();
|
||||
const records: TelemetryRecord[] = [];
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc([]),
|
||||
providerManager: testProviderManager(),
|
||||
mcpConfig: {
|
||||
|
|
|
|||
|
|
@ -4,19 +4,14 @@ import { join } from 'pathe';
|
|||
import { Readable } from 'node:stream';
|
||||
import type { Writable } from 'node:stream';
|
||||
|
||||
import { localKaos, type KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SDKSessionRPC } from '../../src/rpc';
|
||||
import { Session } from '../../src/session';
|
||||
|
||||
const OS_ENV = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
} as const;
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
|
@ -31,10 +26,9 @@ describe('Session lifecycle hooks', () => {
|
|||
it('fires SessionStart on startup and SessionEnd on close', async () => {
|
||||
const { command, logPath, sessionDir, workDir } = await hookFixture();
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
id: 'session-123',
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
hooks: [
|
||||
|
|
@ -77,10 +71,9 @@ describe('Session lifecycle hooks', () => {
|
|||
'utf-8',
|
||||
);
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
id: 'session-456',
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
hooks: [{ event: 'SessionStart', matcher: 'resume', command, timeout: 5 }],
|
||||
|
|
@ -101,10 +94,9 @@ describe('Session lifecycle hooks', () => {
|
|||
it('does not let failing SessionStart or SessionEnd hook commands interrupt startup or close', async () => {
|
||||
const { sessionDir, workDir } = await hookFixture();
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
id: 'session-reject',
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
hooks: [
|
||||
|
|
@ -120,10 +112,9 @@ describe('Session lifecycle hooks', () => {
|
|||
it('stops background tasks on close when keepAliveOnExit is false', async () => {
|
||||
const { sessionDir, workDir } = await hookFixture();
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
id: 'session-bg-cleanup',
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
background: { keepAliveOnExit: false },
|
||||
|
|
@ -142,10 +133,9 @@ describe('Session lifecycle hooks', () => {
|
|||
vi.stubEnv('KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT', '0');
|
||||
const { sessionDir, workDir } = await hookFixture();
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
id: 'session-bg-env-cleanup',
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
background: { keepAliveOnExit: true },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'pathe';
|
||||
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import type { ToolCall } from '@moonshot-ai/kosong';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -24,13 +24,6 @@ vi.mock('../../src/session/git-context', () => ({
|
|||
}));
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const TEST_OS_ENV = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
} as const;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -743,9 +736,8 @@ describe('Session resume permission parent chain', () => {
|
|||
await writeWire(childDir, []);
|
||||
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: TEST_OS_ENV },
|
||||
runtime: { kaos: testKaos.withCwd(workDir) },
|
||||
homedir: sessionDir,
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
initializeMainAgent: false,
|
||||
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
||||
|
|
@ -797,13 +789,6 @@ describe('Session.createAgent', () => {
|
|||
id: 'test-subagent-remote-context',
|
||||
runtime: {
|
||||
kaos,
|
||||
osEnv: {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
},
|
||||
},
|
||||
homedir: '/tmp/kimi-session',
|
||||
rpc: createSessionRpc(),
|
||||
|
|
@ -869,18 +854,8 @@ describe('Session.createAgent', () => {
|
|||
});
|
||||
const session = new Session({
|
||||
id: 'test-subagent-agents-md',
|
||||
runtime: {
|
||||
kaos,
|
||||
osEnv: {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
},
|
||||
},
|
||||
runtime: { kaos: kaos.withCwd(workDir) },
|
||||
homedir: '/tmp/kimi-session',
|
||||
cwd: workDir,
|
||||
rpc: createSessionRpc(),
|
||||
initializeMainAgent: false,
|
||||
});
|
||||
|
|
@ -911,13 +886,6 @@ describe('Session.createAgent', () => {
|
|||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeText: vi.fn().mockResolvedValue(0),
|
||||
}),
|
||||
osEnv: {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
},
|
||||
},
|
||||
homedir: '/tmp/kimi-session',
|
||||
rpc: createSessionRpc(),
|
||||
|
|
@ -946,7 +914,6 @@ describe('Session.createAgent', () => {
|
|||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeText: vi.fn().mockResolvedValue(0),
|
||||
}),
|
||||
osEnv: TEST_OS_ENV,
|
||||
},
|
||||
homedir: '/tmp/kimi-session',
|
||||
rpc: createSessionRpc(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Readable, type Writable } from 'node:stream';
|
||||
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BashTool } from '../../src/tools/builtin/shell/bash';
|
||||
import type { Environment } from '../../src/utils/environment';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
import { createFakeKaos } from './fixtures/fake-kaos';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { PassThrough, Readable, type Writable } from 'node:stream';
|
||||
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BackgroundProcessManager } from '../../src/tools/background/manager';
|
||||
import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash';
|
||||
import type { Environment } from '../../src/utils/environment';
|
||||
import { createFakeKaos } from './fixtures/fake-kaos';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
* their own `WorkspaceConfig` with narrower bounds.
|
||||
*/
|
||||
|
||||
import type { Environment, Kaos } from '@moonshot-ai/kaos';
|
||||
import type { ExecutableToolResult } from '#/loop';
|
||||
import type { Kaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import type { WorkspaceConfig } from '../../../src/tools/support/workspace';
|
||||
|
||||
|
|
@ -20,14 +20,30 @@ function notImplemented(method: string): never {
|
|||
throw new Error(`FakeKaos.${method} not implemented — override in test`);
|
||||
}
|
||||
|
||||
export const FAKE_OS_ENV: Environment = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
};
|
||||
|
||||
export function createFakeKaos(overrides?: Partial<Kaos>): Kaos {
|
||||
// Hold cwd in a closure so `chdir` (which `config.update({cwd})` now
|
||||
// routes through) can mutate it and later `getcwd()` calls see the
|
||||
// update — mirroring real-kaos semantics without needing a backing fs.
|
||||
let cwd = overrides?.getcwd?.() ?? '/workspace';
|
||||
const base: Kaos = {
|
||||
name: 'fake',
|
||||
osEnv: FAKE_OS_ENV,
|
||||
pathClass: () => 'posix',
|
||||
normpath: (p: string) => p,
|
||||
gethome: () => '/home/test',
|
||||
getcwd: () => '/workspace',
|
||||
chdir: () => notImplemented('chdir'),
|
||||
getcwd: () => cwd,
|
||||
withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }),
|
||||
chdir: async (next: string) => {
|
||||
cwd = next;
|
||||
},
|
||||
stat: () => notImplemented('stat'),
|
||||
iterdir: () => notImplemented('iterdir'),
|
||||
glob: () => notImplemented('glob'),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Readable, type Writable } from 'node:stream';
|
||||
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BashTool } from '../../src/tools/builtin/shell/bash';
|
||||
import type { Environment } from '../../src/utils/environment';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
import { createFakeKaos } from './fixtures/fake-kaos';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Readable, type Writable } from 'node:stream';
|
||||
|
||||
import type { KaosProcess } from '@moonshot-ai/kaos';
|
||||
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash';
|
||||
import type { Environment } from '../../src/utils/environment';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
import { createFakeKaos } from './fixtures/fake-kaos';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +1,127 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import { KaosError } from './errors';
|
||||
import type { Kaos } from './kaos';
|
||||
import { localKaos } from './local';
|
||||
import type { KaosProcess } from './process';
|
||||
import type { StatResult } from './types';
|
||||
|
||||
const kaosStorage = new AsyncLocalStorage<Kaos>();
|
||||
|
||||
function getDefaultKaos(): Kaos {
|
||||
return localKaos;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the {@link Kaos} instance for the current async context.
|
||||
*
|
||||
* If {@link runWithKaos} has bound an instance for this context it is
|
||||
* returned; otherwise a lazily-created {@link LocalKaos} default is used.
|
||||
* 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 getCurrentKaos(): Kaos {
|
||||
return kaosStorage.getStore() ?? getDefaultKaos();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token returned by setCurrentKaos, used to restore the previous instance.
|
||||
* Mirrors Python's ContextVar Token pattern.
|
||||
*/
|
||||
export interface KaosToken {
|
||||
readonly previousKaos: Kaos | null;
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current kaos instance and return a token for restoring the previous one.
|
||||
*
|
||||
* Unlike a plain module-level global, this binds the override to the current
|
||||
* async context so concurrent tasks do not pollute each other. The returned
|
||||
* token can later be passed to {@link resetCurrentKaos} to restore the
|
||||
* previously-visible instance, mirroring Python's ContextVar token pattern.
|
||||
*/
|
||||
export function setCurrentKaos(kaos: Kaos): KaosToken {
|
||||
const token: KaosToken = { previousKaos: getCurrentKaos() };
|
||||
kaosStorage.enterWith(kaos);
|
||||
return token;
|
||||
export function writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<number> {
|
||||
return getCurrentKaos().writeText(path, data, options);
|
||||
}
|
||||
|
||||
export function resetCurrentKaos(token: KaosToken): void {
|
||||
kaosStorage.enterWith(token.previousKaos ?? getDefaultKaos());
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,15 @@
|
|||
*
|
||||
* On Windows the probe expects Git Bash (the canonical POSIX shell that
|
||||
* ships with Git for Windows). If it cannot be located the function
|
||||
* throws `KimiError` with code `shell.git_bash_not_found`; the SDK layer
|
||||
* can wrap that into a user-facing install hint. Set `KIMI_SHELL_PATH`
|
||||
* to override.
|
||||
* throws `KaosShellNotFoundError`; the SDK layer can wrap that into a
|
||||
* user-facing install hint. Set `KIMI_SHELL_PATH` to override.
|
||||
*/
|
||||
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
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.
|
||||
|
|
@ -118,8 +117,7 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
throw new KimiError(
|
||||
ErrorCodes.SHELL_GIT_BASH_NOT_FOUND,
|
||||
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(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -143,8 +141,18 @@ function inferGitBashFromGitExe(gitExe: string): string | undefined {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function detectEnvironmentFromNode(): Promise<Environment> {
|
||||
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> => {
|
||||
|
|
@ -155,7 +163,7 @@ export async function detectEnvironmentFromNode(): Promise<Environment> {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
return detectEnvironment({
|
||||
detectedEnvironment = detectEnvironment({
|
||||
platform,
|
||||
arch: process.arch,
|
||||
release: nodeOs.release(),
|
||||
|
|
@ -163,6 +171,7 @@ export async function detectEnvironmentFromNode(): Promise<Environment> {
|
|||
isFile,
|
||||
findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile),
|
||||
});
|
||||
return detectedEnvironment;
|
||||
}
|
||||
|
||||
async function findExecutableOnPath(
|
||||
|
|
@ -27,3 +27,15 @@ export class KaosFileExistsError extends KaosError {
|
|||
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,96 +1,38 @@
|
|||
import { getCurrentKaos } from './current';
|
||||
import type { KaosProcess } from './process';
|
||||
import type { StatResult } from './types';
|
||||
|
||||
export type { StatResult } from './types';
|
||||
export type { KaosProcess } from './process';
|
||||
export type { Kaos } from './kaos';
|
||||
export type { KaosToken } from './current';
|
||||
export { KaosError, KaosValueError, KaosFileExistsError } from './errors';
|
||||
export { KaosPath } from './path';
|
||||
export { LocalKaos, localKaos } from './local';
|
||||
export { setCurrentKaos, resetCurrentKaos, runWithKaos } from './current';
|
||||
export { getCurrentKaos };
|
||||
|
||||
// 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);
|
||||
}
|
||||
export type {
|
||||
Environment,
|
||||
EnvironmentDeps,
|
||||
OsKind,
|
||||
ShellName,
|
||||
} from './environment';
|
||||
export { detectEnvironment, detectEnvironmentFromNode } from './environment';
|
||||
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,3 +1,4 @@
|
|||
import type { Environment } from './environment';
|
||||
import type { KaosProcess } from './process';
|
||||
import type { StatResult } from './types';
|
||||
|
||||
|
|
@ -12,6 +13,13 @@ 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. */
|
||||
|
|
@ -27,6 +35,8 @@ export interface Kaos {
|
|||
|
||||
/** Change the working directory to `path`. */
|
||||
chdir(path: string): Promise<void>;
|
||||
/** Return a new Kaos with the given `cwd`. */
|
||||
withCwd(cwd: string): Kaos;
|
||||
/** Return stat metadata for `path`. */
|
||||
stat(path: string, options?: { followSymlinks?: boolean }): Promise<StatResult>;
|
||||
/** Yield entry names in the directory at `path`. */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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';
|
||||
|
|
@ -146,13 +147,32 @@ class LocalProcess implements KaosProcess {
|
|||
*/
|
||||
export class LocalKaos implements Kaos {
|
||||
readonly name: string = 'local';
|
||||
readonly osEnv: Environment;
|
||||
private _cwd: string;
|
||||
|
||||
constructor() {
|
||||
// Snapshot the process cwd at construction time. After this point we
|
||||
// never touch process.cwd() / process.chdir() — all path resolution
|
||||
// goes through this._cwd.
|
||||
this._cwd = normalize(process.cwd());
|
||||
private constructor(osEnv: Environment, cwd?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const osEnv = await detectEnvironmentFromNode();
|
||||
return new LocalKaos(osEnv);
|
||||
}
|
||||
|
||||
withCwd(cwd: string): LocalKaos {
|
||||
return new LocalKaos(this.osEnv, cwd);
|
||||
}
|
||||
|
||||
private _resolvePath(path: string): string {
|
||||
|
|
@ -555,6 +575,3 @@ function waitForSpawn(child: ChildProcess): Promise<void> {
|
|||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
/** The default local KAOS instance. */
|
||||
export const localKaos: LocalKaos = new LocalKaos();
|
||||
|
|
|
|||
|
|
@ -1,438 +0,0 @@
|
|||
import * as posixPath from 'node:path/posix';
|
||||
import * as win32Path from 'node:path/win32';
|
||||
|
||||
import { getCurrentKaos } from './current';
|
||||
import { KaosValueError } from './errors';
|
||||
import type { Kaos } from './kaos';
|
||||
import type { StatResult } from './types';
|
||||
|
||||
type PathClass = 'posix' | 'win32';
|
||||
type PathModule = typeof posixPath;
|
||||
|
||||
// S_IFMT mask and S_IFDIR/S_IFREG constants for mode checking
|
||||
const S_IFMT = 0o170000;
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFREG = 0o100000;
|
||||
|
||||
/**
|
||||
* Return the path module matching the current Kaos path class.
|
||||
*/
|
||||
function getPathMod(pathClass: PathClass = getCurrentKaos().pathClass()): PathModule {
|
||||
return pathClass === 'win32' ? win32Path : posixPath;
|
||||
}
|
||||
|
||||
function splitPathLexically(pathMod: PathModule, path: string): { root: string; parts: string[] } {
|
||||
const root = pathMod.parse(path).root;
|
||||
const tail = root.length > 0 ? path.slice(root.length) : path;
|
||||
return {
|
||||
root,
|
||||
parts: tail.split('/').filter((part) => part.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function splitPosixPart(path: string): { root: string; parts: string[] } {
|
||||
const normalized = path.replaceAll('\\', '/');
|
||||
const root =
|
||||
normalized.startsWith('//') && !normalized.startsWith('///') ? '//' : normalized.startsWith('/') ? '/' : '';
|
||||
const tail = root.length > 0 ? normalized.slice(root.length) : normalized;
|
||||
return {
|
||||
root,
|
||||
parts: tail.split('/').filter((part) => part.length > 0 && part !== '.'),
|
||||
};
|
||||
}
|
||||
|
||||
function joinPosixPure(parts: string[]): string {
|
||||
let root = '';
|
||||
let pathParts: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '') continue;
|
||||
const parsed = splitPosixPart(part);
|
||||
if (parsed.root !== '') {
|
||||
root = parsed.root;
|
||||
pathParts = parsed.parts;
|
||||
} else {
|
||||
pathParts.push(...parsed.parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (root !== '') {
|
||||
return pathParts.length === 0 ? root : root + pathParts.join('/');
|
||||
}
|
||||
return pathParts.length === 0 ? '.' : pathParts.join('/');
|
||||
}
|
||||
|
||||
interface Win32Part {
|
||||
drive: string;
|
||||
root: '' | '\\';
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
function splitWin32Part(path: string): Win32Part {
|
||||
const normalized = path.replaceAll('/', '\\');
|
||||
const parsed = win32Path.parse(normalized);
|
||||
let drive = '';
|
||||
let root: '' | '\\' = '';
|
||||
|
||||
if (/^[A-Za-z]:/.test(parsed.root)) {
|
||||
drive = parsed.root.slice(0, 2);
|
||||
root = parsed.root.length > 2 ? '\\' : '';
|
||||
} else if (parsed.root.startsWith('\\\\')) {
|
||||
// UNC roots are already complete anchors, e.g. "\\server\\share\\".
|
||||
drive = parsed.root.endsWith('\\') ? parsed.root.slice(0, -1) : parsed.root;
|
||||
root = '\\';
|
||||
} else if (parsed.root === '\\' || parsed.root === '/') {
|
||||
root = '\\';
|
||||
}
|
||||
|
||||
const tail = normalized.slice(parsed.root.length);
|
||||
return {
|
||||
drive,
|
||||
root,
|
||||
parts: tail.split('\\').filter((part) => part.length > 0 && part !== '.'),
|
||||
};
|
||||
}
|
||||
|
||||
function formatWin32Pure(drive: string, root: '' | '\\', parts: string[]): string {
|
||||
const anchor = drive + root;
|
||||
if (anchor !== '') {
|
||||
return parts.length === 0 ? anchor : anchor + parts.join('\\');
|
||||
}
|
||||
return parts.length === 0 ? '.' : parts.join('\\');
|
||||
}
|
||||
|
||||
function joinWin32Pure(parts: string[]): string {
|
||||
let drive = '';
|
||||
let root: '' | '\\' = '';
|
||||
let pathParts: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '') continue;
|
||||
const parsed = splitWin32Part(part);
|
||||
|
||||
if (parsed.root !== '') {
|
||||
drive = parsed.drive !== '' ? parsed.drive : drive;
|
||||
root = parsed.root;
|
||||
pathParts = parsed.parts;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.drive !== '') {
|
||||
if (drive.toLowerCase() !== parsed.drive.toLowerCase()) {
|
||||
drive = parsed.drive;
|
||||
root = '';
|
||||
pathParts = parsed.parts;
|
||||
} else {
|
||||
pathParts.push(...parsed.parts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
pathParts.push(...parsed.parts);
|
||||
}
|
||||
|
||||
return formatWin32Pure(drive, root, pathParts);
|
||||
}
|
||||
|
||||
function joinPure(pathClass: PathClass, parts: string[]): string {
|
||||
return pathClass === 'win32' ? joinWin32Pure(parts) : joinPosixPure(parts);
|
||||
}
|
||||
|
||||
function isWin32DriveRelative(path: string): boolean {
|
||||
return /^[A-Za-z]:(?:$|[^\\/])/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* A path wrapper class that delegates all I/O operations to the current Kaos instance.
|
||||
* The path string is interpreted with the path class active at construction time.
|
||||
*/
|
||||
export class KaosPath {
|
||||
private _path: string;
|
||||
private _pathClass: PathClass;
|
||||
|
||||
constructor(...args: string[]) {
|
||||
this._pathClass = getCurrentKaos().pathClass();
|
||||
if (args.length === 0) {
|
||||
this._path = '.';
|
||||
} else {
|
||||
const raw = joinPure(this._pathClass, args);
|
||||
this._path = this._pathClass === 'win32' ? raw.replaceAll('\\', '/') : raw;
|
||||
}
|
||||
}
|
||||
|
||||
private static _from(path: string, pathClass: PathClass): KaosPath {
|
||||
const ret = new KaosPath();
|
||||
ret._path = path.replaceAll('\\', '/');
|
||||
ret._pathClass = pathClass;
|
||||
return ret;
|
||||
}
|
||||
|
||||
private _currentKaos(operation: string): Kaos {
|
||||
const kaos = getCurrentKaos();
|
||||
const currentPathClass = kaos.pathClass();
|
||||
if (currentPathClass !== this._pathClass) {
|
||||
throw new KaosValueError(
|
||||
`Cannot ${operation} ${this._pathClass} path ${this._path} with ${currentPathClass} kaos`,
|
||||
);
|
||||
}
|
||||
if (this._pathClass === 'win32' && isWin32DriveRelative(this._path)) {
|
||||
throw new KaosValueError(
|
||||
`Cannot ${operation} drive-relative win32 path ${this._path}; use an absolute path like C:\\\\path or a path relative to cwd`,
|
||||
);
|
||||
}
|
||||
return kaos;
|
||||
}
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
/** The final component of this path (like Python's Path.name). */
|
||||
get name(): string {
|
||||
return getPathMod(this._pathClass).basename(this._path);
|
||||
}
|
||||
|
||||
/** The logical parent of this path (like Python's Path.parent). */
|
||||
get parent(): KaosPath {
|
||||
const dir = getPathMod(this._pathClass).dirname(this._path);
|
||||
return KaosPath._from(dir, this._pathClass);
|
||||
}
|
||||
|
||||
// --- Path operations (sync, no I/O) ---
|
||||
|
||||
isAbsolute(): boolean {
|
||||
return getPathMod(this._pathClass).isAbsolute(this._path);
|
||||
}
|
||||
|
||||
joinpath(...other: string[]): KaosPath {
|
||||
return KaosPath._from(joinPure(this._pathClass, [this._path, ...other]), this._pathClass);
|
||||
}
|
||||
|
||||
/** Division operator equivalent: join with another path segment. */
|
||||
div(other: string | KaosPath): KaosPath {
|
||||
if (other instanceof KaosPath && other._pathClass !== this._pathClass) {
|
||||
throw new KaosValueError(`Cannot join ${other._pathClass} path to ${this._pathClass} path`);
|
||||
}
|
||||
const otherStr = other instanceof KaosPath ? other.toString() : other;
|
||||
return this.joinpath(otherStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize the path without touching the filesystem.
|
||||
* Makes the path absolute (relative to cwd) and resolves '..' segments.
|
||||
*/
|
||||
canonical(): KaosPath {
|
||||
const kaos = this._currentKaos('canonicalize');
|
||||
const pathMod = getPathMod(this._pathClass);
|
||||
|
||||
if (pathMod.isAbsolute(this._path)) {
|
||||
return KaosPath._from(pathMod.normalize(this._path), this._pathClass);
|
||||
}
|
||||
const cwd = kaos.getcwd();
|
||||
if (!pathMod.isAbsolute(cwd)) {
|
||||
throw new KaosValueError(`Cannot canonicalize ${this._path} against non-absolute cwd ${cwd}`);
|
||||
}
|
||||
const abs = pathMod.resolve(cwd, this._path);
|
||||
return KaosPath._from(pathMod.normalize(abs), this._pathClass);
|
||||
}
|
||||
|
||||
/** Compute a relative path from `other` to this path. */
|
||||
relativeTo(other: KaosPath): KaosPath {
|
||||
if (other._pathClass !== this._pathClass) {
|
||||
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
|
||||
}
|
||||
const pathMod = getPathMod(this._pathClass);
|
||||
const target = splitPathLexically(pathMod, this._path);
|
||||
const base = splitPathLexically(pathMod, other.toString());
|
||||
|
||||
const sameRoot =
|
||||
this._pathClass === 'win32'
|
||||
? target.root.toLowerCase() === base.root.toLowerCase()
|
||||
: target.root === base.root;
|
||||
|
||||
if (!sameRoot) {
|
||||
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
|
||||
}
|
||||
if (base.parts.length > target.parts.length) {
|
||||
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
|
||||
}
|
||||
for (let i = 0; i < base.parts.length; i++) {
|
||||
const targetPart = target.parts[i];
|
||||
const basePart = base.parts[i];
|
||||
const samePart =
|
||||
this._pathClass === 'win32'
|
||||
? targetPart?.toLowerCase() === basePart?.toLowerCase()
|
||||
: targetPart === basePart;
|
||||
if (!samePart) {
|
||||
throw new KaosValueError(`${this._path} is not within ${other.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const relParts = target.parts.slice(base.parts.length);
|
||||
return KaosPath._from(
|
||||
relParts.length === 0 ? '.' : relParts.join('/'),
|
||||
this._pathClass,
|
||||
);
|
||||
}
|
||||
|
||||
/** Expand leading ~ to the home directory. */
|
||||
expanduser(): KaosPath {
|
||||
if (this._path === '~' || this._path.startsWith('~/') || this._path.startsWith('~\\')) {
|
||||
const kaos = this._currentKaos('expand');
|
||||
const home = kaos.gethome();
|
||||
if (this._path === '~') {
|
||||
return KaosPath._from(home, this._pathClass);
|
||||
}
|
||||
const rest = this._path.slice(2); // strip "~/" or "~\"
|
||||
return KaosPath._from(joinPure(this._pathClass, [home, rest]), this._pathClass);
|
||||
}
|
||||
return KaosPath._from(this._path, this._pathClass);
|
||||
}
|
||||
|
||||
// --- Static methods ---
|
||||
|
||||
static home(): KaosPath {
|
||||
const kaos = getCurrentKaos();
|
||||
return new KaosPath(kaos.gethome());
|
||||
}
|
||||
|
||||
static cwd(): KaosPath {
|
||||
const kaos = getCurrentKaos();
|
||||
return new KaosPath(kaos.getcwd());
|
||||
}
|
||||
|
||||
// --- Conversion ---
|
||||
|
||||
/** Create a KaosPath from a local filesystem path string. */
|
||||
static fromLocalPath(localPath: string): KaosPath {
|
||||
return new KaosPath(localPath);
|
||||
}
|
||||
|
||||
/** Return the underlying path string for local filesystem use. */
|
||||
toLocalPath(): string {
|
||||
if (this._pathClass === 'win32') {
|
||||
return this._path.replaceAll('/', '\\');
|
||||
}
|
||||
return this._path;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
equals(other: KaosPath): boolean {
|
||||
return this._pathClass === other._pathClass && this._path === other.toString();
|
||||
}
|
||||
|
||||
// --- File operations (async, delegate to getCurrentKaos) ---
|
||||
|
||||
/** Get stat information for this path. */
|
||||
async stat(options?: { followSymlinks?: boolean }): Promise<StatResult> {
|
||||
const kaos = this._currentKaos('stat');
|
||||
return kaos.stat(this._path, options);
|
||||
}
|
||||
|
||||
/** Check if this path exists on the filesystem. */
|
||||
async exists(options?: { followSymlinks?: boolean }): Promise<boolean> {
|
||||
const kaos = this._currentKaos('check');
|
||||
try {
|
||||
await kaos.stat(this._path, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if this path points to a regular file. */
|
||||
async isFile(options?: { followSymlinks?: boolean }): Promise<boolean> {
|
||||
const kaos = this._currentKaos('check');
|
||||
try {
|
||||
const s = await kaos.stat(this._path, options);
|
||||
return (s.stMode & S_IFMT) === S_IFREG;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if this path points to a directory. */
|
||||
async isDir(options?: { followSymlinks?: boolean }): Promise<boolean> {
|
||||
const kaos = this._currentKaos('check');
|
||||
try {
|
||||
const s = await kaos.stat(this._path, options);
|
||||
return (s.stMode & S_IFMT) === S_IFDIR;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate over entries in this directory. */
|
||||
async *iterdir(): AsyncGenerator<KaosPath> {
|
||||
const kaos = this._currentKaos('iterate');
|
||||
for await (const entry of kaos.iterdir(this._path)) {
|
||||
yield KaosPath._from(entry, this._pathClass);
|
||||
}
|
||||
}
|
||||
|
||||
/** Glob for entries matching a pattern under this path. */
|
||||
async *glob(pattern: string, options?: { caseSensitive?: boolean }): AsyncGenerator<KaosPath> {
|
||||
const kaos = this._currentKaos('glob');
|
||||
for await (const match of kaos.glob(this._path, pattern, options)) {
|
||||
yield KaosPath._from(match, this._pathClass);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the file content as a Buffer. */
|
||||
async readBytes(n?: number): Promise<Buffer> {
|
||||
const kaos = this._currentKaos('read');
|
||||
return kaos.readBytes(this._path, n);
|
||||
}
|
||||
|
||||
/** Read the file content as a string. */
|
||||
async readText(options?: {
|
||||
encoding?: BufferEncoding;
|
||||
errors?: 'strict' | 'replace' | 'ignore';
|
||||
}): Promise<string> {
|
||||
const kaos = this._currentKaos('read');
|
||||
return kaos.readText(this._path, options);
|
||||
}
|
||||
|
||||
/** Yield lines from the file one by one. */
|
||||
async *readLines(options?: {
|
||||
encoding?: BufferEncoding;
|
||||
errors?: 'strict' | 'replace' | 'ignore';
|
||||
}): AsyncGenerator<string> {
|
||||
const kaos = this._currentKaos('read');
|
||||
for await (const line of kaos.readLines(this._path, options)) {
|
||||
yield line;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write binary data to this path, return the number of bytes written. */
|
||||
async writeBytes(data: Buffer): Promise<number> {
|
||||
const kaos = this._currentKaos('write');
|
||||
return kaos.writeBytes(this._path, data);
|
||||
}
|
||||
|
||||
/** Write text to this path, return the number of characters written. */
|
||||
async writeText(
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<number> {
|
||||
const kaos = this._currentKaos('write');
|
||||
return kaos.writeText(this._path, data, options);
|
||||
}
|
||||
|
||||
/** Append text to this path, return the number of characters written. */
|
||||
async appendText(data: string, options?: { encoding?: BufferEncoding }): Promise<number> {
|
||||
const kaos = this._currentKaos('append');
|
||||
const writeOpts: { mode: 'a'; encoding?: BufferEncoding } = { mode: 'a' };
|
||||
if (options?.encoding !== undefined) {
|
||||
writeOpts.encoding = options.encoding;
|
||||
}
|
||||
return kaos.writeText(this._path, data, writeOpts);
|
||||
}
|
||||
|
||||
/** Create this path as a directory. */
|
||||
async mkdir(options?: { parents?: boolean; existOk?: boolean }): Promise<void> {
|
||||
const kaos = this._currentKaos('mkdir');
|
||||
await kaos.mkdir(this._path, options);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
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';
|
||||
|
|
@ -430,6 +431,14 @@ export class SSHKaos implements Kaos {
|
|||
private _home: string;
|
||||
private _cwd: 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) {
|
||||
this._client = client;
|
||||
this._sftp = sftp;
|
||||
|
|
@ -437,6 +446,10 @@ export class SSHKaos implements Kaos {
|
|||
this._cwd = cwd;
|
||||
}
|
||||
|
||||
withCwd(cwd: string): SSHKaos {
|
||||
return new SSHKaos(this._client, this._sftp, this._home, cwd);
|
||||
}
|
||||
|
||||
private _resolvePath(path: string): string {
|
||||
if (isAbsolute(path)) return path;
|
||||
return join(this._cwd, path);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { join } from 'node:path';
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resetCurrentKaos, setCurrentKaos } from '#/current';
|
||||
import type { KaosToken } from '#/current';
|
||||
import type { Kaos } from '#/kaos';
|
||||
import { LocalKaos } from '#/local';
|
||||
import type { KaosProcess } from '#/process';
|
||||
|
|
@ -56,17 +54,14 @@ async function runCmd(
|
|||
|
||||
describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => {
|
||||
let kaos: Kaos;
|
||||
let token: KaosToken;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-cmd-'));
|
||||
kaos = new LocalKaos();
|
||||
token = setCurrentKaos(kaos);
|
||||
kaos = await LocalKaos.create();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetCurrentKaos(token);
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,179 +1,25 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
chdir,
|
||||
exec,
|
||||
execWithEnv,
|
||||
getCurrentKaos,
|
||||
gethome,
|
||||
getcwd,
|
||||
glob,
|
||||
iterdir,
|
||||
localKaos,
|
||||
LocalKaos,
|
||||
mkdir,
|
||||
normpath,
|
||||
pathClass,
|
||||
readBytes,
|
||||
readLines,
|
||||
readText,
|
||||
resetCurrentKaos,
|
||||
setCurrentKaos,
|
||||
stat,
|
||||
writeBytes,
|
||||
writeText,
|
||||
} from '#/index';
|
||||
import type { Kaos, KaosToken } from '#/index';
|
||||
|
||||
function createMockKaos(name: string): Kaos {
|
||||
return {
|
||||
name,
|
||||
pathClass: () => 'posix' as const,
|
||||
normpath: (p: string) => p,
|
||||
gethome: () => '/',
|
||||
getcwd: () => '/',
|
||||
chdir: async () => {},
|
||||
stat: () =>
|
||||
Promise.resolve({
|
||||
stMode: 0,
|
||||
stIno: 0,
|
||||
stDev: 0,
|
||||
stNlink: 0,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: 0,
|
||||
stMtime: 0,
|
||||
stCtime: 0,
|
||||
}),
|
||||
iterdir: async function* () {},
|
||||
glob: async function* () {},
|
||||
readBytes: () => Promise.resolve(Buffer.alloc(0)),
|
||||
readText: () => Promise.resolve(''),
|
||||
readLines: async function* () {},
|
||||
writeBytes: () => Promise.resolve(0),
|
||||
writeText: () => Promise.resolve(0),
|
||||
mkdir: () => Promise.resolve(),
|
||||
exec: () =>
|
||||
Promise.resolve({
|
||||
stdin: null as unknown as Writable,
|
||||
stdout: null as unknown as Readable,
|
||||
stderr: null as unknown as Readable,
|
||||
pid: -1,
|
||||
exitCode: 0,
|
||||
wait: () => Promise.resolve(0),
|
||||
kill: () => Promise.resolve(),
|
||||
}),
|
||||
execWithEnv: () =>
|
||||
Promise.resolve({
|
||||
stdin: null as unknown as Writable,
|
||||
stdout: null as unknown as Readable,
|
||||
stderr: null as unknown as Readable,
|
||||
pid: -1,
|
||||
exitCode: 0,
|
||||
wait: () => Promise.resolve(0),
|
||||
kill: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('current kaos context', () => {
|
||||
let token: KaosToken | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (token !== undefined) {
|
||||
resetCurrentKaos(token);
|
||||
token = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a LocalKaos instance by default', () => {
|
||||
describe('getCurrentKaos', () => {
|
||||
it('returns the LocalKaos bound by the test setup', () => {
|
||||
const kaos = getCurrentKaos();
|
||||
expect(kaos).toBeInstanceOf(LocalKaos);
|
||||
expect(kaos.name).toBe('local');
|
||||
expect(kaos).toBe(localKaos);
|
||||
});
|
||||
|
||||
it('should return a token from setCurrentKaos', () => {
|
||||
const original = getCurrentKaos();
|
||||
token = setCurrentKaos(new LocalKaos());
|
||||
expect(token.previousKaos).toBe(original);
|
||||
});
|
||||
|
||||
it('should allow setting a custom kaos instance', () => {
|
||||
const mockKaos = createMockKaos('mock');
|
||||
token = setCurrentKaos(mockKaos);
|
||||
const current = getCurrentKaos();
|
||||
expect(current.name).toBe('mock');
|
||||
expect(current).toBe(mockKaos);
|
||||
});
|
||||
|
||||
it('should restore previous kaos with resetCurrentKaos', () => {
|
||||
const original = getCurrentKaos();
|
||||
token = setCurrentKaos(new LocalKaos());
|
||||
expect(getCurrentKaos()).not.toBe(original);
|
||||
resetCurrentKaos(token);
|
||||
expect(getCurrentKaos()).toBe(original);
|
||||
// already restored
|
||||
token = undefined;
|
||||
});
|
||||
|
||||
it('should support nested set/reset', () => {
|
||||
const original = getCurrentKaos();
|
||||
|
||||
const first = new LocalKaos();
|
||||
const token1 = setCurrentKaos(first);
|
||||
expect(getCurrentKaos()).toBe(first);
|
||||
|
||||
const second = new LocalKaos();
|
||||
const token2 = setCurrentKaos(second);
|
||||
expect(getCurrentKaos()).toBe(second);
|
||||
|
||||
resetCurrentKaos(token2);
|
||||
expect(getCurrentKaos()).toBe(first);
|
||||
|
||||
resetCurrentKaos(token1);
|
||||
expect(getCurrentKaos()).toBe(original);
|
||||
});
|
||||
|
||||
it('isolates set/reset across concurrent async flows', async () => {
|
||||
const kaosA = createMockKaos('A');
|
||||
const kaosB = createMockKaos('B');
|
||||
|
||||
const [seenA, seenB] = await Promise.all([
|
||||
(async () => {
|
||||
const tokenA = setCurrentKaos(kaosA);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
return getCurrentKaos().name;
|
||||
} finally {
|
||||
resetCurrentKaos(tokenA);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
const tokenB = setCurrentKaos(kaosB);
|
||||
try {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5);
|
||||
});
|
||||
return getCurrentKaos().name;
|
||||
} finally {
|
||||
resetCurrentKaos(tokenB);
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
|
||||
expect(seenA).toBe('A');
|
||||
expect(seenB).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -238,159 +84,4 @@ describe('module-level proxy functions', () => {
|
|||
|
||||
expect(stdout).toBe('proxy_test_value');
|
||||
});
|
||||
|
||||
// These tests pin the remaining module-level facade functions to their
|
||||
// `getCurrentKaos()` delegate. Using a spy Kaos lets us assert delegation
|
||||
// happened without needing real filesystem I/O for each proxy.
|
||||
it('delegates every facade function to the current kaos instance', async () => {
|
||||
const calls: string[] = [];
|
||||
const spyKaos: Kaos = {
|
||||
...createMockKaos('spy'),
|
||||
pathClass: () => {
|
||||
calls.push('pathClass');
|
||||
return 'posix';
|
||||
},
|
||||
normpath: (p) => {
|
||||
calls.push(`normpath:${p}`);
|
||||
return p;
|
||||
},
|
||||
gethome: () => {
|
||||
calls.push('gethome');
|
||||
return '/mock-home';
|
||||
},
|
||||
getcwd: () => {
|
||||
calls.push('getcwd');
|
||||
return '/mock-cwd';
|
||||
},
|
||||
chdir: async (p) => {
|
||||
calls.push(`chdir:${p}`);
|
||||
},
|
||||
stat: async (p) => {
|
||||
calls.push(`stat:${p}`);
|
||||
return {
|
||||
stMode: 0,
|
||||
stIno: 0,
|
||||
stDev: 0,
|
||||
stNlink: 0,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: 0,
|
||||
stMtime: 0,
|
||||
stCtime: 0,
|
||||
};
|
||||
},
|
||||
iterdir: async function* (p) {
|
||||
calls.push(`iterdir:${p}`);
|
||||
// Yield a throwaway value so this function is a valid non-empty
|
||||
// generator — the test just wants to observe that the push ran.
|
||||
yield `${p}/dummy`;
|
||||
},
|
||||
glob: async function* (p, pat) {
|
||||
calls.push(`glob:${p}:${pat}`);
|
||||
yield `${p}/${pat}`;
|
||||
},
|
||||
readBytes: async (p, n) => {
|
||||
calls.push(`readBytes:${p}:${String(n)}`);
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
readText: async (p) => {
|
||||
calls.push(`readText:${p}`);
|
||||
return '';
|
||||
},
|
||||
readLines: async function* (p) {
|
||||
calls.push(`readLines:${p}`);
|
||||
yield `${p}:line`;
|
||||
},
|
||||
writeBytes: async (p, d) => {
|
||||
calls.push(`writeBytes:${p}:${String(d.length)}`);
|
||||
return d.length;
|
||||
},
|
||||
writeText: async (p, d) => {
|
||||
calls.push(`writeText:${p}:${String(d.length)}`);
|
||||
return d.length;
|
||||
},
|
||||
mkdir: async (p) => {
|
||||
calls.push(`mkdir:${p}`);
|
||||
},
|
||||
};
|
||||
|
||||
const spyToken = setCurrentKaos(spyKaos);
|
||||
try {
|
||||
// Sync proxies
|
||||
expect(pathClass()).toBe('posix');
|
||||
expect(normpath('/a/b')).toBe('/a/b');
|
||||
expect(gethome()).toBe('/mock-home');
|
||||
expect(getcwd()).toBe('/mock-cwd');
|
||||
|
||||
// Async single-value proxies
|
||||
await chdir('/dst');
|
||||
await stat('/path');
|
||||
await readBytes('/f', 10);
|
||||
await readText('/f2');
|
||||
await writeBytes('/f3', Buffer.from([1, 2, 3]));
|
||||
await writeText('/f4', 'hello');
|
||||
await mkdir('/d');
|
||||
|
||||
// Async-generator proxies — must be consumed to execute body
|
||||
for await (const _ of iterdir('/d2')) {
|
||||
// body intentionally empty
|
||||
void _;
|
||||
}
|
||||
for await (const _ of glob('/d3', '*.txt')) {
|
||||
void _;
|
||||
}
|
||||
} finally {
|
||||
resetCurrentKaos(spyToken);
|
||||
}
|
||||
|
||||
expect(calls).toEqual([
|
||||
'pathClass',
|
||||
'normpath:/a/b',
|
||||
'gethome',
|
||||
'getcwd',
|
||||
'chdir:/dst',
|
||||
'stat:/path',
|
||||
'readBytes:/f:10',
|
||||
'readText:/f2',
|
||||
'writeBytes:/f3:3',
|
||||
'writeText:/f4:5',
|
||||
'mkdir:/d',
|
||||
'iterdir:/d2',
|
||||
'glob:/d3:*.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exec proxies to the current kaos instance', async () => {
|
||||
let received: string[] = [];
|
||||
const spyKaos: Kaos = {
|
||||
...createMockKaos('spy-exec'),
|
||||
exec: async (...args) => {
|
||||
received = args;
|
||||
throw new Error('intentionally stopped for the spy');
|
||||
},
|
||||
};
|
||||
|
||||
const spyToken = setCurrentKaos(spyKaos);
|
||||
try {
|
||||
await expect(exec('cmd', 'a', 'b')).rejects.toThrow('intentionally stopped for the spy');
|
||||
} finally {
|
||||
resetCurrentKaos(spyToken);
|
||||
}
|
||||
expect(received).toEqual(['cmd', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCurrentKaos null-token fallback', () => {
|
||||
it('falls back to the default LocalKaos when previousKaos is null', () => {
|
||||
// Construct a synthetic token whose previousKaos is null. This mirrors
|
||||
// the state a token would be in if the ambient context had no override
|
||||
// at all when `setCurrentKaos` was called.
|
||||
const syntheticToken: KaosToken = { previousKaos: null };
|
||||
resetCurrentKaos(syntheticToken);
|
||||
|
||||
// After reset, the current kaos should be the default LocalKaos.
|
||||
const current = getCurrentKaos();
|
||||
expect(current.name).toBe('local');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getCurrentKaos, runWithKaos } from '#/current';
|
||||
import type { Kaos } from '#/kaos';
|
||||
import { LocalKaos } from '#/local';
|
||||
|
||||
// ── Mock Kaos ─────────────────────────────────────────────────────────
|
||||
|
||||
function createNamedKaos(kaosName: string): Kaos {
|
||||
const base = new LocalKaos();
|
||||
return {
|
||||
name: kaosName,
|
||||
pathClass: () => base.pathClass(),
|
||||
normpath: (p: string) => base.normpath(p),
|
||||
gethome: () => base.gethome(),
|
||||
getcwd: () => base.getcwd(),
|
||||
chdir: async (p: string) => base.chdir(p),
|
||||
stat: async (p: string, opts?: { followSymlinks?: boolean }) => base.stat(p, opts),
|
||||
iterdir: (p: string) => base.iterdir(p),
|
||||
glob: (p: string, pattern: string, opts?: { caseSensitive?: boolean }) =>
|
||||
base.glob(p, pattern, opts),
|
||||
readBytes: async (p: string, n?: number) => base.readBytes(p, n),
|
||||
readText: async (
|
||||
p: string,
|
||||
opts?: { encoding?: BufferEncoding; errors?: 'strict' | 'ignore' | 'replace' },
|
||||
) => base.readText(p, opts),
|
||||
readLines: (p: string, opts?: { encoding?: BufferEncoding }) => base.readLines(p, opts),
|
||||
writeBytes: async (p: string, data: Buffer) => base.writeBytes(p, data),
|
||||
writeText: async (
|
||||
p: string,
|
||||
data: string,
|
||||
opts?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
) => base.writeText(p, data, opts),
|
||||
mkdir: async (p: string, opts?: { parents?: boolean; existOk?: boolean }) =>
|
||||
base.mkdir(p, opts),
|
||||
exec: async (...args: string[]) => base.exec(...args),
|
||||
execWithEnv: async (args: string[], env?: Record<string, string>) =>
|
||||
base.execWithEnv(args, env),
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('e2e: async isolation (AsyncLocalStorage)', () => {
|
||||
describe('runWithKaos provides true isolation between concurrent calls', () => {
|
||||
it('two concurrent runWithKaos with different Kaos instances return correct instances', async () => {
|
||||
const kaosA = createNamedKaos('kaos-A');
|
||||
const kaosB = createNamedKaos('kaos-B');
|
||||
|
||||
const [nameA, nameB] = await Promise.all([
|
||||
runWithKaos(kaosA, async () => {
|
||||
// Yield the event loop to let B start
|
||||
await delay(5);
|
||||
return getCurrentKaos().name;
|
||||
}),
|
||||
runWithKaos(kaosB, async () => {
|
||||
// Yield the event loop to let A continue
|
||||
await delay(5);
|
||||
return getCurrentKaos().name;
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(nameA).toBe('kaos-A');
|
||||
expect(nameB).toBe('kaos-B');
|
||||
});
|
||||
|
||||
it('many concurrent runWithKaos calls maintain isolation', async () => {
|
||||
const count = 20;
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const kaos = createNamedKaos(`kaos-${i}`);
|
||||
return runWithKaos(kaos, async () => {
|
||||
await delay(Math.random() * 10);
|
||||
return getCurrentKaos().name;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(results[i]).toBe(`kaos-${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested runWithKaos', () => {
|
||||
it('inner scope sees inner Kaos, outer scope restores after exit', async () => {
|
||||
const kaosOuter = createNamedKaos('outer');
|
||||
const kaosInner = createNamedKaos('inner');
|
||||
|
||||
const result = await runWithKaos(kaosOuter, async () => {
|
||||
const beforeNest = getCurrentKaos().name;
|
||||
|
||||
const innerResult = await runWithKaos(kaosInner, async () => {
|
||||
return getCurrentKaos().name;
|
||||
});
|
||||
|
||||
const afterNest = getCurrentKaos().name;
|
||||
|
||||
return { beforeNest, innerResult, afterNest };
|
||||
});
|
||||
|
||||
expect(result.beforeNest).toBe('outer');
|
||||
expect(result.innerResult).toBe('inner');
|
||||
expect(result.afterNest).toBe('outer');
|
||||
});
|
||||
|
||||
it('triple nested runWithKaos restores correctly at each level', async () => {
|
||||
const kaosA = createNamedKaos('level-A');
|
||||
const kaosB = createNamedKaos('level-B');
|
||||
const kaosC = createNamedKaos('level-C');
|
||||
|
||||
const result = await runWithKaos(kaosA, async () => {
|
||||
const atA = getCurrentKaos().name;
|
||||
|
||||
const inner = await runWithKaos(kaosB, async () => {
|
||||
const atB = getCurrentKaos().name;
|
||||
|
||||
const deepest = await runWithKaos(kaosC, async () => {
|
||||
return getCurrentKaos().name;
|
||||
});
|
||||
|
||||
const afterC = getCurrentKaos().name;
|
||||
return { atB, deepest, afterC };
|
||||
});
|
||||
|
||||
const afterB = getCurrentKaos().name;
|
||||
return { atA, ...inner, afterB };
|
||||
});
|
||||
|
||||
expect(result.atA).toBe('level-A');
|
||||
expect(result.atB).toBe('level-B');
|
||||
expect(result.deepest).toBe('level-C');
|
||||
expect(result.afterC).toBe('level-B');
|
||||
expect(result.afterB).toBe('level-A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithKaos context survives await', () => {
|
||||
it('context is maintained after await delay', async () => {
|
||||
const kaos = createNamedKaos('awaited');
|
||||
|
||||
const result = await runWithKaos(kaos, async () => {
|
||||
const before = getCurrentKaos().name;
|
||||
await delay(10);
|
||||
const after = getCurrentKaos().name;
|
||||
return { before, after };
|
||||
});
|
||||
|
||||
expect(result.before).toBe('awaited');
|
||||
expect(result.after).toBe('awaited');
|
||||
});
|
||||
|
||||
it('context is maintained across multiple awaits', async () => {
|
||||
const kaos = createNamedKaos('multi-await');
|
||||
|
||||
const result = await runWithKaos(kaos, async () => {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await delay(2);
|
||||
names.push(getCurrentKaos().name);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
'multi-await',
|
||||
'multi-await',
|
||||
'multi-await',
|
||||
'multi-await',
|
||||
'multi-await',
|
||||
]);
|
||||
});
|
||||
|
||||
it('context survives Promise.all inside runWithKaos', async () => {
|
||||
const kaos = createNamedKaos('promise-all');
|
||||
|
||||
const result = await runWithKaos(kaos, async () => {
|
||||
const names = await Promise.all([
|
||||
(async () => {
|
||||
await delay(5);
|
||||
return getCurrentKaos().name;
|
||||
})(),
|
||||
(async () => {
|
||||
await delay(10);
|
||||
return getCurrentKaos().name;
|
||||
})(),
|
||||
(async () => {
|
||||
await delay(1);
|
||||
return getCurrentKaos().name;
|
||||
})(),
|
||||
]);
|
||||
return names;
|
||||
});
|
||||
|
||||
expect(result).toEqual(['promise-all', 'promise-all', 'promise-all']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ describe('e2e: concurrent operations', () => {
|
|||
let originalCwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-concurrent-')));
|
||||
process.chdir(tempDir);
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getCurrentKaos, runWithKaos } from '#/current';
|
||||
import { LocalKaos } from '#/local';
|
||||
|
||||
// ── Instance-level cwd isolation: real concurrent file operations ─────
|
||||
//
|
||||
// LocalKaos maintains its own per-instance `_cwd` rather than mutating
|
||||
// `process.cwd()`. Two concurrent LocalKaos instances chdir-ing into
|
||||
// different temp directories and issuing relative-path reads/writes
|
||||
// must not clobber each other.
|
||||
|
||||
describe('e2e: LocalKaos instance-level cwd isolation (concurrent, real FS)', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(label: string): Promise<string> {
|
||||
const dir = await realpath(await mkdtemp(join(tmpdir(), `kaos-cwd-iso-${label}-`)));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir !== undefined) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('two concurrent runWithKaos scopes with different LocalKaos cwds each read their own files', async () => {
|
||||
const dirA = await makeTempDir('A');
|
||||
const dirB = await makeTempDir('B');
|
||||
|
||||
const kaosA = new LocalKaos();
|
||||
const kaosB = new LocalKaos();
|
||||
|
||||
// Pre-seed distinct files in each dir using absolute paths.
|
||||
await kaosA.writeText(join(dirA, 'marker.txt'), 'A-content');
|
||||
await kaosB.writeText(join(dirB, 'marker.txt'), 'B-content');
|
||||
|
||||
// Run two concurrent scopes. Each scope chdirs into its own temp
|
||||
// directory and reads 'marker.txt' via a *relative* path. Yielding
|
||||
// the event loop between the chdir and the read gives the other
|
||||
// scope a chance to clobber shared state if it exists.
|
||||
const [a, b] = await Promise.all([
|
||||
runWithKaos(kaosA, async () => {
|
||||
const k = getCurrentKaos();
|
||||
await k.chdir(dirA);
|
||||
// Yield to the other concurrent scope.
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const value = await k.readText('marker.txt');
|
||||
// Yield again after reading.
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const cwdAfter = k.getcwd();
|
||||
return { value, cwdAfter };
|
||||
}),
|
||||
runWithKaos(kaosB, async () => {
|
||||
const k = getCurrentKaos();
|
||||
await k.chdir(dirB);
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const value = await k.readText('marker.txt');
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const cwdAfter = k.getcwd();
|
||||
return { value, cwdAfter };
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(a.value).toBe('A-content');
|
||||
expect(b.value).toBe('B-content');
|
||||
expect(a.cwdAfter).toBe(dirA);
|
||||
expect(b.cwdAfter).toBe(dirB);
|
||||
|
||||
// Neither chdir should have touched the node-level process.cwd().
|
||||
// (We don't assert a specific value — the test runner controls it —
|
||||
// but the two LocalKaos instances must hold distinct _cwds.)
|
||||
expect(kaosA.getcwd()).toBe(dirA);
|
||||
expect(kaosB.getcwd()).toBe(dirB);
|
||||
});
|
||||
|
||||
it('interleaved concurrent relative writes stay isolated', async () => {
|
||||
const dirA = await makeTempDir('wA');
|
||||
const dirB = await makeTempDir('wB');
|
||||
|
||||
const kaosA = new LocalKaos();
|
||||
const kaosB = new LocalKaos();
|
||||
await kaosA.chdir(dirA);
|
||||
await kaosB.chdir(dirB);
|
||||
|
||||
// Perform many interleaved relative writes from both scopes.
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
tasks.push(
|
||||
runWithKaos(kaosA, async () => {
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
await getCurrentKaos().writeText(`a-${i}.txt`, `A-${i}`);
|
||||
}),
|
||||
);
|
||||
tasks.push(
|
||||
runWithKaos(kaosB, async () => {
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
await getCurrentKaos().writeText(`b-${i}.txt`, `B-${i}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
// All A files must live in dirA with A content, and B in dirB.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const aVal = await kaosA.readText(join(dirA, `a-${i}.txt`));
|
||||
const bVal = await kaosB.readText(join(dirB, `b-${i}.txt`));
|
||||
expect(aVal).toBe(`A-${i}`);
|
||||
expect(bVal).toBe(`B-${i}`);
|
||||
}
|
||||
|
||||
// And no cross-contamination: A files must NOT exist under dirB.
|
||||
await expect(kaosA.readText(join(dirB, 'a-0.txt'))).rejects.toThrow();
|
||||
await expect(kaosB.readText(join(dirA, 'b-0.txt'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('nested runWithKaos with different cwds restores the outer cwd correctly', async () => {
|
||||
const dirOuter = await makeTempDir('outer');
|
||||
const dirInner = await makeTempDir('inner');
|
||||
|
||||
const kaosOuter = new LocalKaos();
|
||||
const kaosInner = new LocalKaos();
|
||||
await kaosOuter.chdir(dirOuter);
|
||||
await kaosInner.chdir(dirInner);
|
||||
|
||||
await kaosOuter.writeText(join(dirOuter, 'outer.txt'), 'OUTER');
|
||||
await kaosInner.writeText(join(dirInner, 'inner.txt'), 'INNER');
|
||||
|
||||
const result = await runWithKaos(kaosOuter, async () => {
|
||||
const beforeNest = await getCurrentKaos().readText('outer.txt');
|
||||
|
||||
const innerValue = await runWithKaos(kaosInner, async () => {
|
||||
return getCurrentKaos().readText('inner.txt');
|
||||
});
|
||||
|
||||
const afterNest = await getCurrentKaos().readText('outer.txt');
|
||||
return { beforeNest, innerValue, afterNest };
|
||||
});
|
||||
|
||||
expect(result.beforeNest).toBe('OUTER');
|
||||
expect(result.innerValue).toBe('INNER');
|
||||
expect(result.afterNest).toBe('OUTER');
|
||||
});
|
||||
|
||||
it('LocalKaos.chdir never mutates process.cwd()', async () => {
|
||||
const dirA = await makeTempDir('nochangeA');
|
||||
const originalProcessCwd = process.cwd();
|
||||
|
||||
const kaos = new LocalKaos();
|
||||
await kaos.chdir(dirA);
|
||||
|
||||
// The instance cwd must have changed.
|
||||
expect(kaos.getcwd()).toBe(dirA);
|
||||
// But the process-global cwd must not have been touched.
|
||||
expect(process.cwd()).toBe(originalProcessCwd);
|
||||
});
|
||||
|
||||
it('exec respects instance-level cwd (two instances with different cwds)', async () => {
|
||||
const dirA = await makeTempDir('execA');
|
||||
const dirB = await makeTempDir('execB');
|
||||
|
||||
const kaosA = new LocalKaos();
|
||||
const kaosB = new LocalKaos();
|
||||
await kaosA.chdir(dirA);
|
||||
await kaosB.chdir(dirB);
|
||||
|
||||
async function readStdout(proc: Awaited<ReturnType<LocalKaos['exec']>>): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of proc.stdout) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
|
||||
}
|
||||
await proc.wait();
|
||||
return Buffer.concat(chunks).toString('utf-8').trim();
|
||||
}
|
||||
|
||||
// Run `pwd` concurrently in both instances. Each must report its own cwd.
|
||||
const [outA, outB] = await Promise.all([
|
||||
kaosA.exec('sh', '-c', 'pwd').then(readStdout),
|
||||
kaosB.exec('sh', '-c', 'pwd').then(readStdout),
|
||||
]);
|
||||
|
||||
// On macOS /tmp is a symlink to /private/tmp — we already realpath'd
|
||||
// the temp dir during creation, so comparing strings is safe.
|
||||
expect(outA).toBe(dirA);
|
||||
expect(outB).toBe(dirB);
|
||||
});
|
||||
});
|
||||
|
|
@ -43,7 +43,7 @@ describe('e2e: exec edge cases', () => {
|
|||
let originalCwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-exec-edge-')));
|
||||
await kaos.chdir(tempDir);
|
||||
|
|
@ -176,8 +176,8 @@ describe('e2e: exec edge cases', () => {
|
|||
await kaos.mkdir(subA);
|
||||
await kaos.mkdir(subB);
|
||||
|
||||
const kaosA = new LocalKaos();
|
||||
const kaosB = new LocalKaos();
|
||||
const kaosA = await LocalKaos.create();
|
||||
const kaosB = await LocalKaos.create();
|
||||
await kaosA.chdir(subA);
|
||||
await kaosB.chdir(subB);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ describe('e2e: glob parity boundaries', () => {
|
|||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-glob-')));
|
||||
await kaos.chdir(tempDir);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getCurrentKaos, runWithKaos } from '#/current';
|
||||
import type { Kaos } from '#/kaos';
|
||||
import { KaosPath } from '#/path';
|
||||
import type { KaosProcess } from '#/process';
|
||||
import type { StatResult } from '#/types';
|
||||
|
||||
// ── Minimal mock Kaos ────────────────────────────────────────────────
|
||||
|
||||
function createMockKaos(overrides: Partial<Kaos> & { name: string }): Kaos {
|
||||
const defaults: Kaos = {
|
||||
name: overrides.name,
|
||||
pathClass(): 'posix' | 'win32' {
|
||||
return 'posix';
|
||||
},
|
||||
normpath(path: string): string {
|
||||
return path;
|
||||
},
|
||||
gethome(): string {
|
||||
return '/default/home';
|
||||
},
|
||||
getcwd(): string {
|
||||
return '/default/cwd';
|
||||
},
|
||||
async chdir(): Promise<void> {
|
||||
// no-op
|
||||
},
|
||||
async stat(): Promise<StatResult> {
|
||||
return {
|
||||
stMode: 0,
|
||||
stIno: 0,
|
||||
stDev: 0,
|
||||
stNlink: 0,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: 0,
|
||||
stMtime: 0,
|
||||
stCtime: 0,
|
||||
};
|
||||
},
|
||||
async *iterdir(): AsyncGenerator<string> {
|
||||
// empty
|
||||
},
|
||||
async *glob(): AsyncGenerator<string> {
|
||||
// empty
|
||||
},
|
||||
async readBytes(): Promise<Buffer> {
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
async readText(): Promise<string> {
|
||||
return '';
|
||||
},
|
||||
async *readLines(): AsyncGenerator<string> {
|
||||
// empty
|
||||
},
|
||||
async writeBytes(): Promise<number> {
|
||||
return 0;
|
||||
},
|
||||
async writeText(): Promise<number> {
|
||||
return 0;
|
||||
},
|
||||
async mkdir(): Promise<void> {
|
||||
// no-op
|
||||
},
|
||||
async exec(): Promise<KaosProcess> {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
async execWithEnv(): Promise<KaosProcess> {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
};
|
||||
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('e2e: KaosPath cross-platform', () => {
|
||||
describe('expanduser delegates to kaos.gethome()', () => {
|
||||
it('~ expands to custom home from mock Kaos', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'custom-home',
|
||||
gethome(): string {
|
||||
return '/custom/home';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('~').expanduser().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/custom/home');
|
||||
});
|
||||
|
||||
it('~/subpath expands correctly', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'custom-home-sub',
|
||||
gethome(): string {
|
||||
return '/users/testuser';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('~/Documents/file.txt').expanduser().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/users/testuser/Documents/file.txt');
|
||||
});
|
||||
|
||||
it('non-tilde path is unchanged', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'no-expand',
|
||||
gethome(): string {
|
||||
return '/should/not/appear';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('/absolute/path').expanduser().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/absolute/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical uses correct path module', () => {
|
||||
it('posix pathClass uses posix rules', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'posix-canonical',
|
||||
pathClass(): 'posix' | 'win32' {
|
||||
return 'posix';
|
||||
},
|
||||
getcwd(): string {
|
||||
return '/home/user/project';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('src/../lib/utils.ts').canonical().toString();
|
||||
});
|
||||
|
||||
// posix normalize of /home/user/project/src/../lib/utils.ts
|
||||
expect(result).toBe('/home/user/project/lib/utils.ts');
|
||||
});
|
||||
|
||||
it('absolute path is normalized without prepending cwd', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'abs-canonical',
|
||||
pathClass(): 'posix' | 'win32' {
|
||||
return 'posix';
|
||||
},
|
||||
getcwd(): string {
|
||||
return '/should/not/appear';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('/a/b/../c/./d').canonical().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/a/c/d');
|
||||
});
|
||||
|
||||
it('relative path is resolved against cwd', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'rel-canonical',
|
||||
pathClass(): 'posix' | 'win32' {
|
||||
return 'posix';
|
||||
},
|
||||
getcwd(): string {
|
||||
return '/workspace';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return new KaosPath('src/main.ts').canonical().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/workspace/src/main.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithKaos concurrent isolation', () => {
|
||||
it('concurrent runWithKaos calls return their own Kaos instances', async () => {
|
||||
const kaos1 = createMockKaos({
|
||||
name: 'kaos-1',
|
||||
gethome(): string {
|
||||
return '/home/user1';
|
||||
},
|
||||
});
|
||||
|
||||
const kaos2 = createMockKaos({
|
||||
name: 'kaos-2',
|
||||
gethome(): string {
|
||||
return '/home/user2';
|
||||
},
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
new Promise<{ name: string; home: string }>((resolve) => {
|
||||
runWithKaos(kaos1, () => {
|
||||
// Small delay to ensure overlap
|
||||
setTimeout(() => {
|
||||
const current = getCurrentKaos();
|
||||
resolve({
|
||||
name: current.name,
|
||||
home: current.gethome(),
|
||||
});
|
||||
}, 10);
|
||||
});
|
||||
}),
|
||||
new Promise<{ name: string; home: string }>((resolve) => {
|
||||
runWithKaos(kaos2, () => {
|
||||
setTimeout(() => {
|
||||
const current = getCurrentKaos();
|
||||
resolve({
|
||||
name: current.name,
|
||||
home: current.gethome(),
|
||||
});
|
||||
}, 10);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results[0].name).toBe('kaos-1');
|
||||
expect(results[0].home).toBe('/home/user1');
|
||||
expect(results[1].name).toBe('kaos-2');
|
||||
expect(results[1].home).toBe('/home/user2');
|
||||
});
|
||||
|
||||
it('nested runWithKaos scopes correctly', () => {
|
||||
const outerKaos = createMockKaos({
|
||||
name: 'outer',
|
||||
gethome(): string {
|
||||
return '/outer/home';
|
||||
},
|
||||
});
|
||||
|
||||
const innerKaos = createMockKaos({
|
||||
name: 'inner',
|
||||
gethome(): string {
|
||||
return '/inner/home';
|
||||
},
|
||||
});
|
||||
|
||||
const outerHome = runWithKaos(outerKaos, () => {
|
||||
const before = getCurrentKaos().gethome();
|
||||
|
||||
const innerHome = runWithKaos(innerKaos, () => {
|
||||
return getCurrentKaos().gethome();
|
||||
});
|
||||
|
||||
const after = getCurrentKaos().gethome();
|
||||
|
||||
return { before, innerHome, after };
|
||||
});
|
||||
|
||||
expect(outerHome.before).toBe('/outer/home');
|
||||
expect(outerHome.innerHome).toBe('/inner/home');
|
||||
expect(outerHome.after).toBe('/outer/home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KaosPath static methods via mock', () => {
|
||||
it('KaosPath.home() delegates to getCurrentKaos', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'static-home',
|
||||
gethome(): string {
|
||||
return '/mock/home/dir';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return KaosPath.home().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/mock/home/dir');
|
||||
});
|
||||
|
||||
it('KaosPath.cwd() delegates to getCurrentKaos', () => {
|
||||
const mockKaos = createMockKaos({
|
||||
name: 'static-cwd',
|
||||
getcwd(): string {
|
||||
return '/mock/working/dir';
|
||||
},
|
||||
});
|
||||
|
||||
const result = runWithKaos(mockKaos, () => {
|
||||
return KaosPath.cwd().toString();
|
||||
});
|
||||
|
||||
expect(result).toBe('/mock/working/dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -23,7 +23,7 @@ describe('e2e: process lifecycle', () => {
|
|||
let originalCwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-')));
|
||||
process.chdir(tempDir);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ describe.skipIf(process.platform === 'win32')('e2e: symlink stat parity', () =>
|
|||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-symlink-')));
|
||||
await kaos.chdir(tempDir);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* - 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,
|
||||
* or well-known install locations; throws `shell.git_bash_not_found`
|
||||
* or well-known install locations; throws `KaosShellNotFoundError`
|
||||
* if none are present.
|
||||
* - `osArch` / `osVersion` are populated from the Node OS APIs.
|
||||
*
|
||||
|
|
@ -18,13 +18,13 @@
|
|||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ErrorCodes, KimiError } from '../../src/errors';
|
||||
import {
|
||||
detectEnvironment,
|
||||
type Environment,
|
||||
type OsKind,
|
||||
type ShellName,
|
||||
} from '../../src/utils/environment';
|
||||
} from '#/environment';
|
||||
import { KaosShellNotFoundError } from '#/errors';
|
||||
|
||||
interface StubOpts {
|
||||
readonly platform: NodeJS.Platform;
|
||||
|
|
@ -178,7 +178,7 @@ describe('detectEnvironment', () => {
|
|||
expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe');
|
||||
});
|
||||
|
||||
it('throws KimiError with the shell.git_bash_not_found code when no candidate is found', async () => {
|
||||
it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => {
|
||||
const error = await detectEnvironment(
|
||||
stubDeps({
|
||||
platform: 'win32',
|
||||
|
|
@ -191,11 +191,10 @@ describe('detectEnvironment', () => {
|
|||
},
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(error).toBeInstanceOf(KimiError);
|
||||
expect((error as KimiError).code).toBe(ErrorCodes.SHELL_GIT_BASH_NOT_FOUND);
|
||||
expect(error).toBeInstanceOf(KaosShellNotFoundError);
|
||||
});
|
||||
|
||||
it('includes attempted paths in the thrown KimiError message', async () => {
|
||||
it('includes attempted paths in the thrown error message', async () => {
|
||||
const error = await detectEnvironment(
|
||||
stubDeps({
|
||||
platform: 'win32',
|
||||
|
|
@ -206,7 +205,7 @@ describe('detectEnvironment', () => {
|
|||
() => {
|
||||
throw new Error('expected throw');
|
||||
},
|
||||
(error: unknown) => error as KimiError,
|
||||
(error: unknown) => error as KaosShellNotFoundError,
|
||||
);
|
||||
expect(error.message).toContain('D:\\custom\\bash.exe');
|
||||
expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe');
|
||||
|
|
@ -15,7 +15,7 @@ describe('LocalKaos', () => {
|
|||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
kaos = new LocalKaos();
|
||||
kaos = await LocalKaos.create();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-')));
|
||||
await kaos.chdir(tempDir);
|
||||
});
|
||||
|
|
@ -644,8 +644,8 @@ describe('LocalKaos', () => {
|
|||
|
||||
describe('LocalKaos instance isolation', () => {
|
||||
test('instances have isolated cwds (no process.cwd pollution)', async () => {
|
||||
const kaosA = new LocalKaos();
|
||||
const kaosB = new LocalKaos();
|
||||
const kaosA = await LocalKaos.create();
|
||||
const kaosB = await LocalKaos.create();
|
||||
|
||||
const tmpA = await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-')));
|
||||
const tmpB = await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-')));
|
||||
|
|
@ -684,7 +684,7 @@ describe('LocalKaos instance isolation', () => {
|
|||
|
||||
describe('LocalProcess.kill safety', () => {
|
||||
test('kill() is safe when spawn failed (pid -1 must not signal process group)', async () => {
|
||||
const kaos = new LocalKaos();
|
||||
const kaos = await LocalKaos.create();
|
||||
|
||||
// Try to spawn a nonexistent command. Node's spawn() returns a
|
||||
// ChildProcess immediately with pid=undefined; the "error" event
|
||||
|
|
@ -712,7 +712,7 @@ describe('LocalProcess.kill safety', () => {
|
|||
});
|
||||
|
||||
test('kill() handles already-exited process gracefully (ESRCH ignored)', async () => {
|
||||
const kaos = new LocalKaos();
|
||||
const kaos = await LocalKaos.create();
|
||||
const proc = await kaos.exec('node', '-e', 'process.exit(0)');
|
||||
await proc.wait();
|
||||
|
||||
|
|
@ -732,7 +732,7 @@ describe('LocalProcess.kill safety', () => {
|
|||
test.skipIf(process.platform !== 'win32')(
|
||||
'kill() terminates the grandchild on Windows (process tree)',
|
||||
async () => {
|
||||
const kaos = new LocalKaos();
|
||||
const kaos = await LocalKaos.create();
|
||||
const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-')));
|
||||
try {
|
||||
const pidFile = join(tmp, 'grandchild.pid').replaceAll('\\', '\\\\');
|
||||
|
|
@ -802,7 +802,7 @@ describe('LocalProcess.kill safety', () => {
|
|||
test.skipIf(process.platform === 'win32')(
|
||||
'kill() terminates the grandchild on POSIX (process tree)',
|
||||
async () => {
|
||||
const kaos = new LocalKaos();
|
||||
const kaos = await LocalKaos.create();
|
||||
const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-posix-')));
|
||||
try {
|
||||
const pidFile = join(tmp, 'grandchild.pid');
|
||||
|
|
|
|||
|
|
@ -1,501 +0,0 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as win32Path from 'node:path/win32';
|
||||
|
||||
import { resetCurrentKaos, setCurrentKaos } from '#/current';
|
||||
import type { KaosToken } from '#/current';
|
||||
import type { Kaos } from '#/kaos';
|
||||
import { LocalKaos } from '#/local';
|
||||
import { KaosPath } from '#/path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function makeMockKaos(pathClass: 'posix' | 'win32', overrides: Partial<Kaos> = {}): Kaos {
|
||||
return {
|
||||
name: `mock-${pathClass}`,
|
||||
pathClass: () => pathClass,
|
||||
normpath: (p: string) => (pathClass === 'win32' ? win32Path.normalize(p) : p),
|
||||
gethome: () => (pathClass === 'win32' ? 'C:\\Users\\test' : '/home/test'),
|
||||
getcwd: () => (pathClass === 'win32' ? 'C:\\work\\project' : '/work/project'),
|
||||
chdir: async () => {},
|
||||
stat: async () => ({
|
||||
stMode: 0,
|
||||
stIno: 0,
|
||||
stDev: 0,
|
||||
stNlink: 0,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: 0,
|
||||
stMtime: 0,
|
||||
stCtime: 0,
|
||||
}),
|
||||
iterdir: async function* () {},
|
||||
glob: async function* () {},
|
||||
readBytes: async () => Buffer.alloc(0),
|
||||
readText: async () => '',
|
||||
readLines: async function* () {},
|
||||
writeBytes: async () => 0,
|
||||
writeText: async () => 0,
|
||||
mkdir: async () => {},
|
||||
exec: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
execWithEnv: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KaosPath', () => {
|
||||
let token: KaosToken;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-path-'));
|
||||
const kaos = new LocalKaos();
|
||||
token = setCurrentKaos(kaos);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetCurrentKaos(token);
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('join and parent', () => {
|
||||
it('should join paths with div', () => {
|
||||
const p = new KaosPath('/usr');
|
||||
const child = p.div('local').div('bin');
|
||||
expect(child.toString()).toBe('/usr/local/bin');
|
||||
});
|
||||
|
||||
it('should join paths with joinpath', () => {
|
||||
const p = new KaosPath('/usr');
|
||||
const child = p.joinpath('local', 'bin');
|
||||
expect(child.toString()).toBe('/usr/local/bin');
|
||||
});
|
||||
|
||||
it('should treat absolute joinpath segments as new roots', () => {
|
||||
const p = new KaosPath('/usr');
|
||||
expect(p.joinpath('/etc').toString()).toBe('/etc');
|
||||
expect(new KaosPath('/usr', '/etc').toString()).toBe('/etc');
|
||||
});
|
||||
|
||||
it('should accept backslashes as separators on posix', () => {
|
||||
const p = new KaosPath('foo\\bar\\baz');
|
||||
expect(p.toString()).toBe('foo/bar/baz');
|
||||
expect(p.name).toBe('baz');
|
||||
expect(p.parent.toString()).toBe('foo/bar');
|
||||
});
|
||||
|
||||
it('should preserve parent references until canonical()', () => {
|
||||
const p = new KaosPath('/usr').joinpath('../etc');
|
||||
expect(p.toString()).toBe('/usr/../etc');
|
||||
expect(p.canonical().toString()).toBe('/etc');
|
||||
});
|
||||
|
||||
it('should accept another KaosPath as div() argument', () => {
|
||||
// Exercises the `other instanceof KaosPath` branch in div() — the
|
||||
// existing tests only pass raw strings so this covers the other
|
||||
// side of the ternary.
|
||||
const p = new KaosPath('/usr');
|
||||
const sub = new KaosPath('local');
|
||||
expect(p.div(sub).toString()).toBe('/usr/local');
|
||||
});
|
||||
|
||||
it('should return the parent path', () => {
|
||||
const p = new KaosPath('/usr/local/bin');
|
||||
expect(p.parent.toString()).toBe('/usr/local');
|
||||
expect(p.parent.parent.toString()).toBe('/usr');
|
||||
});
|
||||
|
||||
it('should return the name', () => {
|
||||
const p = new KaosPath('/usr/local/bin');
|
||||
expect(p.name).toBe('bin');
|
||||
});
|
||||
|
||||
it('should detect absolute paths', () => {
|
||||
expect(new KaosPath('/usr').isAbsolute()).toBe(true);
|
||||
expect(new KaosPath('relative/path').isAbsolute()).toBe(false);
|
||||
});
|
||||
|
||||
it('should default to "." when constructed with no arguments', () => {
|
||||
// KaosPath() with no args represents the current directory placeholder.
|
||||
// Used as a neutral starting point before chaining .div()/.joinpath().
|
||||
const p = new KaosPath();
|
||||
expect(p.toString()).toBe('.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('home and cwd', () => {
|
||||
it('should return home directory via KaosPath.home()', () => {
|
||||
const home = KaosPath.home();
|
||||
expect(home.isAbsolute()).toBe(true);
|
||||
expect(home.toString()).toBe(homedir());
|
||||
});
|
||||
|
||||
it('should return cwd via KaosPath.cwd()', () => {
|
||||
const cwd = KaosPath.cwd();
|
||||
expect(cwd.isAbsolute()).toBe(true);
|
||||
expect(cwd.toString()).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanduser', () => {
|
||||
it('should expand ~ to home directory', () => {
|
||||
const p = new KaosPath('~/Documents');
|
||||
const expanded = p.expanduser();
|
||||
expect(expanded.toString()).toBe(join(homedir(), 'Documents'));
|
||||
expect(expanded.isAbsolute()).toBe(true);
|
||||
});
|
||||
|
||||
it('should expand bare ~ to home directory', () => {
|
||||
const p = new KaosPath('~');
|
||||
const expanded = p.expanduser();
|
||||
expect(expanded.toString()).toBe(homedir());
|
||||
});
|
||||
|
||||
it('should not expand ~ in the middle of a path', () => {
|
||||
const p = new KaosPath('/usr/~test');
|
||||
const expanded = p.expanduser();
|
||||
expect(expanded.toString()).toBe('/usr/~test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical and relativeTo', () => {
|
||||
it('should resolve .. in an absolute path', () => {
|
||||
const p = new KaosPath('/usr/local/../bin');
|
||||
const c = p.canonical();
|
||||
expect(c.toString()).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('should make relative paths absolute using cwd', () => {
|
||||
const p = new KaosPath('foo/bar');
|
||||
const c = p.canonical();
|
||||
expect(c.isAbsolute()).toBe(true);
|
||||
expect(c.toString()).toBe(join(process.cwd(), 'foo/bar'));
|
||||
});
|
||||
|
||||
it('should resolve .. in a relative path against cwd', () => {
|
||||
// Pins the Python test_kaos_path.py::test_canonical_and_relative_to case:
|
||||
// a relative input that also contains '..' must first rebase to cwd and
|
||||
// then collapse the parent reference.
|
||||
const p = new KaosPath('nested/../file.txt').canonical();
|
||||
expect(p.isAbsolute()).toBe(true);
|
||||
expect(p.toString()).toBe(join(process.cwd(), 'file.txt'));
|
||||
});
|
||||
|
||||
it('should compute relativeTo correctly', () => {
|
||||
const child = new KaosPath('/usr/local/bin');
|
||||
const base = new KaosPath('/usr/local');
|
||||
const rel = child.relativeTo(base);
|
||||
expect(rel.toString()).toBe('bin');
|
||||
});
|
||||
|
||||
it('should return dot for identical paths', () => {
|
||||
const p = new KaosPath('/usr/local');
|
||||
expect(p.relativeTo(new KaosPath('/usr/local')).toString()).toBe('.');
|
||||
});
|
||||
|
||||
it('should reject sibling paths for relativeTo', () => {
|
||||
const a = new KaosPath('/usr/local/lib');
|
||||
const base = new KaosPath('/usr/local/bin');
|
||||
expect(() => a.relativeTo(base)).toThrow(/not within/);
|
||||
});
|
||||
|
||||
it('should reject when base is deeper than target', () => {
|
||||
// The target is strictly shorter than base — there is no way for it to
|
||||
// live "within" a deeper base. Hits the parts-length guard separately
|
||||
// from the loop-based prefix-mismatch guard.
|
||||
const a = new KaosPath('/usr');
|
||||
const base = new KaosPath('/usr/local/bin');
|
||||
expect(() => a.relativeTo(base)).toThrow(/not within/);
|
||||
});
|
||||
|
||||
it('should compute relativeTo between two relative paths (empty root)', () => {
|
||||
// Both operands have no absolute root, so splitPathLexically takes
|
||||
// the `root.length === 0 ? path : path.slice(root.length)` else branch.
|
||||
// Pinning this keeps the purely-relative relativeTo path honest.
|
||||
const child = new KaosPath('a/b/c');
|
||||
const base = new KaosPath('a/b');
|
||||
expect(child.relativeTo(base).toString()).toBe('c');
|
||||
});
|
||||
|
||||
it('should use win32 separators when the current kaos reports win32', async () => {
|
||||
// Build a minimal Kaos mock that claims win32 path semantics. All
|
||||
// paths produced by KaosPath must use forward slashes, even when the
|
||||
// underlying path class is win32.
|
||||
const winKaos: Kaos = {
|
||||
name: 'mock-win32',
|
||||
pathClass: () => 'win32',
|
||||
normpath: (p: string) => win32Path.normalize(p).replaceAll('\\', '/'),
|
||||
gethome: () => 'C:/Users/test',
|
||||
getcwd: () => 'C:/work/project',
|
||||
chdir: async () => {},
|
||||
stat: async () => ({
|
||||
stMode: 0,
|
||||
stIno: 0,
|
||||
stDev: 0,
|
||||
stNlink: 0,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: 0,
|
||||
stMtime: 0,
|
||||
stCtime: 0,
|
||||
}),
|
||||
iterdir: async function* () {},
|
||||
glob: async function* () {},
|
||||
readBytes: async () => Buffer.alloc(0),
|
||||
readText: async () => '',
|
||||
readLines: async function* () {},
|
||||
writeBytes: async () => 0,
|
||||
writeText: async () => 0,
|
||||
mkdir: async () => {},
|
||||
exec: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
execWithEnv: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
};
|
||||
|
||||
// Nested set so the outer beforeEach token is still restored cleanly
|
||||
// by afterEach; we explicitly reset the inner token here.
|
||||
const innerToken = setCurrentKaos(winKaos);
|
||||
try {
|
||||
const rel = new KaosPath('foo\\bar').canonical();
|
||||
// Resolved against 'C:/work/project' → 'C:/work/project/foo/bar'.
|
||||
expect(rel.toString()).toBe('C:/work/project/foo/bar');
|
||||
expect(rel.toString().includes('\\')).toBe(false);
|
||||
|
||||
const abs = new KaosPath('C:\\foo\\..\\bar').canonical();
|
||||
expect(abs.toString()).toBe('C:/bar');
|
||||
|
||||
expect(() => new KaosPath('D:\\logs').relativeTo(new KaosPath('C:\\work'))).toThrow(
|
||||
/not within/,
|
||||
);
|
||||
expect(
|
||||
new KaosPath('C:\\Work\\Project').relativeTo(new KaosPath('c:\\work')).toString(),
|
||||
).toBe('Project');
|
||||
|
||||
expect(new KaosPath('C:\\base').joinpath('D:\\logs').toString()).toBe('D:/logs');
|
||||
expect(new KaosPath('C:\\base').joinpath('\\rooted').toString()).toBe('C:/rooted');
|
||||
expect(new KaosPath('C:\\base').joinpath('C:relative').toString()).toBe(
|
||||
'C:/base/relative',
|
||||
);
|
||||
expect(new KaosPath('C:\\base').joinpath('D:relative').toString()).toBe('D:relative');
|
||||
expect(() => new KaosPath('D:relative').canonical()).toThrow(/drive-relative/);
|
||||
await expect(new KaosPath('D:relative').readText()).rejects.toThrow(/drive-relative/);
|
||||
} finally {
|
||||
resetCurrentKaos(innerToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists and file operations', () => {
|
||||
it('should reject I/O when the current kaos path class differs from the path', async () => {
|
||||
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
||||
const p = new KaosPath('C:\\work\\project\\file.txt');
|
||||
resetCurrentKaos(innerToken);
|
||||
|
||||
await expect(p.readText()).rejects.toThrow(/Cannot read win32 path/);
|
||||
await expect(p.stat()).rejects.toThrow(/Cannot stat win32 path/);
|
||||
await expect(p.exists()).rejects.toThrow(/Cannot check win32 path/);
|
||||
});
|
||||
|
||||
it('should detect file existence', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'hello.txt'));
|
||||
expect(await p.exists()).toBe(false);
|
||||
await p.writeText('hello');
|
||||
expect(await p.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should write and read text', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'test.txt'));
|
||||
await p.writeText('hello world');
|
||||
const text = await p.readText();
|
||||
expect(text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should identify files and directories', async () => {
|
||||
const f = new KaosPath(join(tmpDir, 'file.txt'));
|
||||
await f.writeText('content');
|
||||
expect(await f.isFile()).toBe(true);
|
||||
expect(await f.isDir()).toBe(false);
|
||||
|
||||
const d = new KaosPath(tmpDir);
|
||||
expect(await d.isDir()).toBe(true);
|
||||
expect(await d.isFile()).toBe(false);
|
||||
});
|
||||
|
||||
it('should append text', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'append.txt'));
|
||||
await p.writeText('hello');
|
||||
await p.appendText(' world');
|
||||
const text = await p.readText();
|
||||
expect(text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should create directories', async () => {
|
||||
const d = new KaosPath(join(tmpDir, 'a', 'b', 'c'));
|
||||
await d.mkdir({ parents: true });
|
||||
expect(await d.isDir()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle existOk for mkdir', async () => {
|
||||
const d = new KaosPath(tmpDir);
|
||||
await expect(d.mkdir({ existOk: true })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for isFile on a non-existent path', async () => {
|
||||
// exists()/isFile()/isDir() all swallow stat errors and return false
|
||||
// for missing paths. Pin that behavior so a regression does not
|
||||
// silently start throwing on absent files.
|
||||
const p = new KaosPath(join(tmpDir, 'does-not-exist.txt'));
|
||||
expect(await p.isFile()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for isDir on a non-existent path', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'also-missing'));
|
||||
expect(await p.isDir()).toBe(false);
|
||||
});
|
||||
|
||||
it('should append text with an explicit encoding option', async () => {
|
||||
// appendText accepts an encoding option that threads through to
|
||||
// kaos.writeText({ mode: 'a', encoding }). Exercising the explicit
|
||||
// encoding branch keeps the option wiring honest.
|
||||
const p = new KaosPath(join(tmpDir, 'enc-append.txt'));
|
||||
await p.writeText('head-');
|
||||
await p.appendText('tail', { encoding: 'utf-8' });
|
||||
expect(await p.readText()).toBe('head-tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('iterdir and glob', () => {
|
||||
it('should iterate directory entries', async () => {
|
||||
await new KaosPath(join(tmpDir, 'a.txt')).writeText('a');
|
||||
await new KaosPath(join(tmpDir, 'b.txt')).writeText('b');
|
||||
await new KaosPath(join(tmpDir, 'c.md')).writeText('c');
|
||||
|
||||
const entries: string[] = [];
|
||||
const dir = new KaosPath(tmpDir);
|
||||
for await (const entry of dir.iterdir()) {
|
||||
entries.push(entry.name);
|
||||
}
|
||||
expect([...entries].toSorted()).toEqual(['a.txt', 'b.txt', 'c.md']);
|
||||
});
|
||||
|
||||
it('should glob for matching files', async () => {
|
||||
await new KaosPath(join(tmpDir, 'foo.txt')).writeText('foo');
|
||||
await new KaosPath(join(tmpDir, 'bar.txt')).writeText('bar');
|
||||
await new KaosPath(join(tmpDir, 'baz.md')).writeText('baz');
|
||||
|
||||
const matches: string[] = [];
|
||||
const dir = new KaosPath(tmpDir);
|
||||
for await (const entry of dir.glob('*.txt')) {
|
||||
matches.push(entry.name);
|
||||
}
|
||||
expect([...matches].toSorted()).toEqual(['bar.txt', 'foo.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read and write bytes', () => {
|
||||
it('should write and read binary data', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'data.bin'));
|
||||
const data = Buffer.from([0x00, 0x01, 0x02, 0xfe, 0xff]);
|
||||
const written = await p.writeBytes(data);
|
||||
expect(written).toBe(5);
|
||||
|
||||
const read = await p.readBytes();
|
||||
expect(Buffer.compare(read, data)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLines', () => {
|
||||
it('should yield lines from a file', async () => {
|
||||
const p = new KaosPath(join(tmpDir, 'lines.txt'));
|
||||
await p.writeText('line1\nline2\nline3');
|
||||
|
||||
const lines: string[] = [];
|
||||
for await (const line of p.readLines()) {
|
||||
lines.push(line);
|
||||
}
|
||||
// readLines yields lines with trailing \n except for the last line
|
||||
expect(lines).toEqual(['line1\n', 'line2\n', 'line3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromLocalPath and toLocalPath', () => {
|
||||
it('should round-trip a path string', () => {
|
||||
const original = '/usr/local/bin/node';
|
||||
const p = KaosPath.fromLocalPath(original);
|
||||
expect(p.toLocalPath()).toBe(original);
|
||||
expect(p.toString()).toBe(original);
|
||||
});
|
||||
|
||||
it('should return backslashes for win32 toLocalPath', () => {
|
||||
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
||||
try {
|
||||
const p = new KaosPath('C:/Users/test/file.txt');
|
||||
expect(p.toLocalPath()).toBe('C:\\Users\\test\\file.txt');
|
||||
expect(p.toString()).toBe('C:/Users/test/file.txt');
|
||||
} finally {
|
||||
resetCurrentKaos(innerToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('returns true for two KaosPath instances with the same path string', () => {
|
||||
const a = new KaosPath('/foo/bar');
|
||||
const b = new KaosPath('/foo/bar');
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different path strings', () => {
|
||||
const a = new KaosPath('/foo/bar');
|
||||
const b = new KaosPath('/foo/baz');
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true after joining equivalent segments', () => {
|
||||
const a = new KaosPath('/foo').div('bar');
|
||||
const b = new KaosPath('/foo/bar');
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
const a = new KaosPath('/a/b/c');
|
||||
const b = new KaosPath('/a/b/c');
|
||||
expect(a.equals(b)).toBe(b.equals(a));
|
||||
});
|
||||
|
||||
it('returns false for the same string with different path classes', () => {
|
||||
const posixPath = new KaosPath('C:\\workspace');
|
||||
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
||||
try {
|
||||
const winPath = new KaosPath('C:\\workspace');
|
||||
// Both path classes normalise backslashes to forward slashes;
|
||||
// they differ only in pathClass, so equals is still false.
|
||||
expect(posixPath.toString()).toBe('C:/workspace');
|
||||
expect(winPath.toString()).toBe('C:/workspace');
|
||||
expect(posixPath.equals(winPath)).toBe(false);
|
||||
} finally {
|
||||
resetCurrentKaos(innerToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects div() with a KaosPath from a different path class', () => {
|
||||
const posixPath = new KaosPath('/workspace');
|
||||
const innerToken = setCurrentKaos(makeMockKaos('win32'));
|
||||
try {
|
||||
const winPath = new KaosPath('C:\\workspace');
|
||||
expect(() => posixPath.div(winPath)).toThrow(/Cannot join win32 path to posix path/);
|
||||
} finally {
|
||||
resetCurrentKaos(innerToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
14
packages/kaos/test/setup.ts
Normal file
14
packages/kaos/test/setup.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -4,8 +4,6 @@ import { join } from 'node:path';
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resetCurrentKaos, setCurrentKaos } from '#/current';
|
||||
import type { KaosToken } from '#/current';
|
||||
import type { Kaos } from '#/kaos';
|
||||
import { LocalKaos } from '#/local';
|
||||
import type { KaosProcess } from '#/process';
|
||||
|
|
@ -79,17 +77,14 @@ async function runSh(
|
|||
|
||||
describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => {
|
||||
let kaos: Kaos;
|
||||
let token: KaosToken;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-'));
|
||||
kaos = new LocalKaos();
|
||||
token = setCurrentKaos(kaos);
|
||||
kaos = await LocalKaos.create();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetCurrentKaos(token);
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { resetCurrentKaos, setCurrentKaos } from '#/current';
|
||||
import type { KaosToken } from '#/current';
|
||||
import { KaosFileExistsError, KaosValueError } from '#/errors';
|
||||
import { KaosPath } from '#/path';
|
||||
import {
|
||||
KaosConnectionError,
|
||||
KaosFileNotFoundError,
|
||||
|
|
@ -40,7 +37,6 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|||
describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => {
|
||||
let sshKaos: SSHKaos;
|
||||
let remoteBase = '';
|
||||
let token: KaosToken | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (SSH_USERNAME === undefined) {
|
||||
|
|
@ -68,10 +64,6 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (token !== undefined) {
|
||||
resetCurrentKaos(token);
|
||||
token = undefined;
|
||||
}
|
||||
// Cleanup the remote directory best-effort, but always restore cwd.
|
||||
if (remoteBase.length > 0) {
|
||||
try {
|
||||
|
|
@ -169,8 +161,7 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
|
|||
expect(fileStat.stNlink).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('KaosPath roundtrip via SSH', async () => {
|
||||
token = setCurrentKaos(sshKaos);
|
||||
test('file roundtrip via SSH', async () => {
|
||||
await sshKaos.chdir(remoteBase);
|
||||
|
||||
const textPath = remoteBase + '/text.txt';
|
||||
|
|
@ -200,7 +191,7 @@ describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', ()
|
|||
const roundtrip = await sshKaos.readBytes(bytesPath);
|
||||
expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0);
|
||||
|
||||
expect(KaosPath.cwd().toString()).toBe(remoteBase);
|
||||
expect(sshKaos.getcwd()).toBe(remoteBase);
|
||||
});
|
||||
|
||||
test('iterdir lists child entries', async () => {
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ export default defineConfig({
|
|||
test: {
|
||||
name: 'kaos',
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,19 +35,11 @@ import {
|
|||
normalizeWorkDir,
|
||||
} from '@moonshot-ai/agent-core/session/store';
|
||||
import { Session, type SDKSessionRPC } from '@moonshot-ai/agent-core';
|
||||
import { localKaos } from '@moonshot-ai/kaos';
|
||||
import { LocalKaos } from '@moonshot-ai/kaos';
|
||||
|
||||
import { migrateOneSession, type MigrateOneResult } from '../src/sessions/migrate-one.js';
|
||||
import { computeWorkdirBucket } from '../src/sessions/workdir-bucket.js';
|
||||
|
||||
const TEST_OS_ENV = {
|
||||
osKind: 'Linux',
|
||||
osArch: 'arm64',
|
||||
osVersion: 'test',
|
||||
shellPath: '/bin/bash',
|
||||
shellName: 'bash',
|
||||
} as const;
|
||||
|
||||
function createSessionRpc(): SDKSessionRPC {
|
||||
return {
|
||||
emitEvent: vi.fn(async () => {}),
|
||||
|
|
@ -134,10 +126,9 @@ describe('migrated session loads in real kimi-core', () => {
|
|||
// If `agents.main.homedir` were the project workdir (the bug), the agent
|
||||
// would replay an absent file and the history would be empty.
|
||||
const session = new Session({
|
||||
runtime: { kaos: localKaos, osEnv: TEST_OS_ENV },
|
||||
runtime: { kaos: (await LocalKaos.create()).withCwd(WORK_DIR) },
|
||||
id: 'ses_tiny-resume',
|
||||
homedir: targetDir,
|
||||
cwd: WORK_DIR,
|
||||
rpc: createSessionRpc(),
|
||||
initializeMainAgent: false,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue