diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdf2a2e..5bda1bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,27 @@ jobs: - name: Run tests working-directory: wasm-sandboxes/python run: pnpm vitest run + + bash-core-tests: + name: Bash Core Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + working-directory: packages/bash + run: pnpm vitest run + diff --git a/package.json b/package.json index d5dd4f2..676f280 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "@capsule-run/cli": "^0.8.5", - "@capsule-run/sdk": "^0.8.5" + "@capsule-run/cli": "^0.8.6", + "@capsule-run/sdk": "^0.8.6" }, "devDependencies": { "esbuild": "^0.28.0", diff --git a/packages/bash-types/src/command.ts b/packages/bash-types/src/command.ts index b50d499..8860055 100644 --- a/packages/bash-types/src/command.ts +++ b/packages/bash-types/src/command.ts @@ -5,7 +5,7 @@ import { State } from "./state"; * The context of a command execution */ export type CommandContext = { - args: string[]; + opts: CommandOptions; stdin: string; state: State; runtime: BaseRuntime; @@ -26,3 +26,24 @@ export type CommandResult = { }; +/** + * The options of a command execution + */ +export type CommandOptions = { + raw: string[]; + flags: Set; + options: Map; + positionals: string[]; + hasFlag: (...names: string[]) => boolean; +}; + +/** + * The manual of a command + */ +export type CommandManual = { + name: string; + description: string; + usage: string; + options?: Record; +}; + diff --git a/packages/bash-types/src/index.ts b/packages/bash-types/src/index.ts index 7f9d67a..0aa52f6 100644 --- a/packages/bash-types/src/index.ts +++ b/packages/bash-types/src/index.ts @@ -1,4 +1,4 @@ export { State } from "./state"; export { BaseRuntime, RuntimeResult } from "./runtime"; export { BashOptions } from "./bash"; -export { CommandResult, CommandHandler, CommandContext } from "./command"; +export { CommandResult, CommandHandler, CommandContext, CommandOptions, CommandManual } from "./command"; diff --git a/packages/bash-types/src/state.ts b/packages/bash-types/src/state.ts index 48aef48..22c5c93 100644 --- a/packages/bash-types/src/state.ts +++ b/packages/bash-types/src/state.ts @@ -17,6 +17,16 @@ export interface State { */ lastExitCode: number; + /** + * Get the absolute path of the current working directory + */ + absoluteCwd(): string; + + /** + * Change the current working directory + */ + changeDirectory(targetPath: string): Promise; + /** * Set the exit code of the last executed command */ diff --git a/packages/bash-wasm/package.json b/packages/bash-wasm/package.json index d3fdd58..93cd10f 100644 --- a/packages/bash-wasm/package.json +++ b/packages/bash-wasm/package.json @@ -12,8 +12,8 @@ "tsup": "^8.0.0" }, "dependencies": { - "@capsule-run/cli": "^0.8.5", - "@capsule-run/sdk": "^0.8.5", + "@capsule-run/cli": "^0.8.6", + "@capsule-run/sdk": "^0.8.6", "@capsule-run/bash-types": "workspace:*" } } diff --git a/packages/bash/src/commands/cd/cd.test.ts b/packages/bash/src/commands/cd/cd.test.ts new file mode 100644 index 0000000..8bfabf3 --- /dev/null +++ b/packages/bash/src/commands/cd/cd.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from 'vitest'; +import { handler } from './handler'; +import { createMockContext } from '../../helpers/testUtils'; + +describe('cd command', () => { + it('should fall back to /workspace if no path is provided', async () => { + const changeDirectoryMock = vi.fn().mockResolvedValue(true); + const ctx = createMockContext([], { changeDirectory: changeDirectoryMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(0); + expect(changeDirectoryMock).toHaveBeenCalledWith('/workspace'); + }); + + it('should return error if directory does not exist', async () => { + const changeDirectoryMock = vi.fn().mockResolvedValue(false); + const ctx = createMockContext(['/fake-dir'], { changeDirectory: changeDirectoryMock }); + + const result = await handler(ctx); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No such file or directory'); + expect(changeDirectoryMock).toHaveBeenCalledWith('/fake-dir'); + }); +}); + diff --git a/packages/bash/src/commands/cd/handler.ts b/packages/bash/src/commands/cd/handler.ts new file mode 100644 index 0000000..46430f4 --- /dev/null +++ b/packages/bash/src/commands/cd/handler.ts @@ -0,0 +1,28 @@ +import type { CommandContext, CommandHandler, CommandManual } from "@capsule-run/bash-types"; + +export const manual: CommandManual = { + name: "cd", + description: "Change the working directory.", + usage: "cd [-L] [dir]", + options: { + "-L": "force symbolic links to be followed" + } +}; + +export const handler: CommandHandler = async ({ opts, state }: CommandContext) => { + + if(opts.hasFlag('L')) { /* no particular behavior for now */ } + + let targetPath = "/workspace"; + if(opts.positionals[0]) { + targetPath = opts.positionals[0]; + } + + const success = await state.changeDirectory(targetPath); + + if (!success) { + return { stdout: '', stderr: `bash: cd: ${targetPath}: No such file or directory`, exitCode: 1 }; + } + + return { stdout: '', stderr: '', exitCode: 0 }; +}; diff --git a/packages/bash/src/core/executor.ts b/packages/bash/src/core/executor.ts index 84ade9b..3baa7e1 100644 --- a/packages/bash/src/core/executor.ts +++ b/packages/bash/src/core/executor.ts @@ -1,7 +1,11 @@ import path from 'path'; -import type { BaseRuntime, CommandHandler, CommandResult, State } from '@capsule-run/bash-types'; +import { parsedCommandOptions } from '../helpers/commandOptions'; +import { displayCommandManual } from '../helpers/commandManual'; + +import type { BaseRuntime, CommandHandler, CommandManual, CommandResult, State } from '@capsule-run/bash-types'; import type { ASTNode, CommandNode } from './parser'; + export class Executor { constructor( @@ -21,6 +25,7 @@ export class Executor { private async executeCommand(node: CommandNode, stdin: string): Promise { const [name, ...args] = node.args; + let result: CommandResult; for (const r of node.redirects) { if (r.op === '<') { @@ -41,13 +46,15 @@ export class Executor { } } - const handler = await this.searchCommandHandler(name); - let result: CommandResult; + const opts = parsedCommandOptions(args); + const command = await this.searchCommandHandler(name); - if (handler) { - result = await handler({ args, stdin, state: this.state, runtime: this.runtime }); - } else { + if (!command) { result = { stdout: '', stderr: `bash: ${name}: command not found`, exitCode: 127 }; + } else if (opts.hasFlag('h', 'help') && command.manual) { + result = { stdout: displayCommandManual(command.manual), stderr: '', exitCode: 0 }; + } else { + result = await command.handler({ opts, stdin, state: this.state, runtime: this.runtime }); } let currentStdout = result.stdout; @@ -59,11 +66,11 @@ export class Executor { currentStdout = ''; continue; } - + if (r.file === '/dev/stdout') { continue; } - + if (r.file === '/dev/stderr') { currentStderr += currentStdout; currentStdout = ''; @@ -127,13 +134,13 @@ export class Executor { return this.execute(node.right); } - private async searchCommandHandler(name: string): Promise { + private async searchCommandHandler(name: string): Promise<{handler: CommandHandler, manual?: CommandManual} | undefined> { const commandsDir = path.resolve(__dirname, '../commands'); const handlerPath = path.join(commandsDir, name, 'handler'); try { const mod = require(handlerPath); - return mod.handle as CommandHandler; + return { handler: mod.handler as CommandHandler, manual: mod.manual as CommandManual }; } catch { return undefined; } diff --git a/packages/bash/src/core/stateManager.ts b/packages/bash/src/core/stateManager.ts index 29cb961..73bdff7 100644 --- a/packages/bash/src/core/stateManager.ts +++ b/packages/bash/src/core/stateManager.ts @@ -8,29 +8,25 @@ export class StateManager { cwd: initialCwd, env: {}, lastExitCode: 0, + absoluteCwd: () => this.state.cwd.startsWith('/') ? this.state.cwd : `/${this.state.cwd}`, setLastExitCode: (code: number) => { this.state.lastExitCode = code; }, setEnv: (key: string, value: string) => { this.state.env[key] = value; + }, + changeDirectory: async (targetPath: string) => { + try { + const resolvedPath = await this.runtime.resolvePath(this.state, targetPath); + this.state.cwd = resolvedPath; + return true; + } catch { + return false; + } } }; } - get displayCwd(): string { - return this.state.cwd.startsWith('/') ? this.state.cwd : `/${this.state.cwd}`; - } - - public async changeDirectory(targetPath: string): Promise { - try { - const resolvedPath = await this.runtime.resolvePath(this.state, targetPath); - this.state.cwd = resolvedPath; - return true; - } catch { - return false; - } - } - public reset() { this.state.cwd = 'workspace'; this.state.env = {}; diff --git a/packages/bash/src/helpers/commandManual.ts b/packages/bash/src/helpers/commandManual.ts new file mode 100644 index 0000000..99f3d27 --- /dev/null +++ b/packages/bash/src/helpers/commandManual.ts @@ -0,0 +1,10 @@ +import type { CommandManual } from "@capsule-run/bash-types"; + +export function displayCommandManual(command: CommandManual): string { + const helpText = `NAME:\n${command.name}\n\nUSAGE:\n${command.usage}\n\nDESCRIPTION:\n${command.description}\n\nOPTIONS:\n` + + Object.entries(command.options || {}) + .map(([flag, desc]) => ` ${flag.padEnd(5)} ${desc}`) + .join('\n'); + + return helpText + '\n'; +} diff --git a/packages/bash/src/helpers/commandOptions.ts b/packages/bash/src/helpers/commandOptions.ts new file mode 100644 index 0000000..f3ed75f --- /dev/null +++ b/packages/bash/src/helpers/commandOptions.ts @@ -0,0 +1,41 @@ +import type { CommandOptions } from "@capsule-run/bash-types"; + +export function parsedCommandOptions(args: string[]): CommandOptions { + const flags: Set = new Set(); + const options: Map = new Map(); + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg.startsWith('--') && arg.includes('=')) { + const [key, ...val] = arg.slice(2).split('='); + options.set(key, val.join('=')); + } + else if (arg.startsWith('--')) { + const key = arg.slice(2); + if (args[i + 1] && !args[i + 1].startsWith('-')) { + options.set(key, args[++i]); + } else { + flags.add(key); + } + } + else if (arg.startsWith('-') && arg.length > 1) { + for (const char of arg.slice(1)) { + flags.add(char); + } + } + + else { + positionals.push(arg); + } + } + + return { + raw: args, + flags, + options, + positionals, + hasFlag: (...names: string[]) => names.some(name => flags.has(name)) + }; +} diff --git a/packages/bash/src/helpers/testUtils.ts b/packages/bash/src/helpers/testUtils.ts new file mode 100644 index 0000000..bb30b1a --- /dev/null +++ b/packages/bash/src/helpers/testUtils.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest'; +import type { CommandContext, State, BaseRuntime } from '@capsule-run/bash-types'; +import { parsedCommandOptions } from './commandOptions'; + +export function createMockContext( + args: string[] = [], + stateOverrides: Partial = {}, + runtimeOverrides: Partial = {} +): CommandContext { + return { + opts: parsedCommandOptions(args), + + state: { + cwd: '/workspace', + changeDirectory: vi.fn().mockResolvedValue(true), + lastExitCode: 0, + setLastExitCode: vi.fn(), + env: {}, + setEnv: vi.fn(), + absoluteCwd: vi.fn().mockReturnValue('/workspace'), + ...stateOverrides + } as unknown as State, + + runtime: { + executeCode: vi.fn().mockResolvedValue(''), + resolvePath: vi.fn().mockResolvedValue('/workspace'), + ...runtimeOverrides + } as unknown as BaseRuntime, + + stdin: '' + }; +} diff --git a/packages/bash/vitest.config.ts b/packages/bash/vitest.config.ts index fe357a5..e5982de 100644 --- a/packages/bash/vitest.config.ts +++ b/packages/bash/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { name: 'bash', environment: 'node', - // setupFiles: ['./test/setup.ts'], + include: ['src/**/*.test.ts'], }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 822b2e9..95172d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@capsule-run/cli': - specifier: ^0.8.5 - version: 0.8.5 + specifier: ^0.8.6 + version: 0.8.6 '@capsule-run/sdk': - specifier: ^0.8.5 - version: 0.8.5(@types/node@25.6.0) + specifier: ^0.8.6 + version: 0.8.6(@types/node@25.6.0) devDependencies: '@types/node': specifier: ^25.2.3 @@ -56,11 +56,11 @@ importers: specifier: workspace:* version: link:../bash-types '@capsule-run/cli': - specifier: ^0.8.5 - version: 0.8.5 + specifier: ^0.8.6 + version: 0.8.6 '@capsule-run/sdk': - specifier: ^0.8.5 - version: 0.8.5(@types/node@25.6.0) + specifier: ^0.8.6 + version: 0.8.6(@types/node@25.6.0) devDependencies: tsup: specifier: ^8.0.0 @@ -72,11 +72,11 @@ importers: specifier: workspace:* version: link:../../packages/bash-types '@capsule-run/cli': - specifier: ^0.8.5 - version: 0.8.5 + specifier: ^0.8.6 + version: 0.8.6 '@capsule-run/sdk': - specifier: ^0.8.5 - version: 0.8.5(@types/node@25.2.3) + specifier: ^0.8.6 + version: 0.8.6(@types/node@25.2.3) devDependencies: '@types/node': specifier: 25.2.3 @@ -89,7 +89,7 @@ importers: devDependencies: '@capsule-run/sdk': specifier: ^0.8.4 - version: 0.8.5(@types/node@25.2.3) + version: 0.8.6(@types/node@25.2.3) '@types/node': specifier: 25.2.3 version: 25.2.3 @@ -151,37 +151,37 @@ packages: engines: {node: '>=16'} hasBin: true - '@capsule-run/cli-darwin-arm64@0.8.5': - resolution: {integrity: sha512-UrkHN9V4X9h8h/ftjLZ8XIMChgELm/g8xBjQiRecCBGpBVH/RIvPt0WbikpJmRxsXnLyuUbFg+nfJyhiKNOsXQ==} + '@capsule-run/cli-darwin-arm64@0.8.6': + resolution: {integrity: sha512-ZIYvT/LqRGUi1TiV3C+yQ61KhfUWcyv7bGJujMgUczQ2klIRPVHMEArZItHMugUJRBZ7xoU7qSeidmyBRd1D3A==} cpu: [arm64] os: [darwin] hasBin: true - '@capsule-run/cli-darwin-x64@0.8.5': - resolution: {integrity: sha512-WOQlrc+i+e//3TAT1/l8hXQEhpjE2Ey7zCWUL/MO+go65i03o83RyHkr7D7YalfRyxe9GuzzXGXvxxlMownnbQ==} + '@capsule-run/cli-darwin-x64@0.8.6': + resolution: {integrity: sha512-LKStnHeNSEXu1Yfh/6mZlQ1kWsb8+bEca3dYk4WcXe82YuKS+moA2A4BfMdHBh/FKRkmqbjwd8vMyZdUA7SvvQ==} cpu: [x64] os: [darwin] hasBin: true - '@capsule-run/cli-linux-x64@0.8.5': - resolution: {integrity: sha512-HTyMg2G7UyZwwDQ6dtqepGqb9NHM+IH5ZglK86J96KE5kXFB55h1eYg6BQu3vF6/8BXcsK8RqtAeFUxtOsGpxQ==} + '@capsule-run/cli-linux-x64@0.8.6': + resolution: {integrity: sha512-qqQb0qje8U6ZTe+tUfIiYIXykbrrOAcGg8D/4A34MXdi7PzQB/ShcKagSiXzoPG26cue6kD5ELcvsKdWLWLY8A==} cpu: [x64] os: [linux] hasBin: true - '@capsule-run/cli-win32-x64@0.8.5': - resolution: {integrity: sha512-p1ZeiqMSRMhj86CZkGmZAi4ymtmGRfI2p8EdOTNsjmQZrf/PMb1gmtFc5rli500jNFA8iwWCQGg+AQf+Mslg/w==} + '@capsule-run/cli-win32-x64@0.8.6': + resolution: {integrity: sha512-s6cZ5aKiRNAeXTfxFelErMDm6v4vMJUFPImTLJ/235F8mjeIEKmmGSJzuPmaT2oGpVdzo/4tMi7FwOJstTS97A==} cpu: [x64] os: [win32] hasBin: true - '@capsule-run/cli@0.8.5': - resolution: {integrity: sha512-MPa+9PNsrZ9aV1356GHP6X93ehD8XxQRnPncFw8Xp9BDd2O6q3bdtFf9ZOPHXbktrpb33Fn8/lVQ9m6tKPGJmg==} + '@capsule-run/cli@0.8.6': + resolution: {integrity: sha512-E2tVDGquvPBAiq0bs7fUdpDKz7j2WMmHpUWT4NGEFm2F1S6la3GT+DqY0et0Q7BP3ennJGPiHgTXIuH0rLzKpA==} engines: {node: '>=18'} hasBin: true - '@capsule-run/sdk@0.8.5': - resolution: {integrity: sha512-Z8RwcuL3EH5MldZ+3WXd8/0HEDHt758Mem6mB0TQbDF4psoETGtVAqDioyYrTjxtKzGuAN/yiIWcVYv8KyNt6A==} + '@capsule-run/sdk@0.8.6': + resolution: {integrity: sha512-vVwP7TvRWrbUdvnscEZ2a3ecXM1m1UUxfmr0InIZ3PGb9o0vbVIti0kutFeAIfNlvDXM2P0GS7j5br/U5DlIcA==} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1507,26 +1507,26 @@ snapshots: '@bytecodealliance/wizer-linux-x64': 10.0.0 '@bytecodealliance/wizer-win32-x64': 10.0.0 - '@capsule-run/cli-darwin-arm64@0.8.5': + '@capsule-run/cli-darwin-arm64@0.8.6': optional: true - '@capsule-run/cli-darwin-x64@0.8.5': + '@capsule-run/cli-darwin-x64@0.8.6': optional: true - '@capsule-run/cli-linux-x64@0.8.5': + '@capsule-run/cli-linux-x64@0.8.6': optional: true - '@capsule-run/cli-win32-x64@0.8.5': + '@capsule-run/cli-win32-x64@0.8.6': optional: true - '@capsule-run/cli@0.8.5': + '@capsule-run/cli@0.8.6': optionalDependencies: - '@capsule-run/cli-darwin-arm64': 0.8.5 - '@capsule-run/cli-darwin-x64': 0.8.5 - '@capsule-run/cli-linux-x64': 0.8.5 - '@capsule-run/cli-win32-x64': 0.8.5 + '@capsule-run/cli-darwin-arm64': 0.8.6 + '@capsule-run/cli-darwin-x64': 0.8.6 + '@capsule-run/cli-linux-x64': 0.8.6 + '@capsule-run/cli-win32-x64': 0.8.6 - '@capsule-run/sdk@0.8.5(@types/node@25.2.3)': + '@capsule-run/sdk@0.8.6(@types/node@25.2.3)': dependencies: '@bytecodealliance/jco': 1.17.6 esbuild: 0.27.7 @@ -1535,7 +1535,7 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 - '@capsule-run/sdk@0.8.5(@types/node@25.6.0)': + '@capsule-run/sdk@0.8.6(@types/node@25.6.0)': dependencies: '@bytecodealliance/jco': 1.17.6 esbuild: 0.27.7 diff --git a/wasm-sandboxes/js/__test__/sandbox.test.ts b/wasm-sandboxes/js/__test__/sandbox.test.ts index 72af8ae..0a4635a 100644 --- a/wasm-sandboxes/js/__test__/sandbox.test.ts +++ b/wasm-sandboxes/js/__test__/sandbox.test.ts @@ -7,7 +7,7 @@ const SANDBOX = path.resolve(__dirname, '../sandbox.ts'); const WORKSPACE = '__test__/workspace'; const baseState = JSON.stringify({ - cwd: '.', + cwd: '/', env: {}, lastExitCode: 0, }); @@ -126,7 +126,7 @@ describe('sandbox.ts – RESOLVE_PATH', () => { }); const value = assertSuccess(result); - expect(value).toBe('imports'); + expect(value).toBe('/imports'); }); it('Should return an error because the directory path does not exist', async () => { @@ -143,23 +143,23 @@ describe('sandbox.ts – RESOLVE_PATH', () => { it('resolves a directory path', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_PATH', baseState, 'imports/../imports/complex-path-testing'], + args: ['RESOLVE_PATH', baseState, '/imports/../imports/complex-path-testing'], mounts: [`${WORKSPACE}::/`], }); const value = assertSuccess(result); - expect(value).toBe('imports/complex-path-testing'); + expect(value).toBe('/imports/complex-path-testing'); }); it('Should works with a different initial cwd', async () => { const result = await run({ file: SANDBOX, - args: ['RESOLVE_PATH', JSON.stringify({ cwd: 'imports', env: {}, lastExitCode: 0 }), 'complex-path-testing'], + args: ['RESOLVE_PATH', JSON.stringify({ cwd: '/imports', env: {}, lastExitCode: 0 }), 'complex-path-testing'], mounts: [`${WORKSPACE}::/`], }); const value = assertSuccess(result); - expect(value).toBe('imports/complex-path-testing'); + expect(value).toBe('/imports/complex-path-testing'); }); it('Should works with a file path', async () => { @@ -170,6 +170,20 @@ describe('sandbox.ts – RESOLVE_PATH', () => { }); const value = assertSuccess(result); - expect(value).toBe('test-file.js'); + expect(value).toBe('/test-file.js'); }); + + + it('Should works resolve path for absolute path', async () => { + const result = await run({ + file: SANDBOX, + args: ['RESOLVE_PATH', JSON.stringify({ cwd: '/imports/complex-path-testing/', env: {}, lastExitCode: 0 }), '/imports'], + mounts: [`${WORKSPACE}::/`], + }); + + const value = assertSuccess(result); + expect(value).toBe('/imports'); + }); + + }); diff --git a/wasm-sandboxes/js/package.json b/wasm-sandboxes/js/package.json index f763742..a94a9af 100644 --- a/wasm-sandboxes/js/package.json +++ b/wasm-sandboxes/js/package.json @@ -11,8 +11,8 @@ "@types/node": "25.2.3" }, "dependencies": { - "@capsule-run/cli": "^0.8.5", - "@capsule-run/sdk": "^0.8.5", + "@capsule-run/cli": "^0.8.6", + "@capsule-run/sdk": "^0.8.6", "@capsule-run/bash-types": "workspace:*" } }