diff --git a/README.md b/README.md index a5bb3e0..f08b9e2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ These examples mount Mesa repos inside cloud sandboxes using the Mesa CLI. The C | [blaxel-shell](blaxel-shell/) | Interactive shell in a Blaxel sandbox | | [e2b-shell](e2b-shell/) | Interactive shell in an E2B sandbox | | [sprites-shell](sprites-shell/) | Interactive shell in a Sprites sandbox | +| [superserve-shell](superserve-shell/) | Interactive shell in a Superserve sandbox | ## Other diff --git a/superserve-shell/.env.example b/superserve-shell/.env.example new file mode 100644 index 0000000..6df5448 --- /dev/null +++ b/superserve-shell/.env.example @@ -0,0 +1,4 @@ +MESA_ORG= +MESA_API_KEY= +# Get one at https://console.superserve.ai/ +SUPERSERVE_API_KEY= diff --git a/superserve-shell/README.md b/superserve-shell/README.md new file mode 100644 index 0000000..5208109 --- /dev/null +++ b/superserve-shell/README.md @@ -0,0 +1,66 @@ +# superserve-shell + +Interactive shell over repos in a [Mesa](https://mesa.dev) org running inside a [Superserve](https://superserve.ai) sandbox, written in TypeScript. + +Spins up a Superserve sandbox (Firecracker microVM, sub-second cold start), installs the Mesa CLI, mounts your org's repos via FUSE, and drops you into a minimal shell. Commands execute inside the sandbox against the mounted filesystem. + +## Quick start + +```bash +npm install + +# Create a .env in this directory (gitignored) +cp .env.example .env + +# Now populate your .env file with the required values + +# Run +npm start +``` + +``` +Creating Superserve sandbox... +Installing Mesa... +Mounting your-org... +Connected to ~/.local/share/mesa/mnt/your-org. Type "exit" or Ctrl+C to quit. + +$ ls +repo-one repo-two repo-three +$ cd repo-one +$ ls +README.md src/ package.json +$ exit +Cleaning up sandbox... +``` + +## How it works + +1. Creates a Superserve sandbox from the default `superserve/base` template +2. Installs the [Mesa CLI](https://docs.mesa.dev/content/virtual-filesystem/os-level) inside the sandbox +3. Runs `mesa mount -d -y` with your org and API key to start the FUSE daemon +4. Drops you into a REPL where commands run inside the sandbox via `sandbox.commands.run()` + +Superserve sandboxes are full Firecracker microVMs running as root with a FUSE-enabled kernel, so the `user_allow_other` and `chmod 666 /dev/fuse` steps that other sandbox providers require aren't needed. + +The REPL (`repl.ts`) tracks your working directory and handles `cd`, `~` expansion, and relative paths. + +## Files + +| File | Description | +|------|-------------| +| `index.ts` | Sandbox setup, Mesa installation and mount | +| `repl.ts` | Tiny REPL with `cd` and tilde expansion | + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `MESA_ORG` | Your Mesa organization slug | +| `MESA_API_KEY` | Mesa API key ([get one here](https://mesa.dev)) | +| `SUPERSERVE_API_KEY` | Superserve API key ([get one here](https://superserve.ai)) | + +## Requirements + +- Node.js >= 18 +- Mesa account with an API key +- Superserve account with an API key diff --git a/superserve-shell/index.ts b/superserve-shell/index.ts new file mode 100644 index 0000000..cf3b122 --- /dev/null +++ b/superserve-shell/index.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +// To run this example, create a .env file in this directory with: +// MESA_ORG=your-org +// MESA_API_KEY=your-mesa-key +// SUPERSERVE_API_KEY=your-superserve-key +// +// Then run: +// npm start + +import 'dotenv/config'; +import { Sandbox } from '@superserve/sdk'; +import tinySuperserveRepl from './repl.ts'; + +const ORG = + process.env.MESA_ORG ?? + (() => { + throw Error('$MESA_ORG not set.'); + })(); +const MESA_API_KEY = + process.env.MESA_API_KEY ?? + (() => { + throw Error('$MESA_API_KEY not set.'); + })(); +if (!process.env.SUPERSERVE_API_KEY) { + throw Error('$SUPERSERVE_API_KEY not set.'); +} + +console.log('Creating Superserve sandbox...'); +const sandbox = await Sandbox.create({ name: 'mesa-shell' }); + +try { + // Set up Mesa within the Superserve sandbox. + // + // We recommend installing Mesa as part of a custom template, but here we + // install it directly to keep the example small. + + // You can install Mesa as per the guide in https://docs.mesa.dev/content/virtual-filesystem/os-level. + // + // Mesa's installer will install all its dependencies through your system's package manager. + console.log('Installing Mesa...'); + await sandbox.commands.run('curl -fsSL https://mesa.dev/install.sh | sh -s -- --yes'); + + // Superserve sandboxes run as root inside a Firecracker microVM with a + // FUSE-enabled kernel, so the `user_allow_other` and `chmod 666 /dev/fuse` + // steps that other sandbox providers require aren't needed here. + + // You can run mesa in daemon mode to kick it off in the background. + // + // The flags we are using here are: + // -d, --daemonize Spawns mesa in the background + // -y, --non-interactive Tells mesa to use the default values for all its configuration values. It will create a + // new config file for you. + // + // We also pass the environment variable: + // MESA_ORGS=:,... Tells mesa to configure the given organization with the given API key. + // Mesa will store this information in its configuration file. See + // https://docs.mesa.dev/content/reference/mesa-cli-configuration for more details. + // + // Note that mesa will write the orgs to the config file the first time it is booted up, so you do not need to + // specify it again. When mesa is already configured, it will append the orgs given through the environment to the + // ones in the config.toml. + // + // Additionally, we recommend creating and specifying an ephemeral key which persists for the lifetime of the sandbox, + // rather than using the main API key. In the spirit of keeping this example small, we use the main API key. See + // https://docs.mesa.dev/content/getting-started/auth-and-permissions for more details. + console.log(`Mounting ${ORG}...`); + await sandbox.commands.run('mesa mount -d -y', { + env: { + MESA_ORGS: `${ORG}:${MESA_API_KEY}`, + }, + }); + + // You can now explore repos in your org. We've written a tiny REPL here you can use to explore the sandbox. + // + // The default configuration is created in ~/.config/mesa/config.toml + // and your files will be in ~/.local/share/mesa/mnt// + await tinySuperserveRepl(sandbox, { cwd: `~/.local/share/mesa/mnt/${ORG}` }); +} finally { + // No matter what happens, let's make sure we clean up the sandbox so we don't burn Superserve resources. + console.log('\nCleaning up sandbox...'); + await sandbox.kill(); +} diff --git a/superserve-shell/package.json b/superserve-shell/package.json new file mode 100644 index 0000000..2176f2c --- /dev/null +++ b/superserve-shell/package.json @@ -0,0 +1,18 @@ +{ + "name": "superserve-shell", + "version": "0.1.0", + "description": "Interactive shell over a Mesa cloud repo running in a Superserve sandbox", + "type": "module", + "scripts": { + "start": "tsx index.ts" + }, + "dependencies": { + "@superserve/sdk": "^0.7.0", + "dotenv": "^17.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.8.0" + } +} diff --git a/superserve-shell/repl.ts b/superserve-shell/repl.ts new file mode 100644 index 0000000..24563b8 --- /dev/null +++ b/superserve-shell/repl.ts @@ -0,0 +1,128 @@ +import path from 'node:path'; +import * as readline from 'node:readline'; +import type { Sandbox } from '@superserve/sdk'; + +/** + * Options parameter for the @see tinySuperserveRepl function. + */ +export interface ISuperserveReplOptions { + /** The current working directory. */ + cwd: string | undefined; +} + +type CommandResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +function expandTilde(filePath: string, homedir: string): string { + return filePath.startsWith('~') ? path.posix.join(homedir, filePath.slice(1)) : filePath; +} + +class ShellState { + #homedir: string; + #cwd: string; + #sandbox: Sandbox; + + private constructor(homedir: string, cwd: string, sandbox: Sandbox) { + this.#homedir = homedir; + this.#cwd = cwd; + this.#sandbox = sandbox; + } + + static async create(sandbox: Sandbox, cwd: string): Promise { + const homedir = await ShellState.probeHome(sandbox); + return new ShellState(homedir, expandTilde(cwd, homedir), sandbox); + } + + /** Execute a shell command. Handles `cd` gracefully enough. */ + async run(cmd: string): Promise { + const trimmed: string = cmd.trim(); + if (!trimmed) { + return null; + } + + const splitCommand = trimmed.split(/\s+/); + if (splitCommand[0] === 'cd') { + const target = splitCommand[1] ?? '~'; + this.#cwd = path.posix.isAbsolute(target) + ? splitCommand[1] + : path.posix.join(this.#cwd, this.expandTilde(target)); + return null; + } + + const result = await this.#sandbox.commands.run(`cd ${this.#cwd} && ${trimmed}`); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + + private static async probeHome(sandbox: Sandbox): Promise { + const result = await sandbox.commands.run('echo $HOME'); + return result.stdout.trim(); + } + + private expandTilde(filePath: string): string { + return expandTilde(filePath, this.#homedir); + } +} + +function question(rl: readline.Interface, prompt: string): Promise { + return new Promise((resolve) => { + rl.once('close', () => resolve(null)); + rl.question(prompt, (answer) => resolve(answer)); + }); +} + +/** + * Spawns the REPL so that users can use it. + * + * This is a tiny REPL and is not a full shell, so it has an incredibly limited subset of regular shell commands. + */ +export default async function tinySuperserveRepl( + sandbox: Sandbox, + options: ISuperserveReplOptions | undefined +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const shell = await ShellState.create(sandbox, options?.cwd ?? '~'); + + console.log(`Connected to ${options?.cwd ?? '~'}. Type "exit" or Ctrl+C to quit.\n`); + + while (true) { + const input = await question(rl, '$ '); + + if (input === null) { + break; + } + + if (input.trim() === 'exit') { + break; + } + + const result = await shell.run(input); + if (result === null) { + continue; + } + + const { exitCode, stdout, stderr } = result; + + if (stdout) { + process.stdout.write(stdout); + } + + if (stderr) { + process.stderr.write(stderr); + } + + if (exitCode !== null && exitCode !== 0) { + console.error(`The process exited with a non-zero exit code: ${exitCode}`); + } + } +} diff --git a/superserve-shell/sst-env.d.ts b/superserve-shell/sst-env.d.ts new file mode 100644 index 0000000..d4ff07b --- /dev/null +++ b/superserve-shell/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} diff --git a/superserve-shell/tsconfig.json b/superserve-shell/tsconfig.json new file mode 100644 index 0000000..1977241 --- /dev/null +++ b/superserve-shell/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowImportingTsExtensions": true + }, + "include": ["index.ts", "repl.ts"] +}