withruntime 0.7.1 → 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.
- package/BLAXEL.md +210 -0
- package/README.md +18 -0
- package/dist/ai-harness/index.js +5 -0
- package/dist/blaxel/client.d.ts +48 -0
- package/dist/blaxel/client.js +58 -0
- package/dist/blaxel/context.d.ts +59 -0
- package/dist/blaxel/context.js +162 -0
- package/dist/blaxel/errors.d.ts +51 -0
- package/dist/blaxel/errors.js +148 -0
- package/dist/blaxel/filesystem.d.ts +147 -0
- package/dist/blaxel/filesystem.js +585 -0
- package/dist/blaxel/index.d.ts +280 -0
- package/dist/blaxel/index.js +332 -0
- package/dist/blaxel/interpreter.d.ts +74 -0
- package/dist/blaxel/interpreter.js +256 -0
- package/dist/blaxel/lookup.d.ts +37 -0
- package/dist/blaxel/lookup.js +82 -0
- package/dist/blaxel/pagination.d.ts +39 -0
- package/dist/blaxel/pagination.js +60 -0
- package/dist/blaxel/preview.d.ts +110 -0
- package/dist/blaxel/preview.js +228 -0
- package/dist/blaxel/process.d.ts +126 -0
- package/dist/blaxel/process.js +526 -0
- package/dist/blaxel/sandbox.d.ts +173 -0
- package/dist/blaxel/sandbox.js +768 -0
- package/dist/blaxel/snapshot.d.ts +119 -0
- package/dist/blaxel/snapshot.js +197 -0
- package/dist/blaxel/types.d.ts +145 -0
- package/dist/blaxel/types.js +60 -0
- package/dist/blaxel/unsupported.d.ts +22 -0
- package/dist/blaxel/unsupported.js +34 -0
- package/dist/cli.js +49 -15
- package/dist/login.d.ts +9 -36
- package/dist/login.js +0 -22
- package/dist/products/switching.d.ts +2 -2
- package/dist/sandbox.d.ts +3 -2
- package/dist/sandbox.js +3 -2
- package/dist/tar.js +72 -14
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +1 -1
- package/dist/types.d.ts +14 -1
- package/package.json +10 -2
|
@@ -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
|
+
}
|