boxsh.js 0.1.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 ADDED
@@ -0,0 +1,238 @@
1
+ # boxsh.js
2
+
3
+ Node.js SDK for [boxsh](../../README.md) — a sandboxed POSIX shell with Linux namespace isolation and copy-on-write overlay filesystem.
4
+
5
+ boxsh.js lets you drive a long-lived boxsh instance from Node.js: execute shell commands, read/write files, and perform search-and-replace edits — all inside an isolated sandbox.
6
+
7
+ **Requirements:** Node.js ≥ 18, Linux, `boxsh` binary on `$PATH` (or set `BOXSH` env var).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install ./sdk/js
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ Simplest form — no sandbox, just run a command:
20
+
21
+ ```js
22
+ import { BoxshClient } from 'boxsh.js';
23
+
24
+ const client = new BoxshClient();
25
+
26
+ const { exitCode, stdout } = await client.exec('echo hello');
27
+ console.log(stdout); // "hello\n"
28
+
29
+ await client.close();
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Running shell commands
35
+
36
+ `exec(cmd, cwd?, timeout?)` runs a shell command in a boxsh worker, returning the exit code, stdout, and stderr.
37
+
38
+ ```js
39
+ // Specify a working directory
40
+ const result = await client.exec('ls -la', '/workspace');
41
+ console.log(result.exitCode); // 0
42
+ console.log(result.stdout); // file listing
43
+
44
+ // Set a timeout (seconds) — the worker is killed via SIGALRM when it expires
45
+ const result2 = await client.exec('sleep 100', '/workspace', 5);
46
+ ```
47
+
48
+ Multiple `exec` calls can run concurrently. BoxshClient dispatches them across workers and resolves responses in completion order:
49
+
50
+ ```js
51
+ const client = new BoxshClient({ workers: 4 });
52
+
53
+ const [a, b, c] = await Promise.all([
54
+ client.exec('make build', '/workspace'),
55
+ client.exec('make lint', '/workspace'),
56
+ client.exec('make test', '/workspace'),
57
+ ]);
58
+ ```
59
+
60
+ ---
61
+
62
+ ## File operations
63
+
64
+ boxsh has three built-in file tools — `read`, `write`, and `edit`. They run on background threads and never block the RPC event loop.
65
+
66
+ ```js
67
+ // Read a file — optionally specify a start line and line limit
68
+ const content = await client.read('/workspace/src/main.cpp');
69
+ const first50 = await client.read('/workspace/src/main.cpp', 1, 50);
70
+
71
+ // Write a file — full replacement
72
+ await client.write('/workspace/output.txt', 'hello\n');
73
+
74
+ // Edit a file — search-and-replace; each oldText must appear exactly once
75
+ const { diff, firstChangedLine } = await client.edit('/workspace/output.txt', [
76
+ { oldText: 'hello', newText: 'world' },
77
+ ]);
78
+ console.log(diff); // unified diff format
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Sandbox isolation
84
+
85
+ With `sandbox` enabled, commands run inside isolated Linux namespaces (user, mount), separated from the host. You can further isolate the network and PID tree:
86
+
87
+ ```js
88
+ const client = new BoxshClient({
89
+ sandbox: true,
90
+ newNetNs: true, // Isolated network namespace (no external access)
91
+ newPidNs: true, // Isolated PID namespace
92
+ });
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Overlay filesystem
98
+
99
+ Overlay is the primary usage pattern for boxsh: mount a read-only base directory as a copy-on-write workspace. Commands can read and write freely, but all modifications land in the upper directory while the base remains untouched.
100
+
101
+ ```
102
+ Overlay parameters:
103
+ lower Read-only base directory (your project/repository)
104
+ upper Writable upper directory (all modifications go here)
105
+ work Working directory required by overlayfs (must be on the same filesystem as upper)
106
+ dst Mount point (the path visible inside the sandbox)
107
+ ```
108
+
109
+ ```js
110
+ import { BoxshClient } from 'boxsh.js';
111
+ import fs from 'node:fs';
112
+
113
+ // Prepare overlay directories
114
+ const upper = '/tmp/sandbox/upper';
115
+ const work = '/tmp/sandbox/work';
116
+ const mnt = '/tmp/sandbox/mnt';
117
+ fs.mkdirSync(upper, { recursive: true });
118
+ fs.mkdirSync(work, { recursive: true });
119
+ fs.mkdirSync(mnt, { recursive: true });
120
+
121
+ const client = new BoxshClient({
122
+ sandbox: true,
123
+ overlay: {
124
+ lower: '/home/user/myproject', // read-only base
125
+ upper, // modifications land here
126
+ work,
127
+ dst: mnt, // mount point inside the sandbox
128
+ },
129
+ });
130
+
131
+ // Inside the sandbox, /tmp/sandbox/mnt is a COW copy of myproject
132
+ await client.exec('npm install', mnt);
133
+
134
+ // Read/write files via built-in tools (RPC, no shell round-trip needed)
135
+ const pkg = await client.read(`${mnt}/package.json`);
136
+ await client.write(`${mnt}/result.txt`, 'done\n');
137
+
138
+ await client.close();
139
+
140
+ // At this point upper/ contains all modifications; base is completely untouched.
141
+ // You can commit, archive, or simply delete upper/ to discard changes.
142
+ ```
143
+
144
+ The upper directory persists across sessions. To resume a previous session, create a new BoxshClient pointing at the same `upper`/`work`/`mnt` directories.
145
+
146
+ ---
147
+
148
+ ## Inspecting changes
149
+
150
+ `getChanges` scans the overlay's upper directory against the base and returns all added, modified, and deleted files. `formatChanges` formats the result as human-readable text.
151
+
152
+ Both functions run on the host side (inside the Node.js process) and do not require a running boxsh instance.
153
+
154
+ ```js
155
+ import { getChanges, formatChanges } from 'boxsh.js';
156
+
157
+ const changes = getChanges({
158
+ upper: '/tmp/sandbox/upper',
159
+ base: '/home/user/myproject',
160
+ });
161
+ // [{ path: 'package-lock.json', type: 'modified' },
162
+ // { path: 'result.txt', type: 'added' }]
163
+
164
+ console.log(formatChanges(changes));
165
+ // M package-lock.json
166
+ // A result.txt
167
+ ```
168
+
169
+ Deletions are tracked via whiteout files (`.wh.<name>`), which `getChanges` detects automatically.
170
+
171
+ ---
172
+
173
+ ## `shellQuote`
174
+
175
+ POSIX single-quote escaping for safely interpolating variables into shell commands:
176
+
177
+ ```js
178
+ import { shellQuote } from 'boxsh.js';
179
+
180
+ const userInput = "hello'world";
181
+ await client.exec(`echo ${shellQuote(userInput)}`);
182
+ // Executed safely — no injection
183
+ ```
184
+
185
+ ---
186
+
187
+ ## API reference
188
+
189
+ ### `new BoxshClient(options?)`
190
+
191
+ | Option | Type | Default | Description |
192
+ |---|---|---|---|
193
+ | `boxshPath` | `string` | `$BOXSH` → `'boxsh'` | Path to the boxsh binary |
194
+ | `workers` | `number` | `1` | Number of pre-forked workers |
195
+ | `sandbox` | `boolean` | `false` | Enable namespace sandbox |
196
+ | `newNetNs` | `boolean` | `false` | Isolate network |
197
+ | `newPidNs` | `boolean` | `false` | Isolate PID tree |
198
+ | `overlay` | `{ lower, upper, work, dst }` | — | Overlay mount configuration |
199
+
200
+ ### `client.exec(cmd, cwd?, timeout?) → Promise<{ exitCode, stdout, stderr }>`
201
+
202
+ Execute a shell command. `timeout` is in seconds.
203
+
204
+ ### `client.read(path, offset?, limit?) → Promise<string>`
205
+
206
+ Read file contents. `offset` is the 1-based start line; `limit` is the maximum number of lines.
207
+
208
+ ### `client.write(path, content) → Promise<void>`
209
+
210
+ Write a file (full replacement).
211
+
212
+ ### `client.edit(path, edits) → Promise<{ diff, firstChangedLine }>`
213
+
214
+ Apply search-and-replace edits. `edits` is an array of `{ oldText, newText }`. Each `oldText` must appear exactly once in the file. All edits match against the original file content (not the result of a previous edit). Returns a unified diff and the first changed line number.
215
+
216
+ ### `client.close() → Promise<void>`
217
+
218
+ Close stdin and wait for the boxsh process to exit.
219
+
220
+ ### `client.terminate() → void`
221
+
222
+ Send SIGTERM immediately.
223
+
224
+ ### `getChanges({ upper, base }) → Array<{ path, type }>`
225
+
226
+ Scan the upper directory and return a list of changes relative to base. `type` is `'added'`, `'modified'`, or `'deleted'`.
227
+
228
+ ### `formatChanges(changes) → string`
229
+
230
+ Format a change list as `A/M/D\tpath` text.
231
+
232
+ ---
233
+
234
+ ## Testing
235
+
236
+ ```sh
237
+ node --test test/all.test.mjs
238
+ ```
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "boxsh.js",
3
+ "version": "0.1.0",
4
+ "description": "Node.js SDK for boxsh — sandboxed shell execution with overlay copy-on-write and JSON-line RPC",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.mjs"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "node --test test/all.test.mjs"
16
+ },
17
+ "keywords": [
18
+ "boxsh",
19
+ "sandbox",
20
+ "shell",
21
+ "overlayfs",
22
+ "namespace",
23
+ "rpc",
24
+ "linux",
25
+ "isolation"
26
+ ],
27
+ "license": "MIT",
28
+ "author": "xicilion",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+ssh://git@github.com/xicilion/boxsh.git",
35
+ "directory": "sdk/js"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/xicilion/boxsh/issues"
39
+ },
40
+ "homepage": "https://github.com/xicilion/boxsh#readme",
41
+ "os": [
42
+ "linux"
43
+ ],
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ },
47
+ "files": [
48
+ "src/"
49
+ ]
50
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Changes detection: scan the upper directory to extract file modifications
3
+ * relative to the base layer.
4
+ *
5
+ * Detects:
6
+ * - Added files (in upper, not in base)
7
+ * - Modified files (in both layers)
8
+ * - Deleted files (whiteout markers in upper)
9
+ *
10
+ * Supports both host-side whiteout format (.wh.<name> files) and
11
+ * kernel overlay whiteout format (char device 0/0).
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ const WH_PREFIX = '.wh.';
18
+
19
+ /**
20
+ * Scan the upper directory and return a list of all changes relative to base.
21
+ *
22
+ * @param {{ upper: string, base: string }} options
23
+ * @returns {Array<{ path: string, type: 'added'|'modified'|'deleted' }>}
24
+ */
25
+ export function getChanges(options) {
26
+ const changes = [];
27
+ _scanChanges(options.upper, options.base, '.', changes);
28
+ return changes.sort((a, b) => a.path.localeCompare(b.path));
29
+ }
30
+
31
+ /**
32
+ * @param {string} upperRoot
33
+ * @param {string} baseRoot
34
+ * @param {string} rel
35
+ * @param {Array<object>} changes
36
+ */
37
+ function _scanChanges(upperRoot, baseRoot, rel, changes) {
38
+ const upperDir = path.join(upperRoot, rel);
39
+ if (!fs.existsSync(upperDir) || !fs.statSync(upperDir).isDirectory()) return;
40
+
41
+ for (const name of fs.readdirSync(upperDir)) {
42
+ const childRel = rel === '.' ? name : path.join(rel, name);
43
+ const upperPath = path.join(upperRoot, childRel);
44
+
45
+ // Host-side whiteout (.wh.<name>)
46
+ if (name.startsWith(WH_PREFIX)) {
47
+ const targetName = name.slice(WH_PREFIX.length);
48
+ const targetRel = rel === '.' ? targetName : path.join(rel, targetName);
49
+ changes.push({ path: targetRel, type: 'deleted' });
50
+ continue;
51
+ }
52
+
53
+ // Kernel overlay whiteout (char device 0/0)
54
+ if (_isKernelWhiteout(upperPath)) {
55
+ changes.push({ path: childRel, type: 'deleted' });
56
+ continue;
57
+ }
58
+
59
+ const st = fs.statSync(upperPath);
60
+
61
+ if (st.isDirectory()) {
62
+ const basePath = path.join(baseRoot, childRel);
63
+ if (!fs.existsSync(basePath)) {
64
+ changes.push({ path: childRel, type: 'added' });
65
+ }
66
+ _scanChanges(upperRoot, baseRoot, childRel, changes);
67
+ } else if (st.isFile()) {
68
+ const basePath = path.join(baseRoot, childRel);
69
+ changes.push({ path: childRel, type: fs.existsSync(basePath) ? 'modified' : 'added' });
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Detect kernel overlay whiteout: character device with rdev 0.
76
+ * @param {string} filePath
77
+ * @returns {boolean}
78
+ */
79
+ function _isKernelWhiteout(filePath) {
80
+ try {
81
+ const st = fs.lstatSync(filePath);
82
+ return st.isCharacterDevice?.() && st.rdev === 0;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Format a list of changes as a human-readable summary.
90
+ *
91
+ * @param {Array<{ path: string, type: 'added'|'modified'|'deleted' }>} changes
92
+ * @returns {string}
93
+ */
94
+ export function formatChanges(changes) {
95
+ if (changes.length === 0) return 'No changes detected.\n';
96
+ return changes
97
+ .map(c => `${c.type === 'added' ? 'A' : c.type === 'modified' ? 'M' : 'D'}\t${c.path}`)
98
+ .join('\n') + '\n';
99
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * BoxshClient — manages a long-lived boxsh RPC process.
3
+ *
4
+ * Spawns boxsh with --rpc and optional --sandbox/--overlay flags.
5
+ * All commands and tool calls are sent as JSON lines to stdin and
6
+ * responses are read back as JSON lines from stdout.
7
+ *
8
+ * Protocol (request):
9
+ * shell: { id, cmd, timeout? }
10
+ * tool: { id, tool: "read|write", path, ...opts }
11
+ *
12
+ * Protocol (response):
13
+ * shell: { id, exit_code, stdout, stderr, duration_ms }
14
+ * tool: { id, content: [{ text }] } or { id, error }
15
+ */
16
+
17
+ import { spawn } from 'node:child_process';
18
+ import { createInterface } from 'node:readline';
19
+
20
+ /**
21
+ * POSIX single-quote escaping.
22
+ * @param {string} s
23
+ * @returns {string}
24
+ */
25
+ export function shellQuote(s) {
26
+ return "'" + s.replace(/'/g, "'\\''") + "'";
27
+ }
28
+
29
+ export class BoxshClient {
30
+ /** @type {import('node:child_process').ChildProcess} */
31
+ #proc;
32
+ /** @type {Map<string, { resolve: Function, reject: Function }>} */
33
+ #pending = new Map();
34
+ #idCounter = 0;
35
+ #closed = false;
36
+
37
+ /**
38
+ * @param {object} [options]
39
+ * @param {string} [options.boxshPath] Path to boxsh binary (default: BOXSH env var → 'boxsh')
40
+ * @param {number} [options.workers] Worker count (default: 1)
41
+ * @param {boolean} [options.sandbox] Enable --sandbox flag
42
+ * @param {boolean} [options.newNetNs] Enable --new-net-ns flag
43
+ * @param {boolean} [options.newPidNs] Enable --new-pid-ns flag
44
+ * @param {{ lower: string, upper: string, work: string, dst: string }} [options.overlay]
45
+ */
46
+ constructor(options = {}) {
47
+ const boxsh = options.boxshPath ?? process.env['BOXSH'] ?? 'boxsh';
48
+ const args = ['--rpc', '--workers', String(options.workers ?? 1)];
49
+
50
+ if (options.sandbox) args.push('--sandbox');
51
+ if (options.newNetNs) args.push('--new-net-ns');
52
+ if (options.newPidNs) args.push('--new-pid-ns');
53
+ if (options.overlay) {
54
+ const { lower, upper, work, dst } = options.overlay;
55
+ args.push('--overlay', `${lower}:${upper}:${work}:${dst}`);
56
+ }
57
+
58
+ this.#proc = spawn(boxsh, args, { stdio: ['pipe', 'pipe', 'inherit'] });
59
+
60
+ createInterface({ input: this.#proc.stdout }).on('line', (line) => {
61
+ const trimmed = line.trim();
62
+ if (!trimmed) return;
63
+ /** @type {Record<string, unknown>} */
64
+ let resp;
65
+ try {
66
+ resp = JSON.parse(trimmed);
67
+ } catch {
68
+ return;
69
+ }
70
+ const id = String(resp.id ?? '');
71
+ const entry = this.#pending.get(id);
72
+ if (!entry) return;
73
+ this.#pending.delete(id);
74
+ entry.resolve(resp);
75
+ });
76
+
77
+ this.#proc.on('error', (err) => this.#failAll(err));
78
+ this.#proc.on('exit', () => {
79
+ if (!this.#closed) {
80
+ this.#failAll(new Error('boxsh process exited unexpectedly'));
81
+ }
82
+ });
83
+ }
84
+
85
+ #failAll(err) {
86
+ for (const entry of this.#pending.values()) entry.reject(err);
87
+ this.#pending.clear();
88
+ }
89
+
90
+ #nextId() {
91
+ return String(++this.#idCounter);
92
+ }
93
+
94
+ /**
95
+ * Send a raw request and return the parsed response.
96
+ * @param {Record<string, unknown>} req
97
+ * @returns {Promise<Record<string, unknown>>}
98
+ */
99
+ #send(req) {
100
+ return new Promise((resolve, reject) => {
101
+ if (this.#closed) {
102
+ reject(new Error('BoxshClient is closed'));
103
+ return;
104
+ }
105
+ const id = this.#nextId();
106
+ this.#pending.set(id, { resolve, reject });
107
+ this.#proc.stdin.write(JSON.stringify({ ...req, id }) + '\n');
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Execute a shell command.
113
+ *
114
+ * @param {string} cmd Shell command (passed to dash -c)
115
+ * @param {string} [cwd] Working directory inside the sandbox
116
+ * @param {number} [timeout] Timeout in seconds (0 or undefined = none)
117
+ * @returns {Promise<{ exitCode: number|null, stdout: string, stderr: string }>}
118
+ */
119
+ async exec(cmd, cwd, timeout) {
120
+ const command = cwd ? `(cd ${shellQuote(cwd)} && ${cmd})` : cmd;
121
+ const req = { cmd: command };
122
+ if (timeout !== undefined && timeout > 0) req.timeout = timeout;
123
+
124
+ const resp = await this.#send(req);
125
+ return {
126
+ exitCode: typeof resp.exit_code === 'number' ? resp.exit_code : null,
127
+ stdout: typeof resp.stdout === 'string' ? resp.stdout : '',
128
+ stderr: typeof resp.stderr === 'string' ? resp.stderr : '',
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Read a file using boxsh's built-in read tool.
134
+ *
135
+ * @param {string} filePath Absolute path to the file
136
+ * @param {number} [offset] 1-based line number to start reading from
137
+ * @param {number} [limit] Maximum number of lines to return
138
+ * @returns {Promise<string>}
139
+ */
140
+ async read(filePath, offset, limit) {
141
+ const req = { tool: 'read', path: filePath };
142
+ if (offset !== undefined) req.offset = offset;
143
+ if (limit !== undefined) req.limit = limit;
144
+
145
+ const resp = await this.#send(req);
146
+ if (resp.error) throw new Error(String(resp.error));
147
+ return resp.content[0].text;
148
+ }
149
+
150
+ /**
151
+ * Write a file using boxsh's built-in write tool.
152
+ *
153
+ * @param {string} filePath Absolute path to the file
154
+ * @param {string} content Full file content to write
155
+ */
156
+ async write(filePath, content) {
157
+ const resp = await this.#send({ tool: 'write', path: filePath, content });
158
+ if (resp.error) throw new Error(String(resp.error));
159
+ }
160
+
161
+ /**
162
+ * Edit a file using boxsh's built-in edit tool.
163
+ *
164
+ * Each edit is matched against the original file content (not the result
165
+ * of previous edits), and oldText must be unique in the file.
166
+ *
167
+ * @param {string} filePath Absolute path to the file
168
+ * @param {Array<{ oldText: string, newText: string }>} edits
169
+ * @returns {Promise<{ diff: string, firstChangedLine: number }>}
170
+ */
171
+ async edit(filePath, edits) {
172
+ const resp = await this.#send({ tool: 'edit', path: filePath, edits });
173
+ if (resp.error) throw new Error(String(resp.error));
174
+ return {
175
+ diff: resp.details?.diff ?? '',
176
+ firstChangedLine: resp.details?.firstChangedLine ?? 0,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Close stdin and wait for the boxsh process to exit.
182
+ * @returns {Promise<void>}
183
+ */
184
+ close() {
185
+ this.#closed = true;
186
+ this.#proc.stdin.end();
187
+ return new Promise((resolve) => {
188
+ if (this.#proc.exitCode !== null) {
189
+ resolve();
190
+ } else {
191
+ this.#proc.once('exit', () => resolve());
192
+ }
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Kill the boxsh process immediately.
198
+ */
199
+ terminate() {
200
+ this.#closed = true;
201
+ this.#failAll(new Error('BoxshClient terminated'));
202
+ this.#proc.kill('SIGTERM');
203
+ }
204
+ }
package/src/exec/boxsh ADDED
Binary file
package/src/index.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ export interface BoxshOverlayOptions {
2
+ lower: string;
3
+ upper: string;
4
+ work: string;
5
+ dst: string;
6
+ }
7
+
8
+ export interface BoxshClientOptions {
9
+ boxshPath?: string;
10
+ workers?: number;
11
+ sandbox?: boolean;
12
+ newNetNs?: boolean;
13
+ newPidNs?: boolean;
14
+ overlay?: BoxshOverlayOptions;
15
+ }
16
+
17
+ export interface ExecResult {
18
+ exitCode: number | null;
19
+ stdout: string;
20
+ stderr: string;
21
+ }
22
+
23
+ export interface EditOperation {
24
+ oldText: string;
25
+ newText: string;
26
+ }
27
+
28
+ export interface EditResult {
29
+ diff: string;
30
+ firstChangedLine: number;
31
+ }
32
+
33
+ export interface Change {
34
+ path: string;
35
+ type: 'added' | 'modified' | 'deleted';
36
+ }
37
+
38
+ export class BoxshClient {
39
+ constructor(options?: BoxshClientOptions);
40
+ exec(cmd: string, cwd?: string, timeout?: number): Promise<ExecResult>;
41
+ read(filePath: string, offset?: number, limit?: number): Promise<string>;
42
+ write(filePath: string, content: string): Promise<void>;
43
+ edit(filePath: string, edits: EditOperation[]): Promise<EditResult>;
44
+ close(): Promise<void>;
45
+ terminate(): void;
46
+ }
47
+
48
+ export function shellQuote(s: string): string;
49
+
50
+ export function getChanges(options: { upper: string; base: string }): Change[];
51
+
52
+ export function formatChanges(changes: Change[]): string;
53
+
54
+ export interface BashExecOptions {
55
+ onData?: (data: Buffer) => void;
56
+ signal?: AbortSignal;
57
+ timeout?: number;
58
+ }
59
+
60
+ export interface BashOperations {
61
+ exec(command: string, cwd: string, options: BashExecOptions): Promise<{ exitCode: number | null }>;
62
+ }
63
+
64
+ export interface CreateBashOperationsOptions {
65
+ sandbox?: boolean;
66
+ fallback?: BashOperations;
67
+ }
68
+
69
+ export function createBashOperations(options?: CreateBashOperationsOptions): BashOperations;
package/src/index.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @boxsh/sdk — public API
3
+ */
4
+
5
+ // Core RPC client
6
+ export { BoxshClient, shellQuote } from './client.mjs';
7
+
8
+ // Change detection (diff upper vs base on the host filesystem)
9
+ export { getChanges, formatChanges } from './changes.mjs';
10
+
11
+ /**
12
+ * Create a BashOperations adapter backed by BoxshClient.
13
+ * If the boxsh binary is unavailable, returns the provided fallback.
14
+ *
15
+ * @param {object} [options]
16
+ * @param {boolean} [options.sandbox] Enable sandbox (default: true)
17
+ * @param {object} [options.fallback] Fallback BashOperations when boxsh is not found
18
+ * @returns {object} BashOperations-compatible object
19
+ */
20
+ export function createBashOperations(options = {}) {
21
+ const { sandbox = true, fallback } = options;
22
+ let client;
23
+ try {
24
+ client = new BoxshClient({ sandbox });
25
+ } catch {
26
+ if (fallback) {
27
+ console.warn('[boxsh] binary not available, using fallback');
28
+ return fallback;
29
+ }
30
+ throw new Error('boxsh binary not found and no fallback provided');
31
+ }
32
+
33
+ return {
34
+ async exec(command, cwd, { onData, signal, timeout } = {}) {
35
+ if (signal?.aborted) throw new Error('aborted');
36
+
37
+ const result = await client.exec(command, cwd, timeout);
38
+
39
+ const output = result.stdout + result.stderr;
40
+ if (output && onData) {
41
+ onData(Buffer.from(output));
42
+ }
43
+
44
+ return { exitCode: result.exitCode };
45
+ },
46
+ };
47
+ }