@superhq/shuru 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,14 +15,37 @@ import { Sandbox } from "@superhq/shuru";
15
15
 
16
16
  const sb = await Sandbox.start();
17
17
 
18
+ // Buffered exec — run a command and get the full result
18
19
  const result = await sb.exec("echo hello");
19
20
  console.log(result.stdout); // "hello\n"
20
21
 
22
+ // Streaming spawn — real-time stdout/stderr
23
+ const proc = await sb.spawn("npm run dev");
24
+ proc.on("stdout", (data) => process.stdout.write(data));
25
+ proc.on("stderr", (data) => process.stderr.write(data));
26
+ proc.on("exit", (code) => console.log("exited:", code));
27
+
28
+ // File watching — guest-side inotify events
29
+ await sb.watch("/workspace", (event) => {
30
+ console.log(event.event, event.path); // "modify" "/workspace/src/main.ts"
31
+ });
32
+
33
+ // File I/O
21
34
  await sb.writeFile("/tmp/app.ts", "console.log('hi')");
22
35
  const data = await sb.readFile("/tmp/app.ts"); // Uint8Array
23
- const text = new TextDecoder().decode(data);
24
36
 
25
- await sb.checkpoint("my-env"); // saves disk state and stops the VM
37
+ // Filesystem operations
38
+ await sb.mkdir("/workspace/src");
39
+ const entries = await sb.readDir("/workspace"); // { name, type, size }[]
40
+ const info = await sb.stat("/tmp/app.ts"); // { size, mode, mtime, isDir, ... }
41
+ await sb.copy("/tmp/app.ts", "/tmp/backup.ts");
42
+ await sb.rename("/tmp/backup.ts", "/tmp/old.ts");
43
+ await sb.chmod("/tmp/app.ts", 0o755);
44
+ await sb.remove("/tmp/old.ts");
45
+ if (await sb.exists("/tmp/app.ts")) { /* ... */ }
46
+
47
+ // Checkpoint — save disk state and stop
48
+ await sb.checkpoint("my-env");
26
49
  ```
27
50
 
28
51
  ### Start from a checkpoint
@@ -58,8 +81,8 @@ const sb = await Sandbox.start({
58
81
  | `allowNet` | `boolean` | Enable network access |
59
82
  | `ports` | `string[]` | Port forwards (`"host:guest"`) |
60
83
  | `mounts` | `Record<string, string>` | Directory mounts (`{ hostPath: guestPath }`) |
61
- | `secrets` | `Record<string, SecretConfig>` | Secrets to inject via proxy (see below) |
62
- | `network` | `NetworkConfig` | Network access policy (see below) |
84
+ | `secrets` | `Record<string, SecretConfig>` | Secrets to inject via proxy |
85
+ | `network` | `NetworkConfig` | Network access policy |
63
86
  | `shuruBin` | `string` | Path to shuru binary (default: `"shuru"`) |
64
87
 
65
88
  ## API
@@ -70,7 +93,47 @@ Boot a new microVM. Returns when the VM is ready.
70
93
 
71
94
  ### `sandbox.exec(command): Promise<ExecResult>`
72
95
 
73
- Run a shell command in the VM. Returns `{ stdout, stderr, exitCode }`.
96
+ Run a shell command in the VM. Returns `{ stdout, stderr, exitCode }`. Stdout and stderr are buffered — the promise resolves when the command finishes.
97
+
98
+ ### `sandbox.spawn(command, opts?): Promise<SandboxProcess>`
99
+
100
+ Spawn a long-running command in the VM. Returns a `SandboxProcess` handle immediately, streaming output in real-time.
101
+
102
+ ```ts
103
+ const proc = await sb.spawn("npm run dev", { cwd: "/workspace" });
104
+
105
+ proc.on("stdout", (data: Buffer) => { /* real-time chunks */ });
106
+ proc.on("stderr", (data: Buffer) => { /* real-time chunks */ });
107
+ proc.on("exit", (code: number) => { /* process exited */ });
108
+
109
+ proc.write("input to stdin\n"); // write to stdin
110
+ await proc.kill(); // send SIGTERM
111
+ const exitCode = await proc.exited; // await completion
112
+ console.log(proc.pid); // process ID
113
+ ```
114
+
115
+ **`SpawnOptions`:**
116
+ | Option | Type | Description |
117
+ |--------|------|-------------|
118
+ | `cwd` | `string` | Working directory for the command |
119
+ | `env` | `Record<string, string>` | Environment variables |
120
+
121
+ ### `sandbox.watch(path, handler, opts?): Promise<void>`
122
+
123
+ Watch a directory for file changes inside the guest VM. Uses guest-side inotify, so it detects writes to tmpfs overlays that host-side watchers cannot see.
124
+
125
+ ```ts
126
+ await sb.watch("/workspace", (event) => {
127
+ console.log(event.event, event.path);
128
+ // event.event: "create" | "modify" | "delete" | "rename"
129
+ // event.path: full path of the changed file
130
+ });
131
+ ```
132
+
133
+ **`WatchOptions`:**
134
+ | Option | Type | Default | Description |
135
+ |--------|------|---------|-------------|
136
+ | `recursive` | `boolean` | `true` | Watch subdirectories recursively |
74
137
 
75
138
  ### `sandbox.readFile(path): Promise<Uint8Array>`
76
139
 
@@ -80,6 +143,50 @@ Read a file from the VM. Returns raw bytes. Use `new TextDecoder().decode(data)`
80
143
 
81
144
  Write a file to the VM. Accepts raw bytes or a string.
82
145
 
146
+ ### `sandbox.mkdir(path, opts?): Promise<void>`
147
+
148
+ Create a directory. Creates parent directories by default.
149
+
150
+ | Option | Type | Default | Description |
151
+ |--------|------|---------|-------------|
152
+ | `recursive` | `boolean` | `true` | Create parent directories |
153
+
154
+ ### `sandbox.readDir(path): Promise<DirEntry[]>`
155
+
156
+ List the contents of a directory. Each entry has `name`, `type` (`"file"`, `"dir"`, or `"symlink"`), and `size` in bytes.
157
+
158
+ ### `sandbox.stat(path): Promise<StatResult>`
159
+
160
+ Get file metadata. Returns `{ size, mode, mtime, isDir, isFile, isSymlink }`. `mtime` is seconds since the Unix epoch. `mode` includes the file type bits (e.g. `0o100644` for a regular file with 644 permissions).
161
+
162
+ ### `sandbox.remove(path, opts?): Promise<void>`
163
+
164
+ Delete a file or empty directory. To remove a non-empty directory, pass `{ recursive: true }`.
165
+
166
+ | Option | Type | Default | Description |
167
+ |--------|------|---------|-------------|
168
+ | `recursive` | `boolean` | `false` | Remove directories and their contents |
169
+
170
+ ### `sandbox.rename(oldPath, newPath): Promise<void>`
171
+
172
+ Move or rename a file or directory within the guest filesystem. Atomic on the same filesystem.
173
+
174
+ ### `sandbox.copy(src, dst, opts?): Promise<void>`
175
+
176
+ Copy a file. To copy a directory tree, pass `{ recursive: true }`.
177
+
178
+ | Option | Type | Default | Description |
179
+ |--------|------|---------|-------------|
180
+ | `recursive` | `boolean` | `false` | Copy directories recursively |
181
+
182
+ ### `sandbox.chmod(path, mode): Promise<void>`
183
+
184
+ Change file permissions. `mode` is a numeric permission value (e.g. `0o755`).
185
+
186
+ ### `sandbox.exists(path): Promise<boolean>`
187
+
188
+ Check if a path exists. Returns `true` if it does, `false` otherwise.
189
+
83
190
  ### `sandbox.checkpoint(name): Promise<void>`
84
191
 
85
192
  Save the VM's disk state and stop the VM. To continue working, call `Sandbox.start({ from: name })`.
@@ -116,6 +223,17 @@ const sb = await Sandbox.start({
116
223
 
117
224
  Omit `network.allow` to allow all domains.
118
225
 
226
+ ## Concurrency
227
+
228
+ Multiple `spawn()` calls run concurrently in the same VM. Each gets a unique pid and independent stdout/stderr streams. You can mix `spawn()`, `exec()`, and `watch()` freely:
229
+
230
+ ```ts
231
+ // Start a dev server, run tests, and watch for changes — all at once
232
+ const server = await sb.spawn("npm run dev", { cwd: "/workspace" });
233
+ const watcher = sb.watch("/workspace", (e) => console.log(e));
234
+ const tests = await sb.exec("npm test");
235
+ ```
236
+
119
237
  ## Requirements
120
238
 
121
239
  - macOS 14+ on Apple Silicon
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superhq/shuru",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1,7 +1,17 @@
1
+ export { SandboxProcess } from "./process-handle";
1
2
  export { Sandbox } from "./sandbox";
2
3
  export type {
4
+ CopyOptions,
5
+ DirEntry,
6
+ ExecOptions,
3
7
  ExecResult,
8
+ FileChangeEvent,
9
+ MkdirOptions,
4
10
  NetworkConfig,
11
+ RemoveOptions,
5
12
  SecretConfig,
13
+ SpawnOptions,
6
14
  StartOptions,
15
+ StatResult,
16
+ WatchOptions,
7
17
  } from "./types";
@@ -0,0 +1,54 @@
1
+ import type { ShuruProcess } from "./process";
2
+
3
+ type EventMap = {
4
+ stdout: (data: Buffer) => void;
5
+ stderr: (data: Buffer) => void;
6
+ exit: (code: number) => void;
7
+ };
8
+
9
+ export class SandboxProcess {
10
+ readonly pid: string;
11
+ readonly exited: Promise<number>;
12
+ private proc: ShuruProcess;
13
+ private listeners: {
14
+ stdout: ((data: Buffer) => void)[];
15
+ stderr: ((data: Buffer) => void)[];
16
+ exit: ((code: number) => void)[];
17
+ } = { stdout: [], stderr: [], exit: [] };
18
+
19
+ constructor(proc: ShuruProcess, pid: string) {
20
+ this.proc = proc;
21
+ this.pid = pid;
22
+
23
+ this.exited = new Promise<number>((resolve) => {
24
+ proc.processHandlers.set(pid, {
25
+ onStdout: (data) => {
26
+ for (const h of this.listeners.stdout) h(data);
27
+ },
28
+ onStderr: (data) => {
29
+ for (const h of this.listeners.stderr) h(data);
30
+ },
31
+ onExit: (code) => {
32
+ for (const h of this.listeners.exit) h(code);
33
+ resolve(code);
34
+ },
35
+ });
36
+ });
37
+ }
38
+
39
+ on<K extends keyof EventMap>(event: K, handler: EventMap[K]): this {
40
+ this.listeners[event].push(handler);
41
+ return this;
42
+ }
43
+
44
+ write(data: Buffer | string): void {
45
+ this.proc.sendNotification("input", {
46
+ pid: this.pid,
47
+ data: Buffer.from(data).toString("base64"),
48
+ });
49
+ }
50
+
51
+ async kill(): Promise<void> {
52
+ await this.proc.send("kill", { pid: this.pid });
53
+ }
54
+ }
package/src/process.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import type { Subprocess } from "bun";
2
- import type { JsonRpcResponse, JsonRpcResult } from "./types";
2
+ import type { FileChangeEvent, JsonRpcResponse, JsonRpcResult } from "./types";
3
3
 
4
4
  interface PendingRequest {
5
5
  resolve: (value: JsonRpcResult) => void;
6
6
  reject: (reason: Error) => void;
7
7
  }
8
8
 
9
+ export interface ProcessEventHandlers {
10
+ onStdout: (data: Buffer) => void;
11
+ onStderr: (data: Buffer) => void;
12
+ onExit: (code: number) => void;
13
+ }
14
+
9
15
  export class ShuruProcess {
10
16
  private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
11
17
  private pending = new Map<number, PendingRequest>();
@@ -13,6 +19,12 @@ export class ShuruProcess {
13
19
  private onReady: (() => void) | null = null;
14
20
  private onReadyError: ((err: Error) => void) | null = null;
15
21
 
22
+ /** Handlers for spawned process output/exit, keyed by pid. */
23
+ readonly processHandlers = new Map<string, ProcessEventHandlers>();
24
+
25
+ /** Handler for file change events from the guest watcher. */
26
+ fileChangeHandler: ((event: FileChangeEvent) => void) | null = null;
27
+
16
28
  async start(args: string[]): Promise<void> {
17
29
  this.proc = Bun.spawn(args, {
18
30
  stdin: "pipe",
@@ -55,6 +67,14 @@ export class ShuruProcess {
55
67
  });
56
68
  }
57
69
 
70
+ /** Send a fire-and-forget notification (no id, no response expected). */
71
+ sendNotification(method: string, params: Record<string, unknown>): void {
72
+ if (!this.proc) throw new Error("shuru process not started");
73
+ const line = `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`;
74
+ this.proc.stdin.write(line);
75
+ this.proc.stdin.flush();
76
+ }
77
+
58
78
  async stop(): Promise<void> {
59
79
  if (!this.proc) return;
60
80
 
@@ -132,11 +152,47 @@ export class ShuruProcess {
132
152
  }
133
153
 
134
154
  private dispatch(msg: JsonRpcResponse): void {
135
- if ("method" in msg && msg.method === "ready") {
136
- if (this.onReady) {
137
- this.onReady();
138
- this.onReady = null;
139
- this.onReadyError = null;
155
+ // Handle notifications (no id)
156
+ if ("method" in msg && !("id" in msg)) {
157
+ const params = msg.params as Record<string, unknown> | undefined;
158
+
159
+ switch (msg.method) {
160
+ case "ready": {
161
+ if (this.onReady) {
162
+ this.onReady();
163
+ this.onReady = null;
164
+ this.onReadyError = null;
165
+ }
166
+ return;
167
+ }
168
+ case "output": {
169
+ if (!params) return;
170
+ const pid = params.pid as string;
171
+ const h = this.processHandlers.get(pid);
172
+ if (!h) return;
173
+ const buf = Buffer.from(params.data as string, "base64");
174
+ if (params.stream === "stdout") h.onStdout(buf);
175
+ else if (params.stream === "stderr") h.onStderr(buf);
176
+ return;
177
+ }
178
+ case "exit": {
179
+ if (!params) return;
180
+ const pid = params.pid as string;
181
+ const h = this.processHandlers.get(pid);
182
+ if (h) {
183
+ h.onExit(params.code as number);
184
+ this.processHandlers.delete(pid);
185
+ }
186
+ return;
187
+ }
188
+ case "file_change": {
189
+ if (!params) return;
190
+ this.fileChangeHandler?.({
191
+ path: params.path as string,
192
+ event: params.event as FileChangeEvent["event"],
193
+ });
194
+ return;
195
+ }
140
196
  }
141
197
  return;
142
198
  }
package/src/sandbox.ts CHANGED
@@ -1,11 +1,33 @@
1
1
  import { ShuruProcess } from "./process";
2
- import type { ExecResult, StartOptions } from "./types";
2
+ import { SandboxProcess } from "./process-handle";
3
+ import type {
4
+ CopyOptions,
5
+ DirEntry,
6
+ ExecOptions,
7
+ ExecResult,
8
+ FileChangeEvent,
9
+ MkdirOptions,
10
+ RemoveOptions,
11
+ SpawnOptions,
12
+ StartOptions,
13
+ StatResult,
14
+ WatchOptions,
15
+ } from "./types";
3
16
 
4
17
  const Method = {
5
18
  EXEC: "exec",
19
+ SPAWN: "spawn",
6
20
  READ_FILE: "read_file",
7
21
  WRITE_FILE: "write_file",
8
22
  CHECKPOINT: "checkpoint",
23
+ WATCH: "watch",
24
+ MKDIR: "mkdir",
25
+ READ_DIR: "read_dir",
26
+ STAT: "stat",
27
+ REMOVE: "remove",
28
+ RENAME: "rename",
29
+ COPY: "copy",
30
+ CHMOD: "chmod",
9
31
  } as const;
10
32
 
11
33
  export class Sandbox {
@@ -26,10 +48,15 @@ export class Sandbox {
26
48
  return new Sandbox(proc);
27
49
  }
28
50
 
29
- async exec(command: string): Promise<ExecResult> {
30
- const resp = await this.proc.send(Method.EXEC, {
31
- argv: ["sh", "-c", command],
32
- });
51
+ async exec(
52
+ command: string | string[],
53
+ opts?: ExecOptions,
54
+ ): Promise<ExecResult> {
55
+ const argv =
56
+ typeof command === "string"
57
+ ? [opts?.shell ?? "sh", "-c", command]
58
+ : command;
59
+ const resp = await this.proc.send(Method.EXEC, { argv });
33
60
  const r = resp.result as {
34
61
  stdout: string;
35
62
  stderr: string;
@@ -42,6 +69,35 @@ export class Sandbox {
42
69
  };
43
70
  }
44
71
 
72
+ async spawn(
73
+ command: string | string[],
74
+ opts?: SpawnOptions,
75
+ ): Promise<SandboxProcess> {
76
+ const argv =
77
+ typeof command === "string"
78
+ ? [opts?.shell ?? "sh", "-c", command]
79
+ : command;
80
+ const resp = await this.proc.send(Method.SPAWN, {
81
+ argv,
82
+ cwd: opts?.cwd,
83
+ env: opts?.env,
84
+ });
85
+ const { pid } = resp.result as { pid: string };
86
+ return new SandboxProcess(this.proc, pid);
87
+ }
88
+
89
+ async watch(
90
+ path: string,
91
+ handler: (event: FileChangeEvent) => void,
92
+ opts?: WatchOptions,
93
+ ): Promise<void> {
94
+ this.proc.fileChangeHandler = handler;
95
+ await this.proc.send(Method.WATCH, {
96
+ path,
97
+ recursive: opts?.recursive ?? true,
98
+ });
99
+ }
100
+
45
101
  async readFile(path: string): Promise<Uint8Array> {
46
102
  const resp = await this.proc.send(Method.READ_FILE, { path });
47
103
  const r = resp.result as { content: string };
@@ -53,6 +109,74 @@ export class Sandbox {
53
109
  await this.proc.send(Method.WRITE_FILE, { path, content: b64 });
54
110
  }
55
111
 
112
+ async mkdir(path: string, opts?: MkdirOptions): Promise<void> {
113
+ await this.proc.send(Method.MKDIR, {
114
+ path,
115
+ recursive: opts?.recursive ?? true,
116
+ });
117
+ }
118
+
119
+ async readDir(path: string): Promise<DirEntry[]> {
120
+ const resp = await this.proc.send(Method.READ_DIR, { path });
121
+ const r = resp.result as { entries: DirEntry[] };
122
+ return r.entries;
123
+ }
124
+
125
+ async stat(path: string): Promise<StatResult> {
126
+ const resp = await this.proc.send(Method.STAT, { path });
127
+ const r = resp.result as {
128
+ size: number;
129
+ mode: number;
130
+ mtime: number;
131
+ is_dir: boolean;
132
+ is_file: boolean;
133
+ is_symlink: boolean;
134
+ };
135
+ return {
136
+ size: r.size,
137
+ mode: r.mode,
138
+ mtime: r.mtime,
139
+ isDir: r.is_dir,
140
+ isFile: r.is_file,
141
+ isSymlink: r.is_symlink,
142
+ };
143
+ }
144
+
145
+ async remove(path: string, opts?: RemoveOptions): Promise<void> {
146
+ await this.proc.send(Method.REMOVE, {
147
+ path,
148
+ recursive: opts?.recursive ?? false,
149
+ });
150
+ }
151
+
152
+ async rename(oldPath: string, newPath: string): Promise<void> {
153
+ await this.proc.send(Method.RENAME, {
154
+ old_path: oldPath,
155
+ new_path: newPath,
156
+ });
157
+ }
158
+
159
+ async copy(src: string, dst: string, opts?: CopyOptions): Promise<void> {
160
+ await this.proc.send(Method.COPY, {
161
+ src,
162
+ dst,
163
+ recursive: opts?.recursive ?? false,
164
+ });
165
+ }
166
+
167
+ async chmod(path: string, mode: number): Promise<void> {
168
+ await this.proc.send(Method.CHMOD, { path, mode });
169
+ }
170
+
171
+ async exists(path: string): Promise<boolean> {
172
+ try {
173
+ await this.stat(path);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
56
180
  async checkpoint(name: string): Promise<void> {
57
181
  await this.proc.send(Method.CHECKPOINT, { name });
58
182
  this.stopped = true;
package/src/types.ts CHANGED
@@ -29,6 +29,57 @@ export interface ExecResult {
29
29
  exitCode: number;
30
30
  }
31
31
 
32
+ export interface ExecOptions {
33
+ /** Shell to use when command is a string. Defaults to "sh". Ignored when command is an array. */
34
+ shell?: string;
35
+ }
36
+
37
+ export interface SpawnOptions {
38
+ cwd?: string;
39
+ env?: Record<string, string>;
40
+ /** Shell to use when command is a string. Defaults to "sh". Ignored when command is an array. */
41
+ shell?: string;
42
+ }
43
+
44
+ export interface WatchOptions {
45
+ recursive?: boolean;
46
+ }
47
+
48
+ export interface FileChangeEvent {
49
+ path: string;
50
+ event: "create" | "modify" | "delete" | "rename";
51
+ }
52
+
53
+ // --- Filesystem types ---
54
+
55
+ export interface DirEntry {
56
+ name: string;
57
+ type: "file" | "dir" | "symlink";
58
+ size: number;
59
+ }
60
+
61
+ export interface StatResult {
62
+ size: number;
63
+ mode: number;
64
+ /** Seconds since Unix epoch */
65
+ mtime: number;
66
+ isDir: boolean;
67
+ isFile: boolean;
68
+ isSymlink: boolean;
69
+ }
70
+
71
+ export interface MkdirOptions {
72
+ recursive?: boolean;
73
+ }
74
+
75
+ export interface RemoveOptions {
76
+ recursive?: boolean;
77
+ }
78
+
79
+ export interface CopyOptions {
80
+ recursive?: boolean;
81
+ }
82
+
32
83
  // --- JSON-RPC 2.0 wire types (internal) ---
33
84
 
34
85
  export interface JsonRpcResult {
@@ -46,6 +97,7 @@ export interface JsonRpcError {
46
97
  export interface JsonRpcNotification {
47
98
  jsonrpc: "2.0";
48
99
  method: string;
100
+ params?: Record<string, unknown>;
49
101
  }
50
102
 
51
103
  export type JsonRpcResponse =