Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 22 additions & 1 deletion packages/bash-types/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,3 +26,24 @@ export type CommandResult = {
};


/**
* The options of a command execution
*/
export type CommandOptions = {
raw: string[];
flags: Set<string>;
options: Map<string, string>;
positionals: string[];
hasFlag: (...names: string[]) => boolean;
};

/**
* The manual of a command
*/
export type CommandManual = {
name: string;
description: string;
usage: string;
options?: Record<string, string>;
};

2 changes: 1 addition & 1 deletion packages/bash-types/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
10 changes: 10 additions & 0 deletions packages/bash-types/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;

/**
* Set the exit code of the last executed command
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/bash-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
27 changes: 27 additions & 0 deletions packages/bash/src/commands/cd/cd.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});

28 changes: 28 additions & 0 deletions packages/bash/src/commands/cd/handler.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
27 changes: 17 additions & 10 deletions packages/bash/src/core/executor.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -21,6 +25,7 @@ export class Executor {

private async executeCommand(node: CommandNode, stdin: string): Promise<CommandResult> {
const [name, ...args] = node.args;
let result: CommandResult;

for (const r of node.redirects) {
if (r.op === '<') {
Expand All @@ -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;
Expand All @@ -59,11 +66,11 @@ export class Executor {
currentStdout = '';
continue;
}

if (r.file === '/dev/stdout') {
continue;
}

if (r.file === '/dev/stderr') {
currentStderr += currentStdout;
currentStdout = '';
Expand Down Expand Up @@ -127,13 +134,13 @@ export class Executor {
return this.execute(node.right);
}

private async searchCommandHandler(name: string): Promise<CommandHandler | undefined> {
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;
}
Expand Down
24 changes: 10 additions & 14 deletions packages/bash/src/core/stateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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 = {};
Expand Down
10 changes: 10 additions & 0 deletions packages/bash/src/helpers/commandManual.ts
Original file line number Diff line number Diff line change
@@ -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';
}
41 changes: 41 additions & 0 deletions packages/bash/src/helpers/commandOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { CommandOptions } from "@capsule-run/bash-types";

export function parsedCommandOptions(args: string[]): CommandOptions {
const flags: Set<string> = new Set();
const options: Map<string, string> = 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))
};
}
32 changes: 32 additions & 0 deletions packages/bash/src/helpers/testUtils.ts
Original file line number Diff line number Diff line change
@@ -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<State> = {},
runtimeOverrides: Partial<BaseRuntime> = {}
): 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: ''
};
}
2 changes: 1 addition & 1 deletion packages/bash/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export default defineConfig({
test: {
name: 'bash',
environment: 'node',
// setupFiles: ['./test/setup.ts'],
include: ['src/**/*.test.ts'],
},
})
Loading
Loading