withruntime 0.7.2 → 0.8.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.
@@ -0,0 +1,585 @@
1
+ import { HOME, RUNTIME_HOME, toBlaxelPath, toRuntimePath } from "./context.js";
2
+ import { codeOf, guard, NotSupportedError, ResponseError, responseError, translate, } from "./errors.js";
3
+ /** Runtime's files API writes at most 1 MiB outside /workspace in one call. */
4
+ const SMALL = 1_048_576;
5
+ /** A tree written in one command while its JSON stays under this. */
6
+ const TREE_INLINE = 512 * 1024;
7
+ /** Blaxel's FormatPath: `~` is HOME, doubled slashes collapse, empty is ".". */
8
+ function formatPath(path) {
9
+ let out = path === "" ? "." : path;
10
+ if (out === "~" || out.startsWith("~/"))
11
+ out = HOME + out.slice(1);
12
+ return out.replace(/\/{2,}/g, "/");
13
+ }
14
+ /** The directory listing, file owners included (Go's os.ReadDir order). */
15
+ const LISTING = String.raw `
16
+ import json, os, stat, sys, datetime, pwd, grp
17
+ def name(get, i):
18
+ try: return get(i)[0]
19
+ except Exception: return str(i)
20
+ def perm(m):
21
+ bits = m & 0o777
22
+ if m & stat.S_ISUID: bits |= 1 << 23
23
+ if m & stat.S_ISGID: bits |= 1 << 22
24
+ if m & stat.S_ISVTX: bits |= 1 << 20
25
+ if stat.S_ISLNK(m): bits |= 1 << 27
26
+ return format(bits, "o")
27
+ def listing(path, shown):
28
+ files, subs = [], []
29
+ for entry in sorted(os.scandir(path), key=lambda e: e.name):
30
+ child = ("" if shown == "/" else shown.rstrip("/")) + "/" + entry.name
31
+ st = os.lstat(entry.path)
32
+ if stat.S_ISDIR(st.st_mode):
33
+ subs.append({"name": entry.name, "path": child})
34
+ else:
35
+ files.append({"name": entry.name, "path": child, "permissions": perm(st.st_mode),
36
+ "size": st.st_size, "owner": name(pwd.getpwuid, st.st_uid), "group": name(grp.getgrgid, st.st_gid),
37
+ "lastModified": datetime.datetime.fromtimestamp(st.st_mtime, datetime.timezone.utc).isoformat().replace("+00:00", "Z")})
38
+ return {"name": os.path.basename(shown.rstrip("/")) or "/", "path": shown, "files": files, "subdirectories": subs}
39
+ `;
40
+ const LS = String.raw `${LISTING}
41
+ path, shown = sys.argv[1], sys.argv[2]
42
+ try:
43
+ print(json.dumps(listing(path, shown)))
44
+ except FileNotFoundError:
45
+ print(json.dumps({"status": 404, "error": "directory not found: " + shown})); sys.exit(3)
46
+ except NotADirectoryError:
47
+ print(json.dumps({"status": 422, "error": "not a directory: " + shown})); sys.exit(3)
48
+ except PermissionError as e:
49
+ print(json.dumps({"status": 403, "error": str(e)})); sys.exit(3)
50
+ `;
51
+ /** Many files written in one command, then the root listed. */
52
+ const TREE = String.raw `${LISTING}
53
+ spec = json.load(sys.stdin)
54
+ root = spec["root"]
55
+ try:
56
+ for rel, content in spec["files"].items():
57
+ target = os.path.join(root, rel)
58
+ os.makedirs(os.path.dirname(target) or root, exist_ok=True)
59
+ with open(target, "w", encoding="utf-8") as f:
60
+ f.write(content)
61
+ except PermissionError as e:
62
+ print(json.dumps({"status": 403, "error": "error writing file: " + str(e)})); sys.exit(3)
63
+ except OSError as e:
64
+ print(json.dumps({"status": 422, "error": "error writing file: " + str(e)})); sys.exit(3)
65
+ print(json.dumps(listing(root, spec["shown"])))
66
+ `;
67
+ const DEFAULT_EXCLUDES = [
68
+ "node_modules",
69
+ "vendor",
70
+ ".git",
71
+ "dist",
72
+ "build",
73
+ "target",
74
+ "__pycache__",
75
+ ".venv",
76
+ ".next",
77
+ "coverage",
78
+ ];
79
+ /** Blaxel's find: files (or directories) under a path, by basename pattern. */
80
+ const FIND = String.raw `
81
+ import fnmatch, json, os, sys
82
+ o = json.loads(sys.argv[1])
83
+ root, kind, patterns, limit = o["root"], o["type"], o["patterns"], o["max"]
84
+ exclude, hidden = set(o["exclude"]), o["hidden"]
85
+ if not os.path.isdir(root):
86
+ print(json.dumps({"status": 500, "error": "error walking directory: no such directory: " + o["shown"]})); sys.exit(3)
87
+ found = []
88
+ def visit(path, is_dir, top):
89
+ base = os.path.basename(path)
90
+ if not top and is_dir and base in exclude: return False
91
+ if not top and hidden and base.startswith("."): return False
92
+ if (kind == "file") != is_dir:
93
+ if is_dir or not patterns or any(fnmatch.fnmatchcase(base, p) for p in patterns):
94
+ found.append({"path": os.path.relpath(path, root), "type": "directory" if is_dir else "file"})
95
+ return is_dir
96
+ def walk(path, top):
97
+ try: entries = sorted(os.scandir(path), key=lambda e: e.name)
98
+ except OSError: return
99
+ for e in entries:
100
+ if visit(e.path, e.is_dir(follow_symlinks=False), False): walk(e.path, False)
101
+ visit(root, True, True); walk(root, True)
102
+ if limit >= 0: found = found[:limit]
103
+ print(json.dumps({"matches": found, "total": len(found)}))
104
+ `;
105
+ /** Blaxel's grep: a plain substring, line by line, in regular files. */
106
+ const GREP = String.raw `
107
+ import fnmatch, json, os, sys
108
+ o = json.loads(sys.argv[1])
109
+ root, query, case, limit, pattern = o["root"], o["query"], o["case"], o["max"], o["pattern"]
110
+ exclude = set(o["exclude"])
111
+ if not os.path.isdir(root):
112
+ print(json.dumps({"status": 400, "error": "specified directory does not exist: " + o["shown"]})); sys.exit(3)
113
+ needle = query if case else query.lower()
114
+ matches = []
115
+ def scan(path):
116
+ try:
117
+ with open(path, "rb") as f: data = f.read()
118
+ except OSError: return True
119
+ for number, line in enumerate(data.decode("utf-8", "replace").split("\n")):
120
+ hay = line if case else line.lower()
121
+ at = hay.find(needle)
122
+ if at >= 0:
123
+ matches.append({"path": os.path.relpath(path, root), "line": number + 1,
124
+ "column": len(hay[:at].encode("utf-8")) + 1, "text": line})
125
+ if limit >= 0 and len(matches) >= limit: return False
126
+ return True
127
+ def walk(path):
128
+ try: entries = sorted(os.scandir(path), key=lambda e: e.name)
129
+ except OSError: return True
130
+ for e in entries:
131
+ if e.is_dir(follow_symlinks=False):
132
+ if e.name in exclude: continue
133
+ if not walk(e.path): return False
134
+ elif e.is_file(follow_symlinks=False):
135
+ if pattern and not fnmatch.fnmatchcase(e.name, pattern): continue
136
+ if not scan(e.path): return False
137
+ return True
138
+ walk(root)
139
+ print(json.dumps({"query": query, "matches": matches, "total": len(matches)}))
140
+ `;
141
+ /** A fuzzy search over paths: every character of the query in order, scored
142
+ * higher for consecutive characters and for ones that start a word. */
143
+ const SEARCH = String.raw `
144
+ import json, os, sys
145
+ o = json.loads(sys.argv[1])
146
+ root, query, limit = o["root"], o["query"].lower(), o["max"]
147
+ exclude, hidden = set(o["exclude"]), o["hidden"]
148
+ def score(text):
149
+ low, at, total, run, last = text.lower(), 0, 0, 0, -2
150
+ for ch in query:
151
+ i = low.find(ch, at)
152
+ if i < 0: return None
153
+ run = run + 1 if i == last + 1 else 1
154
+ total += 16 + 4 * (run - 1)
155
+ if i == 0 or text[i - 1] in "/_-. ": total += 8
156
+ total -= min(i - at, 3)
157
+ last, at = i, i + 1
158
+ return total
159
+ found = []
160
+ def walk(path):
161
+ try: entries = sorted(os.scandir(path), key=lambda e: e.name)
162
+ except OSError: return
163
+ for e in entries:
164
+ is_dir = e.is_dir(follow_symlinks=False)
165
+ if hidden and e.name.startswith("."): continue
166
+ if is_dir and e.name in exclude: continue
167
+ rel = os.path.relpath(e.path, root)
168
+ s = score(rel)
169
+ if s is not None: found.append({"path": rel, "score": s, "type": "directory" if is_dir else "file"})
170
+ if is_dir: walk(e.path)
171
+ walk(root)
172
+ found.sort(key=lambda m: -m["score"])
173
+ if limit > 0: found = found[:limit]
174
+ print(json.dumps({"matches": found, "total": len(found)}))
175
+ `;
176
+ /** Blaxel's maxResults: a default, capped at 1000, 0 for all; anything else
177
+ * is refused. */
178
+ function maxResults(value, fallback) {
179
+ if (value === undefined)
180
+ return fallback;
181
+ if (!Number.isInteger(value) || value < 0)
182
+ throw responseError(400, `invalid maxResults: ${value}`);
183
+ return value === 0 ? -1 : Math.min(value, 1000);
184
+ }
185
+ /** The search root: "", "/" and "." are the working directory, as in Blaxel's
186
+ * grep and search. */
187
+ function searchRoot(path) {
188
+ return path === "" || path === "/" || path === "."
189
+ ? RUNTIME_HOME
190
+ : toRuntimePath(formatPath(path));
191
+ }
192
+ function bytesOf(data) {
193
+ return typeof data === "string" ? new TextEncoder().encode(data) : data;
194
+ }
195
+ /** `sandbox.fs`. */
196
+ export class SandboxFileSystem {
197
+ #ctx;
198
+ constructor(ctx) {
199
+ this.#ctx = ctx;
200
+ }
201
+ /** Runs a Python script in the sandbox and answers its JSON. A script that
202
+ * reports `{ status, error }` becomes that ResponseError. */
203
+ async #script(script, args, stdin) {
204
+ const argv = ["python3", "-c", script, ...args];
205
+ const run = (timeoutMs) => this.#ctx.run((runtime) => runtime.exec(argv, { timeoutMs, ...(stdin === undefined ? {} : { stdin }) }));
206
+ let result = await run(60_000);
207
+ // A short exec holds 64 KiB of output; a longer one streams all of it.
208
+ if (result.stdoutTruncated && stdin === undefined)
209
+ result = await run(120_000);
210
+ if (result.exitCode === 127)
211
+ throw new NotSupportedError("This file call on an image without python3", "Use Runtime's stock image, or install python3 in your image (the adapter runs a short script for it).");
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(result.stdout);
215
+ }
216
+ catch {
217
+ throw responseError(500, result.stderr.trim() || "the file command failed");
218
+ }
219
+ const failure = parsed;
220
+ if (result.exitCode !== 0 && failure?.error)
221
+ throw responseError(failure.status ?? 500, failure.error);
222
+ return parsed;
223
+ }
224
+ /** Writes bytes anywhere, as Blaxel's root-owned sandbox API can: outside
225
+ * /workspace, a path the sandbox user may not write, one whose directory is
226
+ * missing, or a file over 1 MiB goes through /workspace and is moved into
227
+ * place with sudo. */
228
+ async #writeBytes(target, bytes) {
229
+ const inWorkspace = target === RUNTIME_HOME || target.startsWith(`${RUNTIME_HOME}/`);
230
+ if (inWorkspace || bytes.length <= SMALL) {
231
+ try {
232
+ await this.#ctx.run((runtime) => runtime.files.write(target, bytes));
233
+ return;
234
+ }
235
+ catch (error) {
236
+ // The files API acts as the sandbox user: it cannot write where only
237
+ // root may (a directory a root process made, even in /workspace),
238
+ // nor, outside /workspace, make a missing parent.
239
+ const code = codeOf(error);
240
+ if (code !== "permission_denied" && (inWorkspace || code !== "file_not_found"))
241
+ throw error;
242
+ }
243
+ }
244
+ const staging = `${RUNTIME_HOME}/.runtime-blaxel-${crypto.randomUUID()}`;
245
+ await this.#ctx.run((runtime) => runtime.files.write(staging, bytes));
246
+ await this.#sudo('mkdir -p "$(dirname "$2")" && mv -f "$1" "$2"', [staging, target], `error writing file ${target}`);
247
+ }
248
+ /** A file's bytes. One only root may read (a root process made it private)
249
+ * is copied, with sudo, to a staging file the sandbox user owns, read, and
250
+ * the copy removed. */
251
+ async #readBytes(target) {
252
+ try {
253
+ return await this.#ctx.run((runtime) => runtime.files.read(target));
254
+ }
255
+ catch (error) {
256
+ if (codeOf(error) !== "permission_denied")
257
+ throw error;
258
+ }
259
+ const staging = await this.#stageForReading(target);
260
+ try {
261
+ return await this.#ctx.run((runtime) => runtime.files.read(staging));
262
+ }
263
+ finally {
264
+ await this.#ctx.run((runtime) => runtime.files.remove(staging)).catch(() => undefined);
265
+ }
266
+ }
267
+ async #stageForReading(target) {
268
+ const staging = `/tmp/.runtime-blaxel-${crypto.randomUUID()}`;
269
+ await this.#sudo('install -m 0600 -o "$SUDO_UID" -g "$SUDO_GID" -- "$1" "$2"', [target, staging], `error reading file ${target}`);
270
+ return staging;
271
+ }
272
+ async #sudo(script, args, failure) {
273
+ const result = await this.#ctx.run((runtime) => runtime.exec(["sudo", "sh", "-c", script, "sh", ...args]));
274
+ if (result.exitCode !== 0)
275
+ throw responseError(422, `${failure}: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
276
+ }
277
+ /** Makes a directory and its parents. */
278
+ async mkdir(path, permissions = "0755") {
279
+ const shown = formatPath(path);
280
+ const target = toRuntimePath(shown);
281
+ if (!/^[0-7]{3,4}$/.test(permissions))
282
+ throw responseError(422, `invalid permissions format '${permissions}'`);
283
+ try {
284
+ await this.#ctx.run((runtime) => runtime.files.mkdir(target, { parents: true }));
285
+ if (Number.parseInt(permissions, 8) !== 0o755)
286
+ await this.#ctx.run((runtime) => runtime.exec(["chmod", permissions, "--", target]));
287
+ }
288
+ catch (error) {
289
+ if (codeOf(error) !== "permission_denied")
290
+ throw error;
291
+ await this.#sudo('mkdir -p -m "$1" -- "$2" && chown "$SUDO_UID:$SUDO_GID" -- "$2"', [permissions, target], "error creating directory");
292
+ }
293
+ return { message: "Directory created successfully", path: shown };
294
+ }
295
+ /** Writes a text file, making its directories. */
296
+ async write(path, content) {
297
+ const shown = formatPath(path);
298
+ await this.#writeBytes(toRuntimePath(shown), bytesOf(content));
299
+ return { message: "File created/updated successfully", path: shown };
300
+ }
301
+ /** Writes bytes: a Buffer, Blob, File or Uint8Array, or a string naming a
302
+ * local file to upload. */
303
+ async writeBinary(path, content) {
304
+ const shown = formatPath(path);
305
+ let bytes;
306
+ if (typeof content === "string") {
307
+ const { readFile } = await import("node:fs/promises");
308
+ bytes = await readFile(content);
309
+ }
310
+ else if (content instanceof Uint8Array)
311
+ bytes = content;
312
+ else if (ArrayBuffer.isView(content))
313
+ bytes = new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
314
+ else if (content && typeof content.arrayBuffer === "function")
315
+ bytes = new Uint8Array(await content.arrayBuffer());
316
+ else {
317
+ const kind = content?.constructor?.name ?? typeof content;
318
+ throw new Error(`Unsupported content type: ${kind}`);
319
+ }
320
+ await this.#writeBytes(toRuntimePath(shown), bytes);
321
+ return { message: "Binary file uploaded successfully", path: shown };
322
+ }
323
+ /** Writes files under `destinationPath` (the working directory by
324
+ * default) and answers the listing of it: one command for a small tree. */
325
+ async writeTree(files, destinationPath = null) {
326
+ const shown = formatPath(destinationPath ?? "");
327
+ const root = toRuntimePath(shown);
328
+ const display = shown === "." ? HOME : shown;
329
+ const tree = Object.fromEntries(files.map((file) => [file.path, file.content]));
330
+ const spec = JSON.stringify({ root, shown: display, files: tree });
331
+ if (spec.length <= TREE_INLINE)
332
+ try {
333
+ return await this.#script(TREE, [], spec);
334
+ }
335
+ catch (error) {
336
+ // A root the sandbox user may not write: file by file, through sudo.
337
+ if (!(error instanceof ResponseError && error.status === 403))
338
+ throw error;
339
+ }
340
+ const entries = Object.entries(tree);
341
+ let next = 0;
342
+ const worker = async () => {
343
+ while (next < entries.length) {
344
+ const [rel, content] = entries[next++];
345
+ await this.#writeBytes(toRuntimePath(`${root}/${rel}`), bytesOf(content));
346
+ }
347
+ };
348
+ await Promise.all(Array.from({ length: Math.min(4, entries.length) }, worker));
349
+ return this.ls(display);
350
+ }
351
+ /** A text file's content. */
352
+ async read(path) {
353
+ const target = toRuntimePath(formatPath(path));
354
+ try {
355
+ return new TextDecoder().decode(await this.#readBytes(target));
356
+ }
357
+ catch (error) {
358
+ if (codeOf(error) === "is_a_directory")
359
+ throw new Error("Unsupported file type");
360
+ throw error;
361
+ }
362
+ }
363
+ /** A file's bytes, as a Blob. */
364
+ async readBinary(path) {
365
+ const target = toRuntimePath(formatPath(path));
366
+ const bytes = await this.#readBytes(target);
367
+ return new Blob([bytes]);
368
+ }
369
+ /** Copies a sandbox file to the local disk with `mode` (default 0o644). */
370
+ async download(src, destinationPath, { mode = 0o644 } = {}) {
371
+ const target = toRuntimePath(formatPath(src));
372
+ try {
373
+ await this.#ctx.run((runtime) => runtime.files.download(target, destinationPath));
374
+ }
375
+ catch (error) {
376
+ if (codeOf(error) !== "permission_denied")
377
+ throw error;
378
+ const staging = await this.#stageForReading(target);
379
+ try {
380
+ await this.#ctx.run((runtime) => runtime.files.download(staging, destinationPath));
381
+ }
382
+ finally {
383
+ await this.#ctx.run((runtime) => runtime.files.remove(staging)).catch(() => undefined);
384
+ }
385
+ }
386
+ const { chmod } = await import("node:fs/promises");
387
+ await chmod(destinationPath, mode);
388
+ }
389
+ /** Removes a file, or a directory (its contents too with `recursive`). */
390
+ async rm(path, recursive = false) {
391
+ const shown = formatPath(path);
392
+ const target = toRuntimePath(shown);
393
+ const script = String.raw `p="$1"
394
+ if [ -d "$p" ] && [ ! -L "$p" ]; then
395
+ if [ "$2" = 1 ]; then rm -rf -- "$p" 2>/dev/null || sudo rm -rf -- "$p" || exit 3
396
+ else
397
+ if [ -n "$(ls -A -- "$p" 2>/dev/null)" ]; then echo "directory not empty" >&2; exit 4; fi
398
+ rmdir -- "$p" 2>/dev/null || sudo rmdir -- "$p" || exit 3
399
+ fi
400
+ echo Directory
401
+ elif [ -e "$p" ] || [ -L "$p" ]; then rm -f -- "$p" 2>/dev/null || sudo rm -f -- "$p" || exit 3; echo File
402
+ else exit 2; fi`;
403
+ const result = await this.#ctx.run((runtime) => runtime.exec(["bash", "-c", script, "rm", target, recursive ? "1" : "0"]));
404
+ if (result.exitCode === 2)
405
+ throw responseError(404, "file or directory not found");
406
+ if (result.exitCode === 4)
407
+ throw responseError(422, "error deleting directory: directory not empty");
408
+ if (result.exitCode !== 0)
409
+ throw responseError(422, `error deleting ${shown}: ${result.stderr.trim()}`);
410
+ return { message: `${result.stdout.trim()} deleted successfully`, path: shown };
411
+ }
412
+ /** A directory's files and subdirectories, as Blaxel lists them. */
413
+ async ls(path) {
414
+ const shown = formatPath(path);
415
+ const target = toRuntimePath(shown);
416
+ const display = shown === "." ? HOME : shown;
417
+ try {
418
+ return await this.#script(LS, [target, display]);
419
+ }
420
+ catch (error) {
421
+ if (!(error instanceof NotSupportedError))
422
+ throw error;
423
+ }
424
+ // No python3 in the image: the files API, which does not know owners.
425
+ const entries = await this.#ctx.run((runtime) => runtime.files.list(target, { depth: 1, hidden: true }));
426
+ const child = (name) => `${display === "/" ? "" : display.replace(/\/$/, "")}/${name}`;
427
+ return {
428
+ name: display.split("/").filter(Boolean).pop() ?? "/",
429
+ path: display,
430
+ files: entries
431
+ .filter((entry) => entry.type !== "directory")
432
+ .map((entry) => ({
433
+ name: entry.name,
434
+ path: child(entry.name),
435
+ permissions: (Number.parseInt(entry.mode, 8) & 0o777).toString(8),
436
+ size: entry.size,
437
+ lastModified: entry.modifiedAt,
438
+ owner: "",
439
+ group: "",
440
+ })),
441
+ subdirectories: entries
442
+ .filter((entry) => entry.type === "directory")
443
+ .map((entry) => ({ name: entry.name, path: child(entry.name) })),
444
+ };
445
+ }
446
+ /** Paths under `path` fuzzily matching `query`, best first. */
447
+ async search(query, path = "/", options = {}) {
448
+ if (!query)
449
+ throw responseError(400, "query parameter is required");
450
+ return this.#script(SEARCH, [
451
+ JSON.stringify({
452
+ root: searchRoot(path),
453
+ query,
454
+ max: maxResults(options.maxResults, 20),
455
+ exclude: options.excludeDirs?.length ? options.excludeDirs : DEFAULT_EXCLUDES,
456
+ hidden: options.excludeHidden ?? true,
457
+ }),
458
+ ]);
459
+ }
460
+ /** Files (or directories) under `path` whose name matches a pattern. */
461
+ async find(path, options = {}) {
462
+ const type = options.type ?? "file";
463
+ if (type !== "file" && type !== "directory")
464
+ throw responseError(400, `invalid search type: ${String(type)}`);
465
+ const shown = formatPath(path);
466
+ return this.#script(FIND, [
467
+ JSON.stringify({
468
+ root: toRuntimePath(shown),
469
+ shown,
470
+ type,
471
+ patterns: options.patterns ?? [],
472
+ max: maxResults(options.maxResults, 20),
473
+ exclude: options.excludeDirs?.length ? options.excludeDirs : DEFAULT_EXCLUDES,
474
+ hidden: options.excludeHidden ?? true,
475
+ }),
476
+ ]);
477
+ }
478
+ /** Lines containing `query` (a plain string, case-insensitive by default)
479
+ * in the files under `path`. */
480
+ async grep(query, path = "/", options = {}) {
481
+ if (!query)
482
+ throw responseError(400, "query parameter is required");
483
+ return this.#script(GREP, [
484
+ JSON.stringify({
485
+ root: searchRoot(path),
486
+ shown: path,
487
+ query,
488
+ case: options.caseSensitive ?? false,
489
+ max: maxResults(options.maxResults, 100),
490
+ pattern: options.filePattern ?? "",
491
+ exclude: options.excludeDirs?.length ? options.excludeDirs : DEFAULT_EXCLUDES,
492
+ }),
493
+ ]);
494
+ }
495
+ /** Copies a file or directory (cp -r). */
496
+ async cp(source, destination, _options = {}) {
497
+ const from = toRuntimePath(formatPath(source));
498
+ const to = toRuntimePath(formatPath(destination));
499
+ const result = await this.#ctx.run((runtime) => runtime.exec([
500
+ "sh",
501
+ "-c",
502
+ 'cp -r -- "$1" "$2" 2>/dev/null || sudo cp -r -- "$1" "$2"',
503
+ "cp",
504
+ from,
505
+ to,
506
+ ]));
507
+ if (result.exitCode !== 0)
508
+ throw new Error(`Could not copy ${source} to ${destination} cause: ${result.stderr.trim()}`);
509
+ return { message: "Files copied", source, destination };
510
+ }
511
+ /** Calls `callback` for each change in `path` (and below it for
512
+ * `path/**`) until `close()`. `withContent` adds a created or written
513
+ * file's text; `ignore` skips paths. */
514
+ watch(path, callback, options) {
515
+ const recursive = /\/\*\*\/?$/.test(path);
516
+ const shown = formatPath(recursive ? path.replace(/\/\*\*\/?$/, "") || "/" : path);
517
+ const target = toRuntimePath(shown);
518
+ const backToBlaxel = shown === "." || shown.startsWith(HOME) || !shown.startsWith("/");
519
+ const exclude = (options?.ignore ?? []).flatMap((ignored) => {
520
+ const full = toRuntimePath(formatPath(ignored));
521
+ if (full === target || !full.startsWith(`${target}/`))
522
+ return [];
523
+ const rel = full.slice(target.length + 1);
524
+ return [rel, `${rel}/**`];
525
+ });
526
+ let closed = false;
527
+ let handle;
528
+ const deliverOne = async (event) => {
529
+ const full = event.path;
530
+ const slash = full.lastIndexOf("/");
531
+ const dir = slash <= 0 ? "/" : full.slice(0, slash);
532
+ const out = {
533
+ op: event.type.toUpperCase(),
534
+ path: backToBlaxel ? toBlaxelPath(dir) : dir,
535
+ name: full.slice(slash + 1),
536
+ };
537
+ if (options?.withContent && (out.op === "CREATE" || out.op === "WRITE") && !event.isDir) {
538
+ const content = await this.#readBytes(full).then((bytes) => new TextDecoder().decode(bytes), () => undefined);
539
+ if (content !== undefined)
540
+ out.content = content;
541
+ }
542
+ if (!closed)
543
+ await callback(out);
544
+ };
545
+ // One at a time and in order, as Blaxel awaits each callback: an event
546
+ // whose content is being read holds back the ones after it.
547
+ let chain = Promise.resolve();
548
+ const deliver = (event) => {
549
+ chain = chain
550
+ .then(() => deliverOne(event))
551
+ .catch((error) => {
552
+ options?.onError?.(error instanceof Error ? error : new Error(String(error)));
553
+ });
554
+ return chain;
555
+ };
556
+ void (async () => {
557
+ try {
558
+ const made = await this.#ctx.run((runtime) => runtime.files.watch(target, deliver, {
559
+ recursive,
560
+ ...(exclude.length ? { exclude } : {}),
561
+ }));
562
+ if (closed)
563
+ await made.stop();
564
+ else
565
+ handle = made;
566
+ }
567
+ catch (error) {
568
+ closed = true;
569
+ const failure = translate(error);
570
+ options?.onError?.(failure instanceof Error ? failure : new Error(String(failure)));
571
+ }
572
+ })();
573
+ return {
574
+ close: () => {
575
+ closed = true;
576
+ void handle?.stop().catch(() => undefined);
577
+ },
578
+ };
579
+ }
580
+ }
581
+ /** Blaxel's own retry helper: Runtime's SDK retries transient failures
582
+ * itself, so this runs `fn` once. */
583
+ export async function retryOnTransient(fn) {
584
+ return guard(fn);
585
+ }