@superhq/shuru 0.1.0 → 0.1.2
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 +89 -0
- package/package.json +6 -1
- package/src/index.ts +1 -1
- package/src/process.ts +47 -34
- package/src/sandbox.ts +29 -40
- package/src/types.ts +23 -11
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# @superhq/shuru
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for [shuru](https://github.com/superhq-ai/shuru) — programmatic access to ephemeral Linux microVMs on macOS.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @superhq/shuru
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Sandbox } from "@superhq/shuru";
|
|
15
|
+
|
|
16
|
+
const sb = await Sandbox.start();
|
|
17
|
+
|
|
18
|
+
const result = await sb.exec("echo hello");
|
|
19
|
+
console.log(result.stdout); // "hello\n"
|
|
20
|
+
|
|
21
|
+
await sb.writeFile("/tmp/app.ts", "console.log('hi')");
|
|
22
|
+
const data = await sb.readFile("/tmp/app.ts"); // Uint8Array
|
|
23
|
+
const text = new TextDecoder().decode(data);
|
|
24
|
+
|
|
25
|
+
await sb.checkpoint("my-env"); // saves disk state and stops the VM
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Start from a checkpoint
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const sb = await Sandbox.start({ from: "my-env" });
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const sb = await Sandbox.start({
|
|
38
|
+
from: "my-env",
|
|
39
|
+
cpus: 4,
|
|
40
|
+
memory: 4096,
|
|
41
|
+
diskSize: 8192,
|
|
42
|
+
allowNet: true,
|
|
43
|
+
ports: ["8080:80"],
|
|
44
|
+
mounts: { "./src": "/workspace" },
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Option | Type | Description |
|
|
49
|
+
|--------|------|-------------|
|
|
50
|
+
| `from` | `string` | Checkpoint name to start from |
|
|
51
|
+
| `cpus` | `number` | Number of vCPUs |
|
|
52
|
+
| `memory` | `number` | Memory in MB |
|
|
53
|
+
| `diskSize` | `number` | Disk size in MB |
|
|
54
|
+
| `allowNet` | `boolean` | Enable network access |
|
|
55
|
+
| `ports` | `string[]` | Port forwards (`"host:guest"`) |
|
|
56
|
+
| `mounts` | `Record<string, string>` | Directory mounts (`{ hostPath: guestPath }`) |
|
|
57
|
+
| `shuruBin` | `string` | Path to shuru binary (default: `"shuru"`) |
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
### `Sandbox.start(opts?): Promise<Sandbox>`
|
|
62
|
+
|
|
63
|
+
Boot a new microVM. Returns when the VM is ready.
|
|
64
|
+
|
|
65
|
+
### `sandbox.exec(command): Promise<ExecResult>`
|
|
66
|
+
|
|
67
|
+
Run a shell command in the VM. Returns `{ stdout, stderr, exitCode }`.
|
|
68
|
+
|
|
69
|
+
### `sandbox.readFile(path): Promise<Uint8Array>`
|
|
70
|
+
|
|
71
|
+
Read a file from the VM. Returns raw bytes. Use `new TextDecoder().decode(data)` for text files.
|
|
72
|
+
|
|
73
|
+
### `sandbox.writeFile(path, content: Uint8Array | string): Promise<void>`
|
|
74
|
+
|
|
75
|
+
Write a file to the VM. Accepts raw bytes or a string.
|
|
76
|
+
|
|
77
|
+
### `sandbox.checkpoint(name): Promise<void>`
|
|
78
|
+
|
|
79
|
+
Save the VM's disk state and stop the VM. To continue working, call `Sandbox.start({ from: name })`.
|
|
80
|
+
|
|
81
|
+
### `sandbox.stop(): Promise<void>`
|
|
82
|
+
|
|
83
|
+
Stop the VM without saving. All changes are discarded.
|
|
84
|
+
|
|
85
|
+
## Requirements
|
|
86
|
+
|
|
87
|
+
- macOS 14+ on Apple Silicon
|
|
88
|
+
- [shuru CLI](https://github.com/superhq-ai/shuru) installed
|
|
89
|
+
- Bun runtime
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superhq/shuru",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": ["src"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/superhq-ai/shuru",
|
|
11
|
+
"directory": "packages/sdk"
|
|
12
|
+
},
|
|
8
13
|
"scripts": {
|
|
9
14
|
"test": "bun test test/unit/",
|
|
10
15
|
"test:integration": "bun test test/integration/",
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Sandbox } from "./sandbox";
|
|
2
|
-
export type { ExecResult, StartOptions
|
|
2
|
+
export type { ExecResult, StartOptions } from "./types";
|
package/src/process.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Subprocess } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type { JsonRpcResponse, JsonRpcResult } from "./types";
|
|
3
3
|
|
|
4
4
|
interface PendingRequest {
|
|
5
|
-
resolve: (value:
|
|
5
|
+
resolve: (value: JsonRpcResult) => void;
|
|
6
6
|
reject: (reason: Error) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export class ShuruProcess {
|
|
10
10
|
private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
|
|
11
|
-
private pending = new Map<
|
|
11
|
+
private pending = new Map<number, PendingRequest>();
|
|
12
12
|
private idCounter = 0;
|
|
13
|
+
private onReady: (() => void) | null = null;
|
|
14
|
+
private onReadyError: ((err: Error) => void) | null = null;
|
|
13
15
|
|
|
14
16
|
async start(args: string[]): Promise<void> {
|
|
15
17
|
this.proc = Bun.spawn(args, {
|
|
@@ -25,27 +27,28 @@ export class ShuruProcess {
|
|
|
25
27
|
reject(new Error("shuru: timed out waiting for ready signal (30s)"));
|
|
26
28
|
}, 30_000);
|
|
27
29
|
|
|
28
|
-
this.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
});
|
|
30
|
+
this.onReady = () => {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
this.onReadyError = (err: Error) => {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
reject(err);
|
|
37
|
+
};
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
send(
|
|
42
|
+
method: string,
|
|
43
|
+
params: Record<string, unknown>,
|
|
44
|
+
): Promise<JsonRpcResult> {
|
|
42
45
|
if (!this.proc) throw new Error("shuru process not started");
|
|
43
46
|
|
|
44
|
-
const id =
|
|
45
|
-
const line = `${JSON.stringify({ id,
|
|
47
|
+
const id = ++this.idCounter;
|
|
48
|
+
const line = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
|
|
46
49
|
const proc = this.proc;
|
|
47
50
|
|
|
48
|
-
return new Promise<
|
|
51
|
+
return new Promise<JsonRpcResult>((resolve, reject) => {
|
|
49
52
|
this.pending.set(id, { resolve, reject });
|
|
50
53
|
proc.stdin.write(line);
|
|
51
54
|
proc.stdin.flush();
|
|
@@ -88,21 +91,26 @@ export class ShuruProcess {
|
|
|
88
91
|
|
|
89
92
|
const reader = this.proc.stdout.getReader();
|
|
90
93
|
const decoder = new TextDecoder();
|
|
91
|
-
let
|
|
94
|
+
let remainder = "";
|
|
92
95
|
|
|
93
96
|
try {
|
|
94
97
|
while (true) {
|
|
95
98
|
const { done, value } = await reader.read();
|
|
96
99
|
if (done) break;
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
remainder += decoder.decode(value, { stream: true });
|
|
102
|
+
|
|
103
|
+
while (true) {
|
|
104
|
+
const newlineIdx = remainder.indexOf("\n");
|
|
105
|
+
if (newlineIdx === -1) break;
|
|
106
|
+
const line = remainder.slice(0, newlineIdx);
|
|
107
|
+
remainder = remainder.slice(newlineIdx + 1);
|
|
101
108
|
|
|
102
|
-
for (const line of lines) {
|
|
103
109
|
if (!line) continue;
|
|
110
|
+
|
|
104
111
|
try {
|
|
105
|
-
|
|
112
|
+
const msg = JSON.parse(line) as JsonRpcResponse;
|
|
113
|
+
this.dispatch(msg);
|
|
106
114
|
} catch {
|
|
107
115
|
// malformed line
|
|
108
116
|
}
|
|
@@ -112,33 +120,38 @@ export class ShuruProcess {
|
|
|
112
120
|
// stream closed
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
if (this.onReadyError) {
|
|
124
|
+
this.onReadyError(new Error("shuru process exited unexpectedly"));
|
|
125
|
+
this.onReady = null;
|
|
126
|
+
this.onReadyError = null;
|
|
127
|
+
}
|
|
115
128
|
for (const [, req] of this.pending) {
|
|
116
129
|
req.reject(new Error("shuru process exited unexpectedly"));
|
|
117
130
|
}
|
|
118
131
|
this.pending.clear();
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
private dispatch(msg:
|
|
122
|
-
if (msg.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
134
|
+
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;
|
|
127
140
|
}
|
|
128
141
|
return;
|
|
129
142
|
}
|
|
130
143
|
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
if (!("id" in msg) || msg.id == null) return;
|
|
145
|
+
const id = msg.id as number;
|
|
133
146
|
|
|
134
147
|
const req = this.pending.get(id);
|
|
135
148
|
if (!req) return;
|
|
136
149
|
this.pending.delete(id);
|
|
137
150
|
|
|
138
|
-
if (
|
|
139
|
-
req.reject(new Error(msg.error));
|
|
151
|
+
if ("error" in msg) {
|
|
152
|
+
req.reject(new Error(msg.error.message));
|
|
140
153
|
} else {
|
|
141
|
-
req.resolve(msg);
|
|
154
|
+
req.resolve(msg as JsonRpcResult);
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
}
|
package/src/sandbox.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { ShuruProcess } from "./process";
|
|
2
2
|
import type { ExecResult, StartOptions } from "./types";
|
|
3
3
|
|
|
4
|
+
const Method = {
|
|
5
|
+
EXEC: "exec",
|
|
6
|
+
READ_FILE: "read_file",
|
|
7
|
+
WRITE_FILE: "write_file",
|
|
8
|
+
CHECKPOINT: "checkpoint",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
4
11
|
export class Sandbox {
|
|
5
12
|
private proc: ShuruProcess;
|
|
13
|
+
private stopped = false;
|
|
6
14
|
|
|
7
15
|
private constructor(proc: ShuruProcess) {
|
|
8
16
|
this.proc = proc;
|
|
@@ -19,60 +27,41 @@ export class Sandbox {
|
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
async exec(command: string): Promise<ExecResult> {
|
|
22
|
-
const resp = await this.proc.send({
|
|
23
|
-
type: "exec",
|
|
30
|
+
const resp = await this.proc.send(Method.EXEC, {
|
|
24
31
|
argv: ["sh", "-c", command],
|
|
25
32
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const r = resp.result as {
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
exit_code: number;
|
|
37
|
+
};
|
|
29
38
|
return {
|
|
30
|
-
stdout:
|
|
31
|
-
stderr:
|
|
32
|
-
exitCode:
|
|
39
|
+
stdout: r.stdout,
|
|
40
|
+
stderr: r.stderr,
|
|
41
|
+
exitCode: r.exit_code,
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
async readFile(path: string): Promise<
|
|
37
|
-
const resp = await this.proc.send({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
if (resp.type !== "exec") {
|
|
42
|
-
throw new Error(`unexpected response type: ${resp.type}`);
|
|
43
|
-
}
|
|
44
|
-
if (resp.exit_code !== 0) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`readFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return resp.stdout;
|
|
45
|
+
async readFile(path: string): Promise<Uint8Array> {
|
|
46
|
+
const resp = await this.proc.send(Method.READ_FILE, { path });
|
|
47
|
+
const r = resp.result as { content: string };
|
|
48
|
+
return new Uint8Array(Buffer.from(r.content, "base64"));
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
async writeFile(path: string, content: string): Promise<void> {
|
|
53
|
-
const b64 =
|
|
54
|
-
|
|
55
|
-
type: "exec",
|
|
56
|
-
argv: ["sh", "-c", `echo '${b64}' | base64 -d > "$1"`, "--", path],
|
|
57
|
-
});
|
|
58
|
-
if (resp.type !== "exec") {
|
|
59
|
-
throw new Error(`unexpected response type: ${resp.type}`);
|
|
60
|
-
}
|
|
61
|
-
if (resp.exit_code !== 0) {
|
|
62
|
-
throw new Error(
|
|
63
|
-
`writeFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
51
|
+
async writeFile(path: string, content: Uint8Array | string): Promise<void> {
|
|
52
|
+
const b64 = Buffer.from(content).toString("base64");
|
|
53
|
+
await this.proc.send(Method.WRITE_FILE, { path, content: b64 });
|
|
66
54
|
}
|
|
67
55
|
|
|
68
56
|
async checkpoint(name: string): Promise<void> {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
57
|
+
await this.proc.send(Method.CHECKPOINT, { name });
|
|
58
|
+
this.stopped = true;
|
|
59
|
+
await this.proc.stop();
|
|
73
60
|
}
|
|
74
61
|
|
|
75
62
|
async stop(): Promise<void> {
|
|
63
|
+
if (this.stopped) return;
|
|
64
|
+
this.stopped = true;
|
|
76
65
|
await this.proc.stop();
|
|
77
66
|
}
|
|
78
67
|
}
|
package/src/types.ts
CHANGED
|
@@ -15,14 +15,26 @@ export interface ExecResult {
|
|
|
15
15
|
exitCode: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
// --- JSON-RPC 2.0 wire types (internal) ---
|
|
19
|
+
|
|
20
|
+
export interface JsonRpcResult {
|
|
21
|
+
jsonrpc: "2.0";
|
|
22
|
+
id: number;
|
|
23
|
+
result: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JsonRpcError {
|
|
27
|
+
jsonrpc: "2.0";
|
|
28
|
+
id: number;
|
|
29
|
+
error: { code: number; message: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface JsonRpcNotification {
|
|
33
|
+
jsonrpc: "2.0";
|
|
34
|
+
method: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type JsonRpcResponse =
|
|
38
|
+
| JsonRpcResult
|
|
39
|
+
| JsonRpcError
|
|
40
|
+
| JsonRpcNotification;
|