@watasu/sdk 0.1.5 → 0.1.6

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
@@ -26,6 +26,52 @@ await sbx.kill()
26
26
  `Sandbox.create` and `Sandbox.connect` return only after the Watasu API supplies
27
27
  a usable data-plane session. The SDK does not poll sandbox readiness.
28
28
 
29
+ ## Git, Watch, PTY, And Signed File URLs
30
+
31
+ ```ts
32
+ const sbx = await Sandbox.create()
33
+
34
+ await sbx.git.clone('https://github.com/acme/project.git', {
35
+ path: '/workspace/project',
36
+ branch: 'main',
37
+ depth: 1,
38
+ })
39
+ const status = await sbx.git.status('/workspace/project')
40
+ await sbx.git.configureUser('Watasu Bot', 'bot@watasu.local', {
41
+ scope: 'local',
42
+ path: '/workspace/project',
43
+ })
44
+ await sbx.git.createBranch('/workspace/project', 'feature/docs')
45
+ await sbx.git.add('/workspace/project', { files: ['README.md'] })
46
+ await sbx.git.commit('/workspace/project', 'Update docs', {
47
+ authorName: 'Watasu Bot',
48
+ authorEmail: 'bot@watasu.local',
49
+ })
50
+ await sbx.git.push('/workspace/project', {
51
+ remote: 'origin',
52
+ branch: 'feature/docs',
53
+ setUpstream: true,
54
+ })
55
+
56
+ const watcher = await sbx.files.watchDir('/workspace/project', (event) => {
57
+ console.log(event.type, event.path)
58
+ }, { recursive: true })
59
+
60
+ const terminal = await sbx.pty.create({
61
+ cols: 100,
62
+ rows: 30,
63
+ onData: (data) => process.stdout.write(data),
64
+ })
65
+ await terminal.sendStdin('echo hello\n')
66
+
67
+ const uploadUrl = await sbx.uploadUrl('/workspace/input.bin')
68
+ const downloadUrl = await sbx.downloadUrl('/workspace/output.bin')
69
+
70
+ watcher.stop()
71
+ await terminal.kill()
72
+ await sbx.kill()
73
+ ```
74
+
29
75
  ## Metrics And Snapshots
30
76
 
31
77
  ```ts
@@ -36,6 +82,7 @@ const metrics = await sbx.getMetrics()
36
82
  const snapshot = await sbx.createSnapshot({ name: 'ready' })
37
83
  const snapshots = await sbx.listSnapshots().nextItems()
38
84
  const restored = await sbx.restore({ snapshotId: snapshot.snapshotId })
85
+ await sbx.deleteSnapshot(snapshot.snapshotId)
39
86
  await sbx.kill()
40
87
  ```
41
88
 
@@ -34,6 +34,7 @@ export interface CommandStartOpts {
34
34
  envs?: Record<string, string>;
35
35
  onStdout?: (data: string) => void | Promise<void>;
36
36
  onStderr?: (data: string) => void | Promise<void>;
37
+ onPty?: (data: Uint8Array) => void | Promise<void>;
37
38
  stdin?: boolean;
38
39
  timeoutMs?: number;
39
40
  requestTimeoutMs?: number;
@@ -46,11 +47,12 @@ export declare class CommandHandle implements Partial<CommandResult> {
46
47
  private readonly events;
47
48
  private readonly onStdout?;
48
49
  private readonly onStderr?;
50
+ private readonly onPty?;
49
51
  private _stdout;
50
52
  private _stderr;
51
53
  private result?;
52
54
  private readonly pending;
53
- constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined);
55
+ constructor(pid: number | string, socket: ProcessSocket, handleKill: () => Promise<boolean>, events: AsyncIterable<ProcessFrame>, onStdout?: ((data: string) => void | Promise<void>) | undefined, onStderr?: ((data: string) => void | Promise<void>) | undefined, onPty?: ((data: Uint8Array) => void | Promise<void>) | undefined);
54
56
  get stdout(): string;
55
57
  get stderr(): string;
56
58
  get exitCode(): number | undefined;
@@ -61,6 +63,11 @@ export declare class CommandHandle implements Partial<CommandResult> {
61
63
  kill(): Promise<boolean>;
62
64
  /** Send stdin bytes or text to the process. */
63
65
  sendStdin(data: string | Uint8Array): Promise<void>;
66
+ /** Resize the attached PTY stream when this handle was created as a PTY. */
67
+ resize(size: {
68
+ cols: number;
69
+ rows: number;
70
+ }): Promise<void>;
64
71
  /** Detach the local stream without killing the process. */
65
72
  disconnect(): void;
66
73
  private handleEvents;
package/dist/commands.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ProcessSocket, base64DecodeText } from './processSocket.js';
1
+ import { ProcessSocket, base64DecodeBytes, base64DecodeText } from './processSocket.js';
2
2
  import { SandboxError } from './errors.js';
3
3
  /** Error thrown by `CommandHandle.wait()` when a process exits non-zero. */
4
4
  export class CommandExitError extends SandboxError {
@@ -21,17 +21,19 @@ export class CommandHandle {
21
21
  events;
22
22
  onStdout;
23
23
  onStderr;
24
+ onPty;
24
25
  _stdout = '';
25
26
  _stderr = '';
26
27
  result;
27
28
  pending;
28
- constructor(pid, socket, handleKill, events, onStdout, onStderr) {
29
+ constructor(pid, socket, handleKill, events, onStdout, onStderr, onPty) {
29
30
  this.pid = pid;
30
31
  this.socket = socket;
31
32
  this.handleKill = handleKill;
32
33
  this.events = events;
33
34
  this.onStdout = onStdout;
34
35
  this.onStderr = onStderr;
36
+ this.onPty = onPty;
35
37
  this.pending = this.handleEvents();
36
38
  }
37
39
  get stdout() { return this._stdout; }
@@ -55,6 +57,10 @@ export class CommandHandle {
55
57
  async sendStdin(data) {
56
58
  this.socket.sendStdin(data);
57
59
  }
60
+ /** Resize the attached PTY stream when this handle was created as a PTY. */
61
+ async resize(size) {
62
+ this.socket.sendJson({ type: 'resize', cols: size.cols, rows: size.rows });
63
+ }
58
64
  /** Detach the local stream without killing the process. */
59
65
  disconnect() {
60
66
  this.socket.close();
@@ -75,6 +81,12 @@ export class CommandHandle {
75
81
  this._stderr += out;
76
82
  await this.onStderr?.(out);
77
83
  }
84
+ else if (type === 'pty') {
85
+ const bytes = base64DecodeBytes(frame.data);
86
+ const out = new TextDecoder().decode(bytes);
87
+ this._stdout += out;
88
+ await this.onPty?.(bytes);
89
+ }
78
90
  else if (type === 'exit') {
79
91
  this.result = {
80
92
  exitCode: Number(frame.exit_code ?? frame.exitCode ?? 0),
@@ -140,7 +152,7 @@ export class Commands {
140
152
  const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, `/runtime/v1/process/${pid}/connect?since=0`, opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
141
153
  const first = await nextStarted(socket);
142
154
  const actualPid = framePid(first) ?? pid;
143
- return new CommandHandle(actualPid, socket, () => this.kill(actualPid), socket, opts.onStdout, opts.onStderr);
155
+ return new CommandHandle(actualPid, socket, () => this.kill(actualPid), socket, opts.onStdout, opts.onStderr, opts.onPty);
144
156
  }
145
157
  async start(cmd, opts) {
146
158
  const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, '/runtime/v1/process', opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
@@ -160,7 +172,7 @@ export class Commands {
160
172
  const pid = framePid(first);
161
173
  if (pid === undefined)
162
174
  throw new SandboxError('process started frame did not include pid');
163
- return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), opts.onStdout, opts.onStderr);
175
+ return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), opts.onStdout, opts.onStderr, opts.onPty);
164
176
  }
165
177
  }
166
178
  async function nextStarted(events) {
@@ -1,4 +1,5 @@
1
1
  import { DataPlaneClient } from './transport.js';
2
+ import { ProcessFrame, ProcessSocket } from './processSocket.js';
2
3
  export declare enum FileType {
3
4
  /** Regular file. */
4
5
  FILE = "file",
@@ -20,6 +21,31 @@ export interface EntryInfo {
20
21
  metadata?: Record<string, string>;
21
22
  }
22
23
  export type WriteInfo = EntryInfo;
24
+ export interface FilesystemEvent {
25
+ type: 'create' | 'write' | 'modify' | 'remove' | 'delete' | 'rename' | string;
26
+ path: string;
27
+ entry?: EntryInfo;
28
+ raw: Record<string, unknown>;
29
+ }
30
+ export interface WatchOpts {
31
+ recursive?: boolean;
32
+ includeEntry?: boolean;
33
+ requestTimeoutMs?: number;
34
+ onExit?: (error?: Error) => void | Promise<void>;
35
+ }
36
+ /** Live filesystem watcher. Call `stop()` to close the local watch stream. */
37
+ export declare class WatchHandle {
38
+ private readonly socket;
39
+ private readonly done;
40
+ constructor(socket: ProcessSocket, events: AsyncIterable<ProcessFrame>, onEvent: (event: FilesystemEvent) => void | Promise<void>, onExit?: (error?: Error) => void | Promise<void>);
41
+ /** Stop watching the directory. */
42
+ stop(): void;
43
+ /** Alias for `stop`. */
44
+ close(): void;
45
+ /** Resolves when the watcher stream exits. */
46
+ wait(): Promise<void>;
47
+ private pump;
48
+ }
23
49
  /** Filesystem helper for a sandbox data-plane session. */
24
50
  export declare class Filesystem {
25
51
  private readonly dataPlane;
@@ -61,5 +87,6 @@ export declare class Filesystem {
61
87
  makeDir(path: string, opts?: {
62
88
  requestTimeoutMs?: number;
63
89
  }): Promise<boolean>;
64
- watchDir(): never;
90
+ /** Start watching a directory for filesystem events. */
91
+ watchDir(path: string, onEvent: (event: FilesystemEvent) => void | Promise<void>, opts?: WatchOpts): Promise<WatchHandle>;
65
92
  }
@@ -1,5 +1,6 @@
1
1
  import { withQuery } from './transport.js';
2
- import { FileNotFoundError, unsupported } from './errors.js';
2
+ import { FileNotFoundError } from './errors.js';
3
+ import { ProcessSocket } from './processSocket.js';
3
4
  export var FileType;
4
5
  (function (FileType) {
5
6
  /** Regular file. */
@@ -9,6 +10,46 @@ export var FileType;
9
10
  /** Symbolic link. */
10
11
  FileType["SYMLINK"] = "symlink";
11
12
  })(FileType || (FileType = {}));
13
+ /** Live filesystem watcher. Call `stop()` to close the local watch stream. */
14
+ export class WatchHandle {
15
+ socket;
16
+ done;
17
+ constructor(socket, events, onEvent, onExit) {
18
+ this.socket = socket;
19
+ this.done = this.pump(events, onEvent, onExit);
20
+ }
21
+ /** Stop watching the directory. */
22
+ stop() {
23
+ this.socket.close();
24
+ }
25
+ /** Alias for `stop`. */
26
+ close() {
27
+ this.stop();
28
+ }
29
+ /** Resolves when the watcher stream exits. */
30
+ wait() {
31
+ return this.done;
32
+ }
33
+ async pump(events, onEvent, onExit) {
34
+ let error;
35
+ try {
36
+ for await (const frame of events) {
37
+ if (frame.type !== 'events' || !Array.isArray(frame.events))
38
+ continue;
39
+ for (const item of frame.events) {
40
+ await onEvent(filesystemEvent(item));
41
+ }
42
+ }
43
+ }
44
+ catch (caught) {
45
+ error = caught instanceof Error ? caught : new Error(String(caught));
46
+ throw error;
47
+ }
48
+ finally {
49
+ await onExit?.(error);
50
+ }
51
+ }
52
+ }
12
53
  /** Filesystem helper for a sandbox data-plane session. */
13
54
  export class Filesystem {
14
55
  dataPlane;
@@ -70,8 +111,10 @@ export class Filesystem {
70
111
  await this.dataPlane.postJson(withQuery('/runtime/v1/directories', { path }), opts);
71
112
  return true;
72
113
  }
73
- watchDir() {
74
- unsupported('sandbox.files.watchDir');
114
+ /** Start watching a directory for filesystem events. */
115
+ async watchDir(path, onEvent, opts = {}) {
116
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, withQuery('/runtime/v1/files/watch', { path, recursive: opts.recursive ?? false, include_entry: opts.includeEntry }), opts.requestTimeoutMs).connect();
117
+ return new WatchHandle(socket, socket, onEvent, opts.onExit);
75
118
  }
76
119
  }
77
120
  function entryInfo(value) {
@@ -96,3 +139,19 @@ function recordOfStrings(value) {
96
139
  return undefined;
97
140
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, String(item)]));
98
141
  }
142
+ function filesystemEvent(value) {
143
+ const item = value && typeof value === 'object' ? value : {};
144
+ return {
145
+ type: normalizeEventType(String(item.type ?? 'modify')),
146
+ path: String(item.path ?? ''),
147
+ entry: item.file && typeof item.file === 'object' ? entryInfo(item.file) : undefined,
148
+ raw: item,
149
+ };
150
+ }
151
+ function normalizeEventType(value) {
152
+ if (value === 'delete')
153
+ return 'remove';
154
+ if (value === 'modify')
155
+ return 'write';
156
+ return value;
157
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { DataPlaneClient } from './transport.js';
2
+ export interface GitCommandResult {
3
+ path?: string;
4
+ url?: string;
5
+ ref?: string;
6
+ branch?: string;
7
+ remote?: string;
8
+ name?: string;
9
+ value?: string;
10
+ branches?: string[];
11
+ currentBranch?: string;
12
+ stdout: string;
13
+ stderr: string;
14
+ command?: Record<string, unknown>;
15
+ raw: Record<string, unknown>;
16
+ }
17
+ export interface GitAuthOpts {
18
+ username?: string;
19
+ password?: string;
20
+ envs?: Record<string, string>;
21
+ timeout?: number;
22
+ timeoutMs?: number;
23
+ requestTimeoutMs?: number;
24
+ }
25
+ export interface GitCloneOpts extends GitAuthOpts {
26
+ path?: string;
27
+ branch?: string;
28
+ depth?: number;
29
+ recursive?: boolean;
30
+ submodules?: boolean;
31
+ dangerouslyStoreCredentials?: boolean;
32
+ }
33
+ export interface GitRequestOpts extends GitAuthOpts {
34
+ }
35
+ export interface GitPullOpts extends GitAuthOpts {
36
+ branch?: string;
37
+ remote?: string;
38
+ }
39
+ export interface GitPushOpts extends GitAuthOpts {
40
+ branch?: string;
41
+ remote?: string;
42
+ setUpstream?: boolean;
43
+ }
44
+ export interface GitCredentialOpts extends GitRequestOpts {
45
+ host?: string;
46
+ protocol?: string;
47
+ }
48
+ export interface GitConfigureUserOpts extends GitRequestOpts {
49
+ scope?: 'global' | 'local';
50
+ path?: string;
51
+ }
52
+ export interface GitBranchOpts extends GitRequestOpts {
53
+ force?: boolean;
54
+ }
55
+ export interface GitAddOpts extends GitRequestOpts {
56
+ files?: string[];
57
+ }
58
+ export interface GitCommitOpts extends GitRequestOpts {
59
+ authorName?: string;
60
+ authorEmail?: string;
61
+ allowEmpty?: boolean;
62
+ }
63
+ export interface GitRemoteAddOpts extends GitRequestOpts {
64
+ fetch?: boolean;
65
+ overwrite?: boolean;
66
+ }
67
+ export interface GitConfigOpts extends GitRequestOpts {
68
+ scope?: 'global' | 'local';
69
+ path?: string;
70
+ }
71
+ export interface GitBranches {
72
+ path?: string;
73
+ branches: string[];
74
+ currentBranch?: string;
75
+ result: GitCommandResult;
76
+ }
77
+ export interface GitFileStatus {
78
+ name: string;
79
+ status: string;
80
+ indexStatus: string;
81
+ workingTreeStatus: string;
82
+ staged: boolean;
83
+ renamedFrom?: string;
84
+ }
85
+ export interface GitStatus {
86
+ currentBranch?: string;
87
+ upstream?: string;
88
+ ahead: number;
89
+ behind: number;
90
+ detached: boolean;
91
+ fileStatus: GitFileStatus[];
92
+ isClean: boolean;
93
+ hasChanges: boolean;
94
+ hasStaged: boolean;
95
+ hasUntracked: boolean;
96
+ hasConflicts: boolean;
97
+ totalCount: number;
98
+ stagedCount: number;
99
+ unstagedCount: number;
100
+ untrackedCount: number;
101
+ conflictCount: number;
102
+ result: GitCommandResult;
103
+ }
104
+ /** Git helper backed by sandbox data-plane routes. */
105
+ export declare class Git {
106
+ private readonly dataPlane;
107
+ constructor(dataPlane: DataPlaneClient);
108
+ /** Clone a repository into the sandbox. */
109
+ clone(url: string, opts?: GitCloneOpts): Promise<GitCommandResult>;
110
+ /** Store Git credentials in the sandbox credential helper. */
111
+ dangerouslyAuthenticate(opts: GitCredentialOpts & {
112
+ username: string;
113
+ password: string;
114
+ }): Promise<GitCommandResult>;
115
+ /** Configure Git author identity globally or for one repository. */
116
+ configureUser(name: string, email: string, opts?: GitConfigureUserOpts): Promise<GitCommandResult>;
117
+ /** Return parsed repository status for `path`. */
118
+ status(path: string, opts?: GitRequestOpts): Promise<GitStatus>;
119
+ /** Return branches and the current branch for `path`. */
120
+ branches(path: string, opts?: GitRequestOpts): Promise<GitBranches>;
121
+ /** Create and check out a new branch. */
122
+ createBranch(path: string, branch: string, opts?: GitRequestOpts): Promise<GitCommandResult>;
123
+ /** Delete a branch. */
124
+ deleteBranch(path: string, branch: string, opts?: GitBranchOpts): Promise<GitCommandResult>;
125
+ /** Stage files. Defaults to all files. */
126
+ add(path: string, opts?: GitAddOpts): Promise<GitCommandResult>;
127
+ /** Commit staged files. */
128
+ commit(path: string, message: string, opts?: GitCommitOpts): Promise<GitCommandResult>;
129
+ /** Pull the current branch with a fast-forward-only merge. */
130
+ pull(path: string, opts?: GitPullOpts): Promise<GitCommandResult>;
131
+ /** Push the current branch or a selected branch. */
132
+ push(path: string, opts?: GitPushOpts): Promise<GitCommandResult>;
133
+ /** Check out an arbitrary ref in a repository. */
134
+ checkout(path: string, ref: string, opts?: GitRequestOpts): Promise<GitCommandResult>;
135
+ /** Check out an existing branch in a repository. */
136
+ checkoutBranch(path: string, branch: string, opts?: GitRequestOpts): Promise<GitCommandResult>;
137
+ /** Add a remote. */
138
+ remoteAdd(path: string, name: string, url: string, opts?: GitRemoteAddOpts): Promise<GitCommandResult>;
139
+ /** Set a Git config value. */
140
+ setConfig(key: string, value: string, opts?: GitConfigOpts): Promise<GitCommandResult>;
141
+ /** Read a Git config value. */
142
+ getConfig(key: string, opts?: GitConfigOpts): Promise<string>;
143
+ private run;
144
+ }
package/dist/git.js ADDED
@@ -0,0 +1,236 @@
1
+ /** Git helper backed by sandbox data-plane routes. */
2
+ export class Git {
3
+ dataPlane;
4
+ constructor(dataPlane) {
5
+ this.dataPlane = dataPlane;
6
+ }
7
+ /** Clone a repository into the sandbox. */
8
+ async clone(url, opts = {}) {
9
+ return this.run('/runtime/v1/git/clone', {
10
+ url,
11
+ ...gitOpts(opts),
12
+ ...pick(opts, ['path', 'branch', 'depth', 'recursive', 'submodules', 'username', 'password']),
13
+ dangerously_store_credentials: opts.dangerouslyStoreCredentials,
14
+ }, opts);
15
+ }
16
+ /** Store Git credentials in the sandbox credential helper. */
17
+ async dangerouslyAuthenticate(opts) {
18
+ return this.run('/runtime/v1/git/dangerously_authenticate', {
19
+ ...gitOpts(opts),
20
+ username: opts.username,
21
+ password: opts.password,
22
+ host: opts.host,
23
+ protocol: opts.protocol,
24
+ }, opts);
25
+ }
26
+ /** Configure Git author identity globally or for one repository. */
27
+ async configureUser(name, email, opts = {}) {
28
+ return this.run('/runtime/v1/git/configure_user', {
29
+ ...gitOpts(opts),
30
+ name,
31
+ email,
32
+ scope: opts.scope,
33
+ path: opts.path,
34
+ }, opts);
35
+ }
36
+ /** Return parsed repository status for `path`. */
37
+ async status(path, opts = {}) {
38
+ const result = await this.run('/runtime/v1/git/status', { path, ...gitOpts(opts) }, opts);
39
+ return parseGitStatus(result);
40
+ }
41
+ /** Return branches and the current branch for `path`. */
42
+ async branches(path, opts = {}) {
43
+ const result = await this.run('/runtime/v1/git/branches', { path, ...gitOpts(opts) }, opts);
44
+ return {
45
+ path: result.path,
46
+ branches: Array.isArray(result.raw.branches) ? result.raw.branches.map(String) : result.branches ?? [],
47
+ currentBranch: stringValue(result.raw.current_branch) ?? result.currentBranch,
48
+ result,
49
+ };
50
+ }
51
+ /** Create and check out a new branch. */
52
+ async createBranch(path, branch, opts = {}) {
53
+ return this.run('/runtime/v1/git/create_branch', { path, branch, ...gitOpts(opts) }, opts);
54
+ }
55
+ /** Delete a branch. */
56
+ async deleteBranch(path, branch, opts = {}) {
57
+ return this.run('/runtime/v1/git/delete_branch', { path, branch, force: opts.force, ...gitOpts(opts) }, opts);
58
+ }
59
+ /** Stage files. Defaults to all files. */
60
+ async add(path, opts = {}) {
61
+ return this.run('/runtime/v1/git/add', { path, files: opts.files, ...gitOpts(opts) }, opts);
62
+ }
63
+ /** Commit staged files. */
64
+ async commit(path, message, opts = {}) {
65
+ return this.run('/runtime/v1/git/commit', {
66
+ path,
67
+ message,
68
+ author_name: opts.authorName,
69
+ author_email: opts.authorEmail,
70
+ allow_empty: opts.allowEmpty,
71
+ ...gitOpts(opts),
72
+ }, opts);
73
+ }
74
+ /** Pull the current branch with a fast-forward-only merge. */
75
+ async pull(path, opts = {}) {
76
+ return this.run('/runtime/v1/git/pull', { path, ...gitOpts(opts), ...pick(opts, ['remote', 'branch', 'username', 'password']) }, opts);
77
+ }
78
+ /** Push the current branch or a selected branch. */
79
+ async push(path, opts = {}) {
80
+ return this.run('/runtime/v1/git/push', {
81
+ path,
82
+ ...gitOpts(opts),
83
+ ...pick(opts, ['remote', 'branch', 'username', 'password']),
84
+ set_upstream: opts.setUpstream,
85
+ }, opts);
86
+ }
87
+ /** Check out an arbitrary ref in a repository. */
88
+ async checkout(path, ref, opts = {}) {
89
+ return this.run('/runtime/v1/git/checkout', { path, ref, ...gitOpts(opts) }, opts);
90
+ }
91
+ /** Check out an existing branch in a repository. */
92
+ async checkoutBranch(path, branch, opts = {}) {
93
+ return this.checkout(path, branch, opts);
94
+ }
95
+ /** Add a remote. */
96
+ async remoteAdd(path, name, url, opts = {}) {
97
+ return this.run('/runtime/v1/git/remote_add', {
98
+ path,
99
+ name,
100
+ url,
101
+ fetch: opts.fetch,
102
+ overwrite: opts.overwrite,
103
+ ...gitOpts(opts),
104
+ }, opts);
105
+ }
106
+ /** Set a Git config value. */
107
+ async setConfig(key, value, opts = {}) {
108
+ return this.run('/runtime/v1/git/set_config', {
109
+ key,
110
+ value,
111
+ scope: opts.scope,
112
+ path: opts.path,
113
+ ...gitOpts(opts),
114
+ }, opts);
115
+ }
116
+ /** Read a Git config value. */
117
+ async getConfig(key, opts = {}) {
118
+ const result = await this.run('/runtime/v1/git/get_config', {
119
+ key,
120
+ scope: opts.scope,
121
+ path: opts.path,
122
+ ...gitOpts(opts),
123
+ }, opts);
124
+ return String(result.value ?? '');
125
+ }
126
+ async run(path, json, opts) {
127
+ const payload = await this.dataPlane.postJson(path, { json: compact(json), requestTimeoutMs: opts.requestTimeoutMs });
128
+ return gitResult(payload.git ?? payload);
129
+ }
130
+ }
131
+ function gitOpts(opts) {
132
+ return {
133
+ env_vars: opts.envs,
134
+ timeout_seconds: opts.timeout ?? (opts.timeoutMs === undefined ? undefined : Math.ceil(opts.timeoutMs / 1000)),
135
+ };
136
+ }
137
+ function pick(source, keys) {
138
+ const record = source;
139
+ return Object.fromEntries(keys.map((key) => [key, record[key]]));
140
+ }
141
+ function compact(value) {
142
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
143
+ }
144
+ function gitResult(value) {
145
+ const item = value && typeof value === 'object' ? value : {};
146
+ return {
147
+ path: stringValue(item.path),
148
+ url: stringValue(item.url),
149
+ ref: stringValue(item.ref),
150
+ branch: stringValue(item.branch),
151
+ remote: stringValue(item.remote),
152
+ name: stringValue(item.name),
153
+ value: stringValue(item.value),
154
+ branches: Array.isArray(item.branches) ? item.branches.map(String) : undefined,
155
+ currentBranch: stringValue(item.current_branch),
156
+ stdout: String(item.stdout ?? ''),
157
+ stderr: String(item.stderr ?? ''),
158
+ command: item.command && typeof item.command === 'object' ? item.command : undefined,
159
+ raw: item,
160
+ };
161
+ }
162
+ function parseGitStatus(result) {
163
+ const fileStatus = [];
164
+ let currentBranch;
165
+ let upstream;
166
+ let ahead = 0;
167
+ let behind = 0;
168
+ let detached = false;
169
+ for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
170
+ if (line.startsWith('## ')) {
171
+ const branchLine = line.slice(3);
172
+ detached = branchLine.includes('HEAD') && branchLine.includes('no branch');
173
+ const [branchPart, trackingPart] = branchLine.split('...');
174
+ currentBranch = branchPart?.replace(/\s+\[.*\]$/, '') || undefined;
175
+ if (trackingPart) {
176
+ const match = trackingPart.match(/^([^\s[]+)(?:\s+\[(.*)\])?/);
177
+ upstream = match?.[1];
178
+ const details = match?.[2] ?? '';
179
+ ahead = numberFrom(details, /ahead\s+(\d+)/);
180
+ behind = numberFrom(details, /behind\s+(\d+)/);
181
+ }
182
+ continue;
183
+ }
184
+ const indexStatus = line[0] ?? ' ';
185
+ const workingTreeStatus = line[1] ?? ' ';
186
+ const path = line.slice(3);
187
+ const [name, renamedFrom] = path.includes(' -> ') ? path.split(' -> ').reverse() : [path, undefined];
188
+ const status = statusName(indexStatus, workingTreeStatus);
189
+ fileStatus.push({ name, status, indexStatus, workingTreeStatus, staged: indexStatus !== ' ' && indexStatus !== '?', renamedFrom });
190
+ }
191
+ const stagedCount = fileStatus.filter((item) => item.staged).length;
192
+ const untrackedCount = fileStatus.filter((item) => item.status === 'untracked').length;
193
+ const conflictCount = fileStatus.filter((item) => item.status === 'conflict').length;
194
+ const totalCount = fileStatus.length;
195
+ return {
196
+ currentBranch,
197
+ upstream,
198
+ ahead,
199
+ behind,
200
+ detached,
201
+ fileStatus,
202
+ isClean: totalCount === 0,
203
+ hasChanges: totalCount > 0,
204
+ hasStaged: stagedCount > 0,
205
+ hasUntracked: untrackedCount > 0,
206
+ hasConflicts: conflictCount > 0,
207
+ totalCount,
208
+ stagedCount,
209
+ unstagedCount: totalCount - stagedCount,
210
+ untrackedCount,
211
+ conflictCount,
212
+ result,
213
+ };
214
+ }
215
+ function numberFrom(value, pattern) {
216
+ const match = value.match(pattern);
217
+ return match ? Number(match[1]) : 0;
218
+ }
219
+ function statusName(indexStatus, workingTreeStatus) {
220
+ if (indexStatus === '?' && workingTreeStatus === '?')
221
+ return 'untracked';
222
+ if (indexStatus === 'U' || workingTreeStatus === 'U' || indexStatus === 'A' && workingTreeStatus === 'A')
223
+ return 'conflict';
224
+ if (indexStatus === 'D' || workingTreeStatus === 'D')
225
+ return 'deleted';
226
+ if (indexStatus === 'R')
227
+ return 'renamed';
228
+ if (indexStatus === 'A')
229
+ return 'added';
230
+ if (indexStatus === 'M' || workingTreeStatus === 'M')
231
+ return 'modified';
232
+ return 'changed';
233
+ }
234
+ function stringValue(value) {
235
+ return typeof value === 'string' ? value : undefined;
236
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export { ApiError, AuthenticationError, FileNotFoundError, InvalidArgumentError, NotEnoughSpaceError, NotFoundError, NotImplementedError, RateLimitError, SandboxError, TimeoutError, } from './errors.js';
2
2
  export { ConnectionConfig, KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
3
3
  export { Sandbox, SnapshotPaginator } from './sandbox.js';
4
- export type { CreateSnapshotOpts, RestoreSnapshotOpts, SandboxCreateOpts, SandboxConnectOpts, SandboxInfo, SandboxMetrics, SnapshotInfo, } from './sandbox.js';
4
+ export type { CreateSnapshotOpts, RestoreSnapshotOpts, SandboxCreateOpts, SandboxConnectOpts, SandboxInfo, SandboxMetrics, SandboxUrlOpts, SnapshotInfo, FileUrlInfo, } from './sandbox.js';
5
5
  export { CommandExitError, CommandHandle, Commands } from './commands.js';
6
6
  export type { CommandResult, CommandStartOpts, ProcessInfo } from './commands.js';
7
- export { FileType, Filesystem } from './filesystem.js';
8
- export type { EntryInfo, WriteInfo } from './filesystem.js';
9
- export { ProcessSocket, base64DecodeText, base64Encode } from './processSocket.js';
7
+ export { FileType, Filesystem, WatchHandle } from './filesystem.js';
8
+ export type { EntryInfo, FilesystemEvent, WatchOpts, WriteInfo } from './filesystem.js';
9
+ export { Git } from './git.js';
10
+ export type { GitAddOpts, GitAuthOpts, GitBranches, GitBranchOpts, GitCloneOpts, GitCommandResult, GitConfigOpts, GitConfigureUserOpts, GitCredentialOpts, GitCommitOpts, GitFileStatus, GitPullOpts, GitPushOpts, GitRemoteAddOpts, GitRequestOpts, GitStatus, } from './git.js';
11
+ export { Pty } from './pty.js';
12
+ export type { PtyConnectOpts, PtyCreateOpts, PtySize } from './pty.js';
13
+ export { ProcessSocket, base64DecodeBytes, base64DecodeText, base64Encode } from './processSocket.js';
package/dist/index.js CHANGED
@@ -2,5 +2,7 @@ export { ApiError, AuthenticationError, FileNotFoundError, InvalidArgumentError,
2
2
  export { ConnectionConfig, KEEPALIVE_PING_INTERVAL_SEC } from './connectionConfig.js';
3
3
  export { Sandbox, SnapshotPaginator } from './sandbox.js';
4
4
  export { CommandExitError, CommandHandle, Commands } from './commands.js';
5
- export { FileType, Filesystem } from './filesystem.js';
6
- export { ProcessSocket, base64DecodeText, base64Encode } from './processSocket.js';
5
+ export { FileType, Filesystem, WatchHandle } from './filesystem.js';
6
+ export { Git } from './git.js';
7
+ export { Pty } from './pty.js';
8
+ export { ProcessSocket, base64DecodeBytes, base64DecodeText, base64Encode } from './processSocket.js';
@@ -23,3 +23,4 @@ export declare class ProcessSocket implements AsyncIterable<ProcessFrame> {
23
23
  }
24
24
  export declare function base64Encode(bytes: Uint8Array): string;
25
25
  export declare function base64DecodeText(value: unknown): string;
26
+ export declare function base64DecodeBytes(value: unknown): Uint8Array;
@@ -122,12 +122,17 @@ export function base64DecodeText(value) {
122
122
  if (typeof value !== 'string')
123
123
  return String(value ?? '');
124
124
  try {
125
- return Buffer.from(value, 'base64').toString('utf8');
125
+ return Buffer.from(base64DecodeBytes(value)).toString('utf8');
126
126
  }
127
127
  catch {
128
128
  return value;
129
129
  }
130
130
  }
131
+ export function base64DecodeBytes(value) {
132
+ if (typeof value !== 'string')
133
+ return new TextEncoder().encode(String(value ?? ''));
134
+ return new Uint8Array(Buffer.from(value, 'base64'));
135
+ }
131
136
  function rawDataToText(message) {
132
137
  if (typeof message === 'string')
133
138
  return message;
package/dist/pty.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { CommandHandle, CommandStartOpts } from './commands.js';
2
+ import { ConnectionConfig } from './connectionConfig.js';
3
+ import { DataPlaneClient } from './transport.js';
4
+ export interface PtySize {
5
+ cols: number;
6
+ rows: number;
7
+ }
8
+ export interface PtyCreateOpts extends Omit<CommandStartOpts, 'background' | 'onStdout' | 'onStderr'>, PtySize {
9
+ onData?: (data: Uint8Array) => void | Promise<void>;
10
+ }
11
+ export interface PtyConnectOpts {
12
+ onData?: (data: Uint8Array) => void | Promise<void>;
13
+ timeoutMs?: number;
14
+ requestTimeoutMs?: number;
15
+ }
16
+ /** PTY helper backed by the sandbox process WebSocket runtime. */
17
+ export declare class Pty {
18
+ private readonly dataPlane;
19
+ private readonly config;
20
+ constructor(dataPlane: DataPlaneClient, config: ConnectionConfig);
21
+ /** Create an interactive shell PTY and return its live command handle. */
22
+ create(opts: PtyCreateOpts): Promise<CommandHandle>;
23
+ /** Connect to a running PTY by pid. */
24
+ connect(pid: number | string, opts?: PtyConnectOpts): Promise<CommandHandle>;
25
+ /** Send input bytes or text to a PTY. */
26
+ sendStdin(pid: number | string, data: string | Uint8Array, opts?: PtyConnectOpts): Promise<void>;
27
+ /** Alias for `sendStdin`. */
28
+ sendInput(pid: number | string, data: string | Uint8Array, opts?: PtyConnectOpts): Promise<void>;
29
+ /** Resize a running PTY. */
30
+ resize(pid: number | string, size: PtySize, opts?: PtyConnectOpts): Promise<void>;
31
+ /** Kill a running PTY. */
32
+ kill(pid: number | string): Promise<boolean>;
33
+ }
package/dist/pty.js ADDED
@@ -0,0 +1,88 @@
1
+ import { CommandHandle } from './commands.js';
2
+ import { ProcessSocket } from './processSocket.js';
3
+ import { SandboxError } from './errors.js';
4
+ /** PTY helper backed by the sandbox process WebSocket runtime. */
5
+ export class Pty {
6
+ dataPlane;
7
+ config;
8
+ constructor(dataPlane, config) {
9
+ this.dataPlane = dataPlane;
10
+ this.config = config;
11
+ }
12
+ /** Create an interactive shell PTY and return its live command handle. */
13
+ async create(opts) {
14
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, '/runtime/v1/process', opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
15
+ const envs = { TERM: 'xterm-256color', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', ...(opts.envs ?? {}) };
16
+ socket.sendJson({
17
+ type: 'start',
18
+ cmd: '/bin/bash',
19
+ args: ['-i', '-l'],
20
+ cwd: opts.cwd,
21
+ user: opts.user,
22
+ environment: envs,
23
+ envs,
24
+ stdin: true,
25
+ pty: { cols: opts.cols, rows: opts.rows },
26
+ timeout_ms: opts.timeoutMs ?? 60_000,
27
+ });
28
+ const first = await nextStarted(socket);
29
+ const pid = framePid(first);
30
+ if (pid === undefined)
31
+ throw new SandboxError('PTY started frame did not include pid');
32
+ return new CommandHandle(pid, socket, () => this.kill(pid), withFirst(first, socket), undefined, undefined, opts.onData ?? opts.onPty);
33
+ }
34
+ /** Connect to a running PTY by pid. */
35
+ async connect(pid, opts = {}) {
36
+ const socket = await new ProcessSocket(this.dataPlane.baseUrl, this.dataPlane.token, `/runtime/v1/process/${pid}/connect?since=0`, opts.requestTimeoutMs ?? this.config.requestTimeoutMs).connect();
37
+ const first = await nextStarted(socket);
38
+ const actualPid = framePid(first) ?? pid;
39
+ return new CommandHandle(actualPid, socket, () => this.kill(actualPid), withFirst(first, socket), undefined, undefined, opts.onData);
40
+ }
41
+ /** Send input bytes or text to a PTY. */
42
+ async sendStdin(pid, data, opts = {}) {
43
+ const handle = await this.connect(pid, opts);
44
+ try {
45
+ await handle.sendStdin(data);
46
+ }
47
+ finally {
48
+ handle.disconnect();
49
+ }
50
+ }
51
+ /** Alias for `sendStdin`. */
52
+ async sendInput(pid, data, opts = {}) {
53
+ return this.sendStdin(pid, data, opts);
54
+ }
55
+ /** Resize a running PTY. */
56
+ async resize(pid, size, opts = {}) {
57
+ const handle = await this.connect(pid, opts);
58
+ try {
59
+ await handle.resize(size);
60
+ }
61
+ finally {
62
+ handle.disconnect();
63
+ }
64
+ }
65
+ /** Kill a running PTY. */
66
+ async kill(pid) {
67
+ await this.dataPlane.postJson(`/runtime/v1/process/${pid}/signal`, {
68
+ json: { signal: 'SIGKILL' },
69
+ });
70
+ return true;
71
+ }
72
+ }
73
+ async function nextStarted(events) {
74
+ for await (const frame of events) {
75
+ if (frame.type === 'started')
76
+ return frame;
77
+ }
78
+ throw new SandboxError('PTY ended before started frame');
79
+ }
80
+ async function* withFirst(first, rest) {
81
+ yield first;
82
+ yield* rest;
83
+ }
84
+ function framePid(frame) {
85
+ const process = frame.process && typeof frame.process === 'object' ? frame.process : {};
86
+ const pid = frame.pid ?? process.pid ?? process.id;
87
+ return typeof pid === 'number' || typeof pid === 'string' ? pid : undefined;
88
+ }
package/dist/sandbox.d.ts CHANGED
@@ -2,6 +2,8 @@ import { Commands } from './commands.js';
2
2
  import { ConnectionConfig, ConnectionOpts } from './connectionConfig.js';
3
3
  import { ControlClient } from './transport.js';
4
4
  import { Filesystem } from './filesystem.js';
5
+ import { Git } from './git.js';
6
+ import { Pty } from './pty.js';
5
7
  export interface SandboxCreateOpts extends ConnectionOpts {
6
8
  /** Template slug to create. Defaults to "base". */
7
9
  template?: string;
@@ -47,6 +49,18 @@ export interface SnapshotInfo {
47
49
  expiresAt?: string;
48
50
  raw: Record<string, unknown>;
49
51
  }
52
+ export interface FileUrlInfo {
53
+ method: string;
54
+ path: string;
55
+ url: string;
56
+ expiresAt?: string;
57
+ raw: Record<string, unknown>;
58
+ }
59
+ export interface SandboxUrlOpts extends ConnectionOpts {
60
+ user?: string;
61
+ useSignatureExpiration?: number;
62
+ expiresInSeconds?: number;
63
+ }
50
64
  export interface CreateSnapshotOpts extends ConnectionOpts {
51
65
  name?: string;
52
66
  metadata?: Record<string, string>;
@@ -73,13 +87,9 @@ export declare class Sandbox {
73
87
  static readonly defaultTemplate = "base";
74
88
  files: Filesystem;
75
89
  commands: Commands;
90
+ pty: Pty;
91
+ git: Git;
76
92
  readonly sandboxId: string;
77
- readonly pty: {
78
- create: () => never;
79
- };
80
- readonly git: {
81
- clone: () => never;
82
- };
83
93
  private readonly config;
84
94
  private readonly control;
85
95
  private readonly envs;
@@ -109,8 +119,8 @@ export declare class Sandbox {
109
119
  static createSnapshot(sandboxId: string, opts?: CreateSnapshotOpts): Promise<SnapshotInfo>;
110
120
  /** List checkpoints for one sandbox using snapshot naming. */
111
121
  static listSnapshots(sandboxId: string, opts?: ConnectionOpts): SnapshotPaginator;
112
- /** Snapshot deletion is not backed by a Watasu checkpoint delete API yet. */
113
- static deleteSnapshot(..._args: unknown[]): never;
122
+ /** Delete a snapshot by id. Returns `false` when the snapshot does not exist. */
123
+ static deleteSnapshot(snapshotId: string, opts?: ConnectionOpts): Promise<boolean>;
114
124
  /** Destroy this sandbox. */
115
125
  kill(): Promise<boolean>;
116
126
  /** Check if this sandbox is in a runtime-active lifecycle state. */
@@ -127,6 +137,8 @@ export declare class Sandbox {
127
137
  getMetrics(opts?: ConnectionOpts): Promise<SandboxMetrics[]>;
128
138
  /** Create a Watasu checkpoint using snapshot naming. */
129
139
  createSnapshot(opts?: CreateSnapshotOpts): Promise<SnapshotInfo>;
140
+ /** Delete a snapshot by id. */
141
+ deleteSnapshot(snapshotId: string, opts?: ConnectionOpts): Promise<boolean>;
130
142
  /** Watasu-native alias for `createSnapshot`. */
131
143
  checkpoint(opts?: CreateSnapshotOpts): Promise<SnapshotInfo>;
132
144
  /** List checkpoints for this sandbox using snapshot naming. */
@@ -139,9 +151,18 @@ export declare class Sandbox {
139
151
  }): Promise<SandboxInfo[]>;
140
152
  /** Return the public hostname for an exposed sandbox port. */
141
153
  getHost(port: number): string;
154
+ /** Get a signed URL that accepts a POST upload for a sandbox file path. */
155
+ uploadUrl(path: string, opts?: SandboxUrlOpts): Promise<string>;
156
+ /** Get a signed URL that accepts a GET download for a sandbox file path. */
157
+ downloadUrl(path: string, opts?: SandboxUrlOpts): Promise<string>;
158
+ /** Get signed upload URL metadata for a sandbox file path. */
159
+ uploadUrlInfo(path: string, opts?: SandboxUrlOpts): Promise<FileUrlInfo>;
160
+ /** Get signed download URL metadata for a sandbox file path. */
161
+ downloadUrlInfo(path: string, opts?: SandboxUrlOpts): Promise<FileUrlInfo>;
142
162
  updateNetwork(..._args: unknown[]): never;
143
163
  pause(): never;
144
164
  betaPause(): never;
145
165
  resume(): never;
166
+ private fileUrl;
146
167
  private configOptions;
147
168
  }
package/dist/sandbox.js CHANGED
@@ -3,6 +3,8 @@ import { ConnectionConfig, SESSION_OPERATION_REQUEST_TIMEOUT_MS } from './connec
3
3
  import { DataPlaneClient, ControlClient } from './transport.js';
4
4
  import { NotFoundError, SandboxError, unsupported } from './errors.js';
5
5
  import { Filesystem } from './filesystem.js';
6
+ import { Git } from './git.js';
7
+ import { Pty } from './pty.js';
6
8
  export class SnapshotPaginator {
7
9
  loadItems;
8
10
  consumed = false;
@@ -25,9 +27,9 @@ export class Sandbox {
25
27
  static defaultTemplate = 'base';
26
28
  files;
27
29
  commands;
30
+ pty;
31
+ git;
28
32
  sandboxId;
29
- pty = { create: () => unsupported('sandbox.pty') };
30
- git = { clone: () => unsupported('sandbox.git') };
31
33
  config;
32
34
  control;
33
35
  envs;
@@ -43,6 +45,8 @@ export class Sandbox {
43
45
  this.dataPlane = dataPlane;
44
46
  this.files = new Filesystem(dataPlane);
45
47
  this.commands = new Commands(dataPlane, this.config, this.envs);
48
+ this.pty = new Pty(dataPlane, this.config);
49
+ this.git = new Git(dataPlane);
46
50
  }
47
51
  /** Create a sandbox and return it only after the API supplies a data-plane session. */
48
52
  static async create(templateOrOpts, opts = {}) {
@@ -110,6 +114,8 @@ export class Sandbox {
110
114
  this.dataPlane = dataPlane;
111
115
  this.files = new Filesystem(dataPlane);
112
116
  this.commands = new Commands(dataPlane, this.config, this.envs);
117
+ this.pty = new Pty(dataPlane, this.config);
118
+ this.git = new Git(dataPlane);
113
119
  return this;
114
120
  }
115
121
  /** Destroy a sandbox by id. */
@@ -133,7 +139,7 @@ export class Sandbox {
133
139
  /** Create a Watasu checkpoint using snapshot naming. */
134
140
  static async createSnapshot(sandboxId, opts = {}) {
135
141
  const control = new ControlClient(new ConnectionConfig(opts));
136
- const payload = await control.post(`/sandboxes/${sandboxId}/checkpoints`, {
142
+ const payload = await control.post(`/sandboxes/${sandboxId}/snapshots`, {
137
143
  json: snapshotPayload(opts),
138
144
  requestTimeoutMs: opts.requestTimeoutMs,
139
145
  });
@@ -150,9 +156,20 @@ export class Sandbox {
150
156
  return snapshots.map((item) => snapshotInfo(record(item)));
151
157
  });
152
158
  }
153
- /** Snapshot deletion is not backed by a Watasu checkpoint delete API yet. */
154
- static deleteSnapshot(..._args) {
155
- unsupported('Sandbox.deleteSnapshot');
159
+ /** Delete a snapshot by id. Returns `false` when the snapshot does not exist. */
160
+ static async deleteSnapshot(snapshotId, opts = {}) {
161
+ const control = new ControlClient(new ConnectionConfig(opts));
162
+ try {
163
+ await control.delete(`/sandbox_snapshots/${snapshotId}`, {
164
+ requestTimeoutMs: opts.requestTimeoutMs,
165
+ });
166
+ return true;
167
+ }
168
+ catch (error) {
169
+ if (error instanceof NotFoundError)
170
+ return false;
171
+ throw error;
172
+ }
156
173
  }
157
174
  /** Destroy this sandbox. */
158
175
  async kill() {
@@ -206,6 +223,10 @@ export class Sandbox {
206
223
  async createSnapshot(opts = {}) {
207
224
  return Sandbox.createSnapshot(this.sandboxId, { ...this.configOptions(), ...opts });
208
225
  }
226
+ /** Delete a snapshot by id. */
227
+ async deleteSnapshot(snapshotId, opts = {}) {
228
+ return Sandbox.deleteSnapshot(snapshotId, { ...this.configOptions(), ...opts });
229
+ }
209
230
  /** Watasu-native alias for `createSnapshot`. */
210
231
  async checkpoint(opts = {}) {
211
232
  return this.createSnapshot(opts);
@@ -249,10 +270,40 @@ export class Sandbox {
249
270
  throw new SandboxError('port response did not include host or url');
250
271
  return `p${port}-${routeToken}.sandbox.${this.config.dataPlaneDomain}`;
251
272
  }
273
+ /** Get a signed URL that accepts a POST upload for a sandbox file path. */
274
+ async uploadUrl(path, opts = {}) {
275
+ const fileUrl = await this.fileUrl('/upload_url', path, opts);
276
+ return fileUrl.url;
277
+ }
278
+ /** Get a signed URL that accepts a GET download for a sandbox file path. */
279
+ async downloadUrl(path, opts = {}) {
280
+ const fileUrl = await this.fileUrl('/download_url', path, opts);
281
+ return fileUrl.url;
282
+ }
283
+ /** Get signed upload URL metadata for a sandbox file path. */
284
+ async uploadUrlInfo(path, opts = {}) {
285
+ return this.fileUrl('/upload_url', path, opts);
286
+ }
287
+ /** Get signed download URL metadata for a sandbox file path. */
288
+ async downloadUrlInfo(path, opts = {}) {
289
+ return this.fileUrl('/download_url', path, opts);
290
+ }
252
291
  updateNetwork(..._args) { unsupported('Sandbox.updateNetwork'); }
253
292
  pause() { unsupported('Sandbox.pause'); }
254
293
  betaPause() { unsupported('Sandbox.betaPause'); }
255
294
  resume() { unsupported('Sandbox.resume'); }
295
+ async fileUrl(route, path, opts) {
296
+ const payload = await this.control.post(`/sandboxes/${this.sandboxId}/files${route}`, {
297
+ json: compactRecord({
298
+ path,
299
+ user: opts.user,
300
+ use_signature_expiration: opts.useSignatureExpiration,
301
+ expires_in_seconds: opts.expiresInSeconds,
302
+ }),
303
+ requestTimeoutMs: opts.requestTimeoutMs,
304
+ });
305
+ return fileUrlInfo(record(payload.file_url ?? payload));
306
+ }
256
307
  configOptions() {
257
308
  return {
258
309
  apiKey: this.config.apiKey,
@@ -273,6 +324,18 @@ function dataPlaneFromSession(session, config) {
273
324
  }
274
325
  return new DataPlaneClient(url, token, config);
275
326
  }
327
+ function fileUrlInfo(payload) {
328
+ return {
329
+ method: String(payload.method ?? ''),
330
+ path: String(payload.path ?? ''),
331
+ url: String(payload.url ?? ''),
332
+ expiresAt: typeof payload.expires_at === 'string' ? payload.expires_at : typeof payload.expiresAt === 'string' ? payload.expiresAt : undefined,
333
+ raw: payload,
334
+ };
335
+ }
336
+ function compactRecord(payload) {
337
+ return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined));
338
+ }
276
339
  function sessionOperationRequestTimeout(config, opts) {
277
340
  if (opts.requestTimeoutMs !== undefined)
278
341
  return opts.requestTimeoutMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watasu/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "description": "TypeScript SDK for Watasu",