@mainbrella/sdk 0.0.0-stage → 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.
Files changed (6) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +190 -2
  3. package/cli.mjs +155 -0
  4. package/index.d.ts +88 -0
  5. package/index.js +298 -0
  6. package/package.json +34 -4
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # Mainbrella JavaScript SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local package, not published to npm yet. Requires Node 22+; no dependencies.
4
+
5
+ ```sh
6
+ npm install /absolute/path/to/backend/sdk/javascript
7
+ ```
8
+
9
+ ```js
10
+ import { Mainbrella } from '@mainbrella/sdk';
11
+
12
+ const client = new Mainbrella({ apiKey: process.env.MAINBRELLA_API_KEY });
13
+ console.log(await client.capabilities());
14
+ const sandbox = await client.create({ catalogId: 'node' });
15
+ try {
16
+ const result = await sandbox.commands.run('printf hello');
17
+ await sandbox.files.write('/tmp/probe.bin', new Uint8Array([0, 128, 255]));
18
+ const bytes = await sandbox.files.read('/tmp/probe.bin');
19
+ console.log(result.exitCode, bytes.length);
20
+ } finally {
21
+ await sandbox.kill();
22
+ }
23
+ ```
24
+
25
+ Creation retries use the same key until the bounded wait expires. An ambiguous
26
+ creation error contains `idempotencyKey`; preserve it and retry the same selection.
27
+ Commands and file writes are never automatically retried. Inspect the file or
28
+ application state after a lost response. `connect({id, createdAt})` attaches to
29
+ an explicit existing generation without creating or stopping anything.
30
+
31
+ The richer filesystem helpers require the corresponding `capabilities.files`
32
+ flags. They operate on absolute UTF-8 guest paths:
33
+
34
+ ```js
35
+ await sandbox.files.mkdir('/workspace/output', { recursive: true, mode: '0700' });
36
+ const page = await sandbox.files.list('/workspace', { limit: 100 });
37
+ // Pass page.nextOffset as offset for the next page, if it is not null.
38
+ const metadata = await sandbox.files.stat('/workspace/output');
39
+ await sandbox.files.move('/tmp/probe.bin', '/workspace/output/probe.bin');
40
+ await sandbox.files.chmod('/workspace/output/probe.bin', '0640');
41
+ await sandbox.files.remove('/workspace/output', { recursive: true });
42
+ ```
43
+
44
+ Lists contain one level, sorted by UTF-8 filename bytes, with up to 1,000 entries
45
+ per page. Pagination rescans; directory changes can duplicate or omit entries.
46
+ Metadata includes type, size, mode, uid/gid, second-precision modification time
47
+ and symlink target. `stat` inspects a symlink itself unless `followSymlinks: true`.
48
+ Moves never overwrite an existing destination. Removal defaults to files or empty
49
+ directories and removes a symlink itself. Mutation paths cannot be root. These
50
+ operations share the four-operation pool and 30-second file deadline. Recursive
51
+ deletion and moves across filesystems may partly complete before interruption;
52
+ inspect state before retrying a mutation. Watchers remain unsupported.
53
+
54
+ Versioned local archives can be built and checked with backend `npm run sdk:qualify`.
55
+ Install its `.tgz` with `npm install /path/to/mainbrella-sdk-0.1.0.tgz`; this remains
56
+ an archive installation, not an npm registry release. See the backend
57
+ [SDK release runbook](https://github.com/mainbrella/backend/blob/main/docs/sdk-release.md)
58
+ for artifact and deployed-workflow gates.
59
+
60
+ Keep the API key in a secret store or environment, never in code or logs. The
61
+ client rejects credential-bearing URLs, redirects, and HTTP except loopback.
62
+ Nonzero command exit codes are returned normally. API failures raise
63
+ `MainbrellaError` with a sanitized code and HTTP status.
64
+
65
+ Managed execution separates process lifetime from a request:
66
+
67
+ ```js
68
+ const job = await sandbox.commands.start('npm test', { timeoutMs: 300_000 });
69
+ for await (const event of job.events()) {
70
+ if (event.type === 'stdout') process.stdout.write(event.data);
71
+ }
72
+ const result = await job.get();
73
+ // await job.cancel(); // Explicit cancellation; closing events only detaches.
74
+ ```
75
+
76
+ `events()` reconnects on normal stream rotation and tracks `job.cursor`; a transport
77
+ failure exposes its cursor for explicit reconnect. `job.wait()` polls to terminal
78
+ state. A wait timeout leaves the job running. `new Execution(sandbox, id)` reconnects
79
+ to a known job. Preserve the idempotency key from a failed `commands.start()` call
80
+ and retry the same options within the one-hour retention window. Runtime restart
81
+ interrupts unfinished jobs and stops their matching container generation.
82
+
83
+ For richer process control, check `execution.argv`, `stdin`, `signals`, `managedProcessListing`, `programmaticPty` and `ptyResize` in `client.capabilities()`:
84
+
85
+ ```js
86
+ const job = await sandbox.commands.start(['cat'], { stdin: true, cwd: '/tmp', env: { TASK: 'probe' } });
87
+ await job.stdin.write(new TextEncoder().encode('hello\n'));
88
+ await job.stdin.close(); // Pipe EOF; no automatic input retry.
89
+ const result = await job.wait();
90
+ const attached = sandbox.commands.attach(job.id);
91
+ const { executions } = await sandbox.commands.list(); // Retained managed jobs.
92
+ const terminal = await sandbox.commands.start(['/bin/sh'], { stdin: true, pty: { cols: 80, rows: 24 } });
93
+ await terminal.resize(132, 40);
94
+ await terminal.stdin.write(new TextEncoder().encode('exit\n'));
95
+ await terminal.wait();
96
+ // await terminal.signal('SIGTERM'); // Delivery request; inspect state afterward.
97
+ ```
98
+
99
+ An array starts the executable directly; a string uses `/bin/sh -lc`. Environment values go into the guest and are not protected secrets. Input caps are 64 KiB per write, 1 MiB accepted per job and 256 KiB pending. Ambiguous writes stay counted and are never retried. PTY output merges stderr into stdout with terminal line discipline; input closure may hang up the terminal. Cancellation targets the operation process group; deliberately detached processes remain bounded by the machine lease. These local helpers require corresponding deployed capabilities. Guest-wide process listing and filesystem watching remain unsupported.
100
+
101
+ Protected application previews are available only when
102
+ `(await client.capabilities()).previews.supported` is true. Start your application's
103
+ HTTP server in the sandbox first, then create a link for its listening port:
104
+
105
+ ```js
106
+ const preview = await sandbox.previews.create(3000, { ttlSeconds: 900 });
107
+ // Give preview.url to the intended recipient through a private channel.
108
+ const { previews } = await sandbox.previews.list(); // Metadata only, no URLs.
109
+ await sandbox.previews.revoke(preview.id);
110
+ ```
111
+
112
+ Ports are 1024–65535, TTL is 60–3600 seconds (default 900), and at most eight
113
+ grants are active per generation. `expiresAt` is Unix milliseconds, clipped to
114
+ the container deadline. The URL is a bearer credential returned once; keep it
115
+ out of logs and analytics. Listing and revocation work when new issuance is disabled.
116
+ Creation is never retried automatically. After a lost response, list and revoke
117
+ the unwanted grant before creating another link. A `preview_reconciliation_required`
118
+ error exposes `error.previewId`; retry `sandbox.previews.revoke(error.previewId)`.
119
+ Revocation closes active connections. Stopping or replacing the generation
120
+ invalidates its links. Application cookies are stripped; cookie sessions and
121
+ absolute redirect rewriting are unsupported. Host and forwarded host/protocol
122
+ reflect the validated HTTPS preview origin; the caller's Origin is preserved.
123
+ Protected previews are enabled on `mainbrella.dev` after live qualification;
124
+ check `previews.supported` in `/capabilities` before using them.
125
+
126
+ ## Command line
127
+
128
+ The same local npm archive installs the dependency-free `mainbrella` command. Set `MAINBRELLA_API_KEY` in your environment; optional `MAINBRELLA_API_URL` selects a trusted API origin. Credentials are never CLI arguments. From the project where the archive is installed:
129
+
130
+ ```sh
131
+ ./node_modules/.bin/mainbrella --help
132
+ ./node_modules/.bin/mainbrella create --size small --idempotency-key stable-create-key
133
+ # Preserve the returned id/createdAt before issuing another command.
134
+ ./node_modules/.bin/mainbrella run --id <id> --created-at <generation> --command 'printf hello'
135
+ ./node_modules/.bin/mainbrella kill --id <id> --created-at <generation>
136
+ ```
137
+
138
+ `create` and `start` require an explicit stable idempotency key. Errors include it after an ambiguous response. Repeat the same options within retention; never issue a new key to resolve uncertainty. Every workload action requires both container ID and exact generation. There is no bulk-stop command. `job` controls inspect/cancel/stream/signal/resize/send input to known execution IDs; `jobs` lists retained managed jobs. Results are JSON and streamed events are newline-delimited JSON. `run` returns the guest exit code (124 on timeout, 125 on truncated output); API/CLI errors exit 1 and emit sanitized JSON on stderr.
139
+
140
+ `file read --path /guest/file --output /local/new-file` creates a new local file without overwriting. `file write --path /guest/file --source /local/file` transfers binary bytes up to 1 MiB. `job input --source /local/chunk` accepts up to 64 KiB. CLI input writes never retry automatically. Registry publication and live qualification remain pending.
141
+
142
+ ## Workload observations and webhooks
143
+
144
+ Check `observability.lifecycleEvents`, `metrics` and `webhooks` first. Lifecycle history belongs to one generation, survives stop, and is bounded to seven days/256 events per slot. Deduplicate stable IDs and order by sequence. Metrics are provider workload observations, separate from billing allocations and public service health; missing evidence remains unobserved/null. Metrics and webhook delivery remain disabled until operator configuration and live qualification.
145
+
146
+ ```js
147
+ const page = await sandbox.events({ cursor: 0, limit: 100 });
148
+ if (page.hasMore) await sandbox.events({ cursor: page.nextCursor });
149
+ const capabilities = await client.capabilities();
150
+ if (capabilities.observability.metrics) {
151
+ const observations = await sandbox.metrics();
152
+ }
153
+ if (capabilities.observability.webhooks) {
154
+ const configured = await sandbox.webhook.configure('https://trusted-relay.example/callback', { replayFromCursor: 0 });
155
+ // Store configured.signingSecret securely, outside guest files/env and logs.
156
+ const { deliveries } = await sandbox.webhook.deliveries();
157
+ // await sandbox.webhook.retry(exhaustedEventId);
158
+ await sandbox.webhook.remove();
159
+ }
160
+ ```
161
+
162
+ For a receiving server, import `verifyWebhookSignature` and call `await verifyWebhookSignature(rawBodyBytes, signatureHeader, signingSecret)` before parsing JSON. It authenticates the exact bytes and a five-minute timestamp window. Persist received event IDs to reject duplicates; delivery can be out of order. The CLI also exposes generation-bound `events` and `metrics` reads.
163
+
164
+ Targets must be operator-controlled trusted HTTPS relay hosts; arbitrary customer domains, IPs, credentials, ports and redirects are unsupported. Configuration is per generation and expires after seven days. PUT rotates the secret and clears old attempts; it is never retried automatically. Reconcile lost responses, then rotate explicitly if the one-time secret is unavailable. Eight automatic attempts use bounded backoff; exhausted deliveries allow at most three manual retry cycles. Removing configuration cancels future attempts but cannot undo requests a receiver already accepted. See [API.md](https://mainbrella.com/API.md#lifecycle-webhooks) for retention and signature details. OTLP remains unsupported.
165
+
166
+ ## Outbound internet selection
167
+
168
+ Internet defaults to enabled. Offline creation is immutable for that generation and requires the advertised `networking.internetControl` capability. The SDK checks discovery before admission, fails if a runtime cannot enforce the policy, and confirms the returned selection. It never coerces a string to a boolean. This flag does not configure domain/CIDR allowlists or mediated secrets. Keep the same creation key on ambiguous transport failure; live provider isolation remains a release gate.
169
+
170
+ ```js
171
+ const offline = await client.create({ internet: false, idempotencyKey: "offline-workspace" });
172
+ ```
173
+
174
+ The CLI accepts `create --idempotency-key offline-workspace --internet false`.
175
+
176
+ The CLI also supports generation-bound `file list/stat/mkdir/remove/move/chmod`. Directory removal is nonrecursive unless `--recursive` is explicit; move refuses replacement. Permission modes use four octal digits (`0640`). Listing accepts `--limit` up to 1000 and `--offset` up to 1000000.
177
+
178
+ ## Saved workspaces
179
+
180
+ Check `(await client.capabilities()).persistence.snapshots` before using save/restore. Save explicitly; ordinary stop discards changes.
181
+
182
+ ```js
183
+ const saveKey = crypto.randomUUID(); // Persist this key and body before sending.
184
+ const saved = await sandbox.saveWorkspace('Project files', { stop: true, idempotencyKey: saveKey });
185
+ const restored = await client.workspaces.restore(saved.id, { idempotencyKey: crypto.randomUUID() });
186
+ const archive = await restored.exportWorkspace(); // /workspace gzip tar, at most 16 MiB compressed.
187
+ await restored.kill();
188
+ await client.workspaces.delete(saved.id);
189
+ ```
190
+
191
+ `client.workspaces.list()`, `.get(id)` and `.update(id, {name, archived})` manage saved metadata. Retry an ambiguous save with its original body and key (`error.idempotencyKey`). Restore consumes one start, requires the saved image digest, size and internet policy, and restores filesystem bytes with a fresh generation. RAM, processes and previews do not resume. Plan quotas and expiry apply; archive retains quota, while deletion revokes future restores without immediately erasing provider-held bytes.
package/cli.mjs ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { open, realpath, writeFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Mainbrella, MainbrellaError } from './index.js';
5
+
6
+ const help = `Mainbrella 0.1.0 — generation-bound container automation
7
+ Set MAINBRELLA_API_KEY in your environment. Optional MAINBRELLA_API_URL.
8
+
9
+ mainbrella capabilities | list
10
+ mainbrella create --idempotency-key KEY [--size lite|small|medium|large|xl] [--internet true|false] [--catalog-id ID | --image-id ID]
11
+ mainbrella kill --id ID --created-at ISO
12
+ mainbrella run --id ID --created-at ISO --command COMMAND [--timeout-ms N]
13
+ mainbrella start --id ID --created-at ISO --idempotency-key KEY (--command COMMAND | --argv-json JSON) [--stdin] [--cwd PATH] [--env-json JSON] [--pty-cols N --pty-rows N] [--timeout-ms N]
14
+ mainbrella jobs --id ID --created-at ISO
15
+ mainbrella events|metrics --id ID --created-at ISO [--cursor N --limit N | --from ISO --to ISO]
16
+ mainbrella job get|cancel|events|signal|resize|input|close --id ID --created-at ISO --execution-id UUID [--cursor N] [--signal SIGTERM] [--cols N --rows N] [--source FILE]
17
+ mainbrella file read|write --id ID --created-at ISO --path PATH (--output NEW_FILE | --source FILE)
18
+ mainbrella file list|stat --id ID --created-at ISO --path PATH [--limit N --offset N | --follow-symlinks]
19
+ mainbrella file mkdir --id ID --created-at ISO --path PATH [--recursive] [--mode 0700]
20
+ mainbrella file remove --id ID --created-at ISO --path PATH [--recursive]
21
+ mainbrella file move --id ID --created-at ISO --path PATH --destination PATH
22
+ mainbrella file chmod --id ID --created-at ISO --path PATH --mode 0640
23
+
24
+ Results are JSON; job events are newline-delimited JSON. Errors go to stderr.
25
+ Creation and start require a stable key. Preserve it after an ambiguous response.
26
+ kill affects only the supplied generation. Credentials are never command options.
27
+ `;
28
+ const specifications = {
29
+ capabilities: [], list: [], create: ['idempotency-key', 'internet', 'size', 'catalog-id', 'image-id'],
30
+ kill: [], run: ['command', 'timeout-ms'], start: ['idempotency-key', 'command', 'argv-json', 'timeout-ms', 'stdin', 'cwd', 'env-json', 'pty-cols', 'pty-rows'],
31
+ jobs: [], events: ['cursor', 'limit'], metrics: ['from', 'to'], 'job:get': ['execution-id'], 'job:cancel': ['execution-id'], 'job:events': ['execution-id', 'cursor'],
32
+ 'job:signal': ['execution-id', 'signal'], 'job:resize': ['execution-id', 'cols', 'rows'], 'job:input': ['execution-id', 'source'], 'job:close': ['execution-id'],
33
+ 'file:read': ['path', 'output'], 'file:write': ['path', 'source'],
34
+ 'file:list': ['path', 'limit', 'offset'], 'file:stat': ['path', 'follow-symlinks'],
35
+ 'file:mkdir': ['path', 'recursive', 'mode'], 'file:remove': ['path', 'recursive'], 'file:move': ['path', 'destination'], 'file:chmod': ['path', 'mode'],
36
+ };
37
+ const fail = () => { throw new MainbrellaError('invalid_cli_arguments'); };
38
+ function parse(argv) {
39
+ const remaining = [...argv];
40
+ let command = remaining.shift();
41
+ if (['job', 'file'].includes(command)) command += ':' + remaining.shift();
42
+ if (!Object.hasOwn(specifications, command)) fail();
43
+ const hasIdentity = !['capabilities', 'list', 'create'].includes(command);
44
+ const allowed = new Set([...specifications[command], 'base-url', ...(hasIdentity ? ['id', 'created-at'] : [])]);
45
+ const options = {};
46
+ while (remaining.length) {
47
+ const flag = remaining.shift();
48
+ if (!flag?.startsWith('--') || !allowed.has(flag.slice(2)) || Object.hasOwn(options, flag.slice(2))) fail();
49
+ const name = flag.slice(2);
50
+ if (['stdin', 'recursive', 'follow-symlinks'].includes(name)) options[name] = true;
51
+ else { if (!remaining.length) fail(); options[name] = remaining.shift(); }
52
+ }
53
+ const required = name => { if (typeof options[name] !== 'string' || !options[name]) fail(); return options[name]; };
54
+ const integer = (name, maximum) => {
55
+ if (options[name] === undefined) return undefined;
56
+ if (!/^\d+$/.test(options[name]) || !Number.isSafeInteger(Number(options[name])) || Number(options[name]) > maximum) fail();
57
+ return Number(options[name]);
58
+ };
59
+ const json = name => { try { return JSON.parse(required(name)); } catch { fail(); } };
60
+ if (hasIdentity) { required('id'); required('created-at'); }
61
+ if (command === 'create' || command === 'start') { if (!/^[A-Za-z0-9_-]{1,128}$/.test(required('idempotency-key'))) fail(); }
62
+ if (options.internet !== undefined && !['true', 'false'].includes(options.internet)) fail();
63
+ if (command.startsWith('job:')) required('execution-id');
64
+ if (command === 'run') required('command');
65
+ if (command === 'start' && Boolean(options.command) === Boolean(options['argv-json'])) fail();
66
+ if (command === 'start' && Boolean(options['pty-cols']) !== Boolean(options['pty-rows'])) fail();
67
+ if (command.startsWith('file:')) required('path');
68
+ if (command === 'file:read') required('output');
69
+ if (command === 'file:write') required('source');
70
+ if (command === 'file:move') required('destination');
71
+ if (command === 'file:chmod') required('mode');
72
+ if (options.mode !== undefined && !/^0[0-7]{3}$/.test(options.mode)) fail();
73
+ if (command === 'job:input') required('source');
74
+ if (command === 'job:signal') required('signal');
75
+ if (command === 'job:resize') { required('cols'); required('rows'); }
76
+ const timeoutMs = integer('timeout-ms', command === 'run' ? 60_000 : 900_000);
77
+ if (timeoutMs === 0) fail();
78
+ const pty = options['pty-cols'] ? { cols: integer('pty-cols', 1000), rows: integer('pty-rows', 1000) } : undefined;
79
+ if (pty && (!pty.cols || !pty.rows || !options.stdin)) fail();
80
+ let argvValues, envValues;
81
+ if (options['argv-json']) { argvValues = json('argv-json'); if (!Array.isArray(argvValues) || !argvValues.length || !argvValues.every(value => typeof value === 'string')) fail(); }
82
+ if (options['env-json']) { envValues = json('env-json'); if (!envValues || typeof envValues !== 'object' || Array.isArray(envValues) || !Object.values(envValues).every(value => typeof value === 'string')) fail(); }
83
+ return { command, options, timeoutMs, pty, argvValues, envValues, cursor: integer('cursor', Number.MAX_SAFE_INTEGER), limit: integer('limit', command === 'file:list' ? 1000 : 100), offset: integer('offset', 1_000_000),
84
+ cols: integer('cols', 1000), rows: integer('rows', 1000) };
85
+ }
86
+
87
+ async function sourceBytes(path, limit) {
88
+ const file = await open(path, 'r');
89
+ try {
90
+ const stat = await file.stat();
91
+ if (!stat.isFile()) throw new MainbrellaError('invalid_local_file');
92
+ if (stat.size > limit) throw new MainbrellaError('input_too_large');
93
+ const bytes = new Uint8Array(limit + 1); let offset = 0;
94
+ while (offset < bytes.length) {
95
+ const { bytesRead } = await file.read(bytes, offset, bytes.length - offset, null);
96
+ if (!bytesRead) break;
97
+ offset += bytesRead;
98
+ }
99
+ if (offset > limit) throw new MainbrellaError('input_too_large');
100
+ return bytes.subarray(0, offset);
101
+ } finally { await file.close(); }
102
+ }
103
+
104
+ export async function main(argv = process.argv.slice(2), { env = process.env, stdout = process.stdout, stderr = process.stderr, fetch = globalThis.fetch } = {}) {
105
+ const print = value => stdout.write(JSON.stringify(value) + '\n');
106
+ try {
107
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) { stdout.write(help); return 0; }
108
+ if (argv.length === 1 && argv[0] === '--version') { stdout.write('0.1.0\n'); return 0; }
109
+ const { command, options, timeoutMs, pty, argvValues, envValues, cursor, limit, offset, cols, rows } = parse(argv);
110
+ const client = new Mainbrella({ apiKey: env.MAINBRELLA_API_KEY, baseUrl: options['base-url'] || env.MAINBRELLA_API_URL, fetch });
111
+ if (command === 'capabilities' || command === 'list') { print(await client[command]()); return 0; }
112
+ if (command === 'create') {
113
+ const sandbox = await client.create({ idempotencyKey: options['idempotency-key'], size: options.size, internet: options.internet === undefined ? undefined : options.internet === 'true', catalogId: options['catalog-id'], imageId: options['image-id'] });
114
+ print({ id: sandbox.id, createdAt: sandbox.createdAt, creationId: sandbox.creationId, internet: sandbox.internet, imageDigest: sandbox.imageDigest, instance: sandbox.instance }); return 0;
115
+ }
116
+ const sandbox = client.connect({ id: options.id, createdAt: options['created-at'] });
117
+ if (command === 'kill') print(await sandbox.kill());
118
+ else if (command === 'jobs') print(await sandbox.commands.list());
119
+ else if (command === 'events') print(await sandbox.events({ cursor, limit }));
120
+ else if (command === 'metrics') print(await sandbox.metrics({ from: options.from, to: options.to }));
121
+ else if (command === 'run') {
122
+ const result = await sandbox.commands.run(options.command, { timeoutMs }); print(result);
123
+ return result.timedOut ? 124 : result.outputTruncated ? 125 : Number.isInteger(result.exitCode) && result.exitCode >= 0 && result.exitCode <= 255 ? result.exitCode : 1;
124
+ } else if (command === 'start') {
125
+ const job = await sandbox.commands.start(argvValues || options.command, { timeoutMs, stdin: options.stdin, cwd: options.cwd, env: envValues, pty, idempotencyKey: options['idempotency-key'] });
126
+ print({ id: job.id, containerId: sandbox.id, createdAt: sandbox.createdAt, idempotencyKey: options['idempotency-key'] });
127
+ } else if (command === 'file:read') {
128
+ const bytes = await sandbox.files.read(options.path); await writeFile(options.output, bytes, { flag: 'wx', mode: 0o600 }); print({ path: options.path, bytes: bytes.byteLength, output: options.output });
129
+ } else if (command === 'file:write') print(await sandbox.files.write(options.path, await sourceBytes(options.source, 1024 * 1024)));
130
+ else if (command === 'file:list') print(await sandbox.files.list(options.path, { limit, offset }));
131
+ else if (command === 'file:stat') print(await sandbox.files.stat(options.path, { followSymlinks: options['follow-symlinks'] ?? false }));
132
+ else if (command === 'file:mkdir') print(await sandbox.files.mkdir(options.path, { recursive: options.recursive ?? false, mode: options.mode }));
133
+ else if (command === 'file:remove') print(await sandbox.files.remove(options.path, { recursive: options.recursive ?? false }));
134
+ else if (command === 'file:move') print(await sandbox.files.move(options.path, options.destination));
135
+ else if (command === 'file:chmod') print(await sandbox.files.chmod(options.path, options.mode));
136
+ else {
137
+ const job = sandbox.commands.attach(options['execution-id']);
138
+ const action = command.split(':')[1];
139
+ if (action === 'events') { for await (const event of job.events({ cursor })) print(event); }
140
+ else if (action === 'signal') print(await job.signal(options.signal));
141
+ else if (action === 'resize') print(await job.resize(cols, rows));
142
+ else if (action === 'input') print(await job.stdin.write(await sourceBytes(options.source, 64 * 1024)));
143
+ else if (action === 'close') print(await job.stdin.close());
144
+ else print(await job[action]());
145
+ }
146
+ return 0;
147
+ } catch (error) {
148
+ const code = error instanceof MainbrellaError ? error.code : error?.code === 'EEXIST' ? 'output_exists' : error?.code === 'ENOENT' ? 'local_file_not_found' : 'cli_failed';
149
+ stderr.write(JSON.stringify({ error: code, ...(error instanceof MainbrellaError && error.status ? { status: error.status } : {}),
150
+ ...(error?.idempotencyKey && /^[A-Za-z0-9_-]{1,128}$/.test(error.idempotencyKey) ? { idempotencyKey: error.idempotencyKey } : {}) }) + '\n');
151
+ return 1;
152
+ }
153
+ }
154
+
155
+ if (process.argv[1] && await realpath(process.argv[1]).catch(() => '') === fileURLToPath(import.meta.url)) process.exitCode = await main();
package/index.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ export type MachineSize = 'lite' | 'small' | 'medium' | 'large' | 'xl';
2
+ export interface Size { id: MachineSize; name: string; instance: string; cpuVcpu: number; memoryMiB: number; diskGB: number; computeUnits: number }
3
+ export interface ContainerIdentity { id: string; createdAt: string }
4
+ export interface Workspace {id:string;name:string;createdAt:string;expiresAt:number;source:ContainerIdentity;size:MachineSize;internet:boolean;imageDigest:string;imageId?:string;imageName?:string;catalogId?:string;bytes:number|null;archived:boolean;status:'saving'|'ready'|'failed'|'expired'|'deleted';stopRequested:boolean;stopCompleted:boolean}
5
+ export interface FileEntry { name: string; path: string; type: 'file' | 'directory' | 'symlink' | 'fifo' | 'socket' | 'character' | 'block' | 'other'; size: number; mode: string; uid: number; gid: number; modifiedAt: string; linkTarget?: string }
6
+ export interface DirectoryPage { path: string; entries: FileEntry[]; nextOffset: number | null }
7
+ export interface ExecutionOptions { timeoutMs?: number; idempotencyKey?: string; stdin?: boolean; cwd?: string; env?: Record<string, string>; pty?: { cols: number; rows: number } }
8
+ export interface Preview { id: string; port: number; createdAt: string; expiresAt: number }
9
+ export interface PreviewLink extends Preview { url: string }
10
+ export interface CommandResult { stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; outputTruncated: boolean }
11
+ export interface Container extends ContainerIdentity { internet?: boolean; status: 'starting' | 'running'; expiresAt: string; imageName?: string; catalogId?: string; imageId?: string; imageDigest?: string; instance?: string; size?: MachineSize; computeUnits?: number }
12
+ export interface AccountState { plan: 'builder' | 'pro' | 'scale' | null; active: boolean; containers: Container[];
13
+ sizes: Size[]; imageCatalog: { id: string; name: string }[]; limits: { maxComputeUnitHours: number; maxConcurrentComputeUnits: number; maxContainers: number; maxStartsPerMonth: number; maxSessionMs: number; idleTimeoutMs: number };
14
+ usage: { month: string; starts: number; computeUnitHours: number; reservedComputeUnitHours: number; availableComputeUnitHours: number; concurrentComputeUnits: number } }
15
+ export interface Capabilities {
16
+ apiVersion: string;
17
+ execution: { foreground: boolean; streaming: boolean; background: boolean; cancellation: boolean; reconnect: boolean; pty: boolean;
18
+ programmaticPty: boolean; ptyResize: boolean; stdin: boolean; signals: boolean; argv: boolean; managedProcessListing: boolean; processListing: boolean;
19
+ maxStdinChunkBytes: number; maxStdinBytes: number; maxPendingStdinBytes: number;
20
+ maxCommandBytes: number; maxTimeoutMs: number; maxOutputBytes: number; maxConcurrentOperations: number;
21
+ maxManagedTimeoutMs: number; retentionMs: number; maxRetainedExecutions: number };
22
+ files: { read: boolean; write: boolean; binary: boolean; maxFileBytes: number; maxPathBytes: number; timeoutMs: number; [key: string]: boolean | number };
23
+ persistence: { filesystemAfterStop: boolean; snapshots: boolean; workspaces?:boolean; exports?:boolean; memory: boolean; volumes: boolean };
24
+ observability: { lifecycleEvents: boolean; metrics: boolean; webhooks: boolean; otlp: boolean; eventRetentionMs: number; maxLifecycleEvents: number; maxMetricRangeMs: number; metricBucketMs: number };
25
+ previews: { supported: boolean; signedUrls: boolean };
26
+ containers: { idempotentCreate: boolean; creationRetentionMs: number; generationRequired: boolean; accountLimitsPath: string; configurableDeadline: boolean };
27
+ resources: Size[];
28
+ images: { catalog: boolean; customBuilds: boolean; availableCatalogPath: string; limits: Record<string, number> };
29
+ authentication: Record<string, boolean>; networking: Record<string, boolean>; access: Record<string, number>;
30
+ }
31
+ export class MainbrellaError extends Error { code: string; status: number; idempotencyKey?: string; cursor?: number; previewId?: string; constructor(code: string, status?: number, details?: Record<string, unknown>) }
32
+ export class Mainbrella {
33
+ constructor(options: { apiKey: string; baseUrl?: string; fetch?: typeof fetch; timeoutMs?: number });
34
+ baseUrl: string; timeoutMs: number;
35
+ workspaces:{list():Promise<{workspaces:Workspace[];limits:{maxSaved:number;maxReservedBytes:number;retentionMs:number;maxSavesPerMonth:number}|null;usage:{saved:number;reservedBytes:number}}>;
36
+ get(id:string):Promise<Workspace>;update(id:string,options:{name?:string;archived?:boolean}):Promise<Workspace>;delete(id:string):Promise<{deleted:true}>;
37
+ restore(id:string,options?:{idempotencyKey?:string;waitTimeoutMs?:number;pollIntervalMs?:number}):Promise<Sandbox>};
38
+ request<T = unknown>(path: string, options?: { method?: string; body?: unknown; headers?: Record<string, string>; binary?: boolean; stream?: boolean; signal?: AbortSignal }): Promise<T>;
39
+ capabilities(): Promise<Capabilities>; list(): Promise<AccountState>; connect(value: ContainerIdentity): Sandbox;
40
+ create(options?: { catalogId?: string; imageId?: string; workspaceId?:string; size?: MachineSize; internet?: boolean; idempotencyKey?: string; waitTimeoutMs?: number; pollIntervalMs?: number }): Promise<Sandbox>;
41
+ }
42
+ export class Sandbox implements ContainerIdentity {
43
+ internet?: boolean;
44
+ constructor(client: Mainbrella, value: ContainerIdentity);
45
+ client: Mainbrella; id: string; createdAt: string; creationId?: string; imageDigest?: string; instance?: string;
46
+ files: { read(path: string): Promise<Uint8Array>; write(path: string, bytes: Uint8Array): Promise<{ path: string; size: number }>;
47
+ list(path: string, options?: { limit?: number; offset?: number }): Promise<DirectoryPage>;
48
+ stat(path: string, options?: { followSymlinks?: boolean }): Promise<FileEntry>;
49
+ mkdir(path: string, options?: { recursive?: boolean; mode?: string }): Promise<{ path: string; ok: true }>;
50
+ remove(path: string, options?: { recursive?: boolean }): Promise<{ path: string; ok: true }>;
51
+ move(path: string, destination: string): Promise<{ path: string; destination: string; ok: true }>;
52
+ chmod(path: string, mode: string): Promise<{ path: string; mode: string; ok: true }> };
53
+ previews: { create(port: number, options?: { ttlSeconds?: number }): Promise<PreviewLink>;
54
+ list(): Promise<{ previews: Preview[] }>; revoke(previewId: string): Promise<{ revoked: true }> };
55
+ commands: { run(command: string, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<CommandResult>;
56
+ start(command: string | string[], options?: ExecutionOptions): Promise<Execution>;
57
+ list(): Promise<{ executions: Omit<ExecutionRecord, 'stdout' | 'stderr'>[] }>; attach(id: string): Execution };
58
+ kill(): Promise<AccountState>;
59
+ workspaceId?:string;
60
+ saveWorkspace(name:string,options?:{stop?:boolean;idempotencyKey?:string}):Promise<Workspace>;
61
+ exportWorkspace():Promise<Uint8Array>;
62
+ webhook: { get(): Promise<{webhook: WebhookConfig | null}>; configure(url: string, options?: {replayFromCursor?: number}): Promise<{webhook: WebhookConfig; signingSecret: string}>;
63
+ remove(): Promise<{removed: true}>; deliveries(): Promise<{deliveries: WebhookDelivery[]}>; retry(eventId: string): Promise<WebhookDelivery> };
64
+ events(options?: {cursor?: number; limit?: number}): Promise<LifecyclePage>;
65
+ metrics(options?: {from?: string; to?: string}): Promise<WorkloadMetrics>;
66
+ }
67
+ export interface LifecycleEvent { id: string; sequence: number; createdAt: string; occurredAt: string; type: 'starting' | 'started' | 'failed' | 'stopped'; reason?: string; size: MachineSize; retainUntil: number }
68
+ export interface LifecyclePage { events: LifecycleEvent[]; nextCursor: number; hasMore: boolean; historyTruncated: boolean; retainForMs: number }
69
+ export interface WorkloadMetrics { id: string; createdAt: string; from: string; to: string; bucketMs: number; source: 'cloudflare-workload-analytics'; state: 'observed' | 'unobserved'; buckets: {at: string; samples: number; cpuSeconds: number | null; memoryPeakBytes: number | null; diskUsagePeak: number | null}[] }
70
+ export const terminalExecutionStates: Set<string>;
71
+ export function verifyWebhookSignature(bytes: Uint8Array, signature: string, signingSecret: string, options?: {nowMs?: number; toleranceSeconds?: number}): Promise<boolean>;
72
+ export interface WebhookConfig { id: string; url: string; createdAt: string; configuredAt: string; retainUntil: number }
73
+ export interface WebhookDelivery { id: string; sequence: number; status: 'pending' | 'sending' | 'delivered' | 'exhausted'; attempts: number; manualRetries: number; nextAt: number | null; retainUntil: number; lastAttemptAt?: number; httpStatus?: number | null }
74
+ export interface ExecutionRecord extends CommandResult {
75
+ id: string; createdAt: string; startedAt: string; finishedAt?: string; retainUntil: number; cursor: number; outputBytes: number;
76
+ status: 'starting' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out' | 'output_limit' | 'interrupted';
77
+ pty?: { cols: number; rows: number }; stdinEnabled?: boolean; stdinClosed?: boolean; stdinBytes?: number;
78
+ }
79
+ export type ExecutionEvent = { type: 'stdout' | 'stderr'; data: string; sequence: number } | { type: 'status'; execution: Omit<ExecutionRecord, 'stdout' | 'stderr'> };
80
+ export class Execution {
81
+ constructor(sandbox: Sandbox, id: string); id: string; cursor: number;
82
+ get(): Promise<ExecutionRecord>; cancel(): Promise<Omit<ExecutionRecord, 'stdout' | 'stderr'>>;
83
+ resize(cols: number, rows: number): Promise<Omit<ExecutionRecord, 'stdout' | 'stderr'>>;
84
+ signal(signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): Promise<Omit<ExecutionRecord, 'stdout' | 'stderr'>>;
85
+ stdin: { write(bytes: Uint8Array): Promise<{ bytes: number; stdinClosed: boolean }>; close(): Promise<{ bytes: number; stdinClosed: boolean }> };
86
+ wait(options?: { timeoutMs?: number; pollIntervalMs?: number }): Promise<ExecutionRecord>;
87
+ events(options?: { cursor?: number; signal?: AbortSignal }): AsyncGenerator<ExecutionEvent>;
88
+ }