@chengchenccc/sandbox 0.1.1-rc.3 → 0.2.0-rc.2
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/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +186 -21
- package/dist/index.test.js +63 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,21 @@ export interface SandboxInput {
|
|
|
24
24
|
cwd?: string;
|
|
25
25
|
/** Keep the working dir after the run (default: removed). */
|
|
26
26
|
keepCwd?: boolean;
|
|
27
|
+
/** H2: OS-level isolation knobs. When requested and a wrapper tool is
|
|
28
|
+
* available (Linux: bwrap, macOS: sandbox-exec) the subprocess runs
|
|
29
|
+
* inside it; without a tool the run falls back to process isolation
|
|
30
|
+
* only, with a warning naming the ceiling. */
|
|
31
|
+
isolation?: {
|
|
32
|
+
/** Cut all network access (unshare-net / deny network*). */
|
|
33
|
+
noNetwork?: boolean;
|
|
34
|
+
/** Directories the script must not read: overlaid with an empty tmpfs
|
|
35
|
+
* (bwrap) or denied by profile (sandbox-exec). Non-existent dirs are
|
|
36
|
+
* skipped. */
|
|
37
|
+
denyReadDirs?: readonly string[];
|
|
38
|
+
};
|
|
39
|
+
/** Abort kills the process tree immediately (the run resolves with a
|
|
40
|
+
* non-zero exit, timedOut stays false). */
|
|
41
|
+
signal?: AbortSignal;
|
|
27
42
|
}
|
|
28
43
|
export interface SandboxResult {
|
|
29
44
|
/** The script's returned value (parsed JSON), or null when it returned nothing. */
|
|
@@ -35,6 +50,10 @@ export interface SandboxResult {
|
|
|
35
50
|
exitCode: number;
|
|
36
51
|
timedOut: boolean;
|
|
37
52
|
}
|
|
53
|
+
/** Compose the spawn argv for one sandboxed process: platform wrapper
|
|
54
|
+
* (setsid/bwrap/sandbox-exec) around the base command. Shared by the
|
|
55
|
+
* one-shot runner and persistent sessions. */
|
|
56
|
+
export declare function buildWrappedArgv(baseArgv: readonly string[], isolation: SandboxInput["isolation"] | undefined, dir: string): string[];
|
|
38
57
|
export declare function runInSandbox(input: SandboxInput): Promise<SandboxResult>;
|
|
39
58
|
export declare class SandboxTimeoutError extends Error {
|
|
40
59
|
constructor(timeoutMs: number);
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;wEAYwE;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;wEAYwE;AAKxE,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;mDAG+C;IAC/C,SAAS,CAAC,EAAE;QACV,4DAA4D;QAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB;;uBAEe;QACf,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;KAClC,CAAC;IACF;gDAC4C;IAC5C,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAuHD;;+CAE+C;AAC/C,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,SAAS,EAAE,YAAY,CAAC,WAAW,CAAC,GAAG,SAAS,EAChD,GAAG,EAAE,MAAM,GACV,MAAM,EAAE,CAsBV;AAED,wBAAsB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CA8G9E;AAED,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,SAAS,EAAE,MAAM;CAI9B"}
|
package/dist/index.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* no access to host objects). It is NOT a filesystem/network jail — a
|
|
12
12
|
* hostile script can still touch the host filesystem like any spawned
|
|
13
13
|
* process. Container-level isolation is a deliberate non-goal here. */
|
|
14
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
|
-
import { join, resolve } from "node:path";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
17
|
const BASE_ENV = {
|
|
18
18
|
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
19
19
|
HOME: tmpdir(),
|
|
@@ -46,33 +46,198 @@ try {
|
|
|
46
46
|
process.exit(1);
|
|
47
47
|
}
|
|
48
48
|
`;
|
|
49
|
+
function bwrapArgv(base, isolation, selfBinDir) {
|
|
50
|
+
const args = [
|
|
51
|
+
"bwrap",
|
|
52
|
+
"--dev-bind",
|
|
53
|
+
"/",
|
|
54
|
+
"/",
|
|
55
|
+
// Fresh PID namespace + procfs: the script cannot see, signal, or
|
|
56
|
+
// enumerate host processes (the backend included).
|
|
57
|
+
"--unshare-pid",
|
|
58
|
+
"--proc",
|
|
59
|
+
"/proc",
|
|
60
|
+
];
|
|
61
|
+
if (isolation?.noNetwork)
|
|
62
|
+
args.push("--unshare-net");
|
|
63
|
+
// Hide the sandbox user's home dirs: the script runs as the SAME user,
|
|
64
|
+
// so home is the highest-value read/write surface. The bun binary often
|
|
65
|
+
// lives under the home — re-expose its bin dir read-only so PATH lookup
|
|
66
|
+
// and exec keep working.
|
|
67
|
+
args.push("--tmpfs", "/root", "--tmpfs", "/home", "--ro-bind", selfBinDir, selfBinDir);
|
|
68
|
+
for (const d of isolation?.denyReadDirs ?? []) {
|
|
69
|
+
if (!existsSync(d))
|
|
70
|
+
continue;
|
|
71
|
+
// Directory: empty tmpfs shadows content (reads AND writes). File
|
|
72
|
+
// (e.g. the deployment .env): /dev/null overlay empties its reads.
|
|
73
|
+
if (statSync(d).isDirectory())
|
|
74
|
+
args.push("--tmpfs", d);
|
|
75
|
+
else
|
|
76
|
+
args.push("--ro-bind", "/dev/null", d);
|
|
77
|
+
}
|
|
78
|
+
args.push(...base);
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
function sandboxExecArgv(base, dir, isolation) {
|
|
82
|
+
const denies = (isolation?.denyReadDirs ?? [])
|
|
83
|
+
.map((d) => `(deny file-read-data (subpath "${d}"))`)
|
|
84
|
+
.join("");
|
|
85
|
+
const net = isolation?.noNetwork ? "(deny network*)" : "";
|
|
86
|
+
const profilePath = join(dir, "__sandbox_profile.sb");
|
|
87
|
+
writeFileSync(profilePath, `(version 1)(allow default)${net}${denies}`);
|
|
88
|
+
return ["sandbox-exec", "-f", profilePath, ...base];
|
|
89
|
+
}
|
|
90
|
+
/** Post-exit drain grace: fires only when the pipe is still open (an
|
|
91
|
+
* orphaned grandchild holds it) — buffered data resolves instantly. */
|
|
92
|
+
function stallAfter(ms) {
|
|
93
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
94
|
+
const t = setTimeout(() => resolve("stalled"), ms);
|
|
95
|
+
t.unref?.();
|
|
96
|
+
return promise;
|
|
97
|
+
}
|
|
98
|
+
/** Depth-first descendant reap for platforms without setsid (H3): pgrep -P
|
|
99
|
+
* per generation, grandchildren killed before their parents so nothing
|
|
100
|
+
* re-parents and survives. Best effort — a missing pgrep degrades to the
|
|
101
|
+
* direct-child kill only. */
|
|
102
|
+
function killDescendants(pid, signal) {
|
|
103
|
+
let kids;
|
|
104
|
+
try {
|
|
105
|
+
kids = Bun.spawnSync(["pgrep", "-P", String(pid)])
|
|
106
|
+
.stdout.toString()
|
|
107
|
+
.split("\n")
|
|
108
|
+
.flatMap((line) => {
|
|
109
|
+
const n = Number(line.trim());
|
|
110
|
+
return Number.isInteger(n) && n > 0 ? [n] : [];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const kid of kids)
|
|
117
|
+
killDescendants(kid, signal);
|
|
118
|
+
for (const kid of kids) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(kid, signal);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* already gone */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Compose the spawn argv for one sandboxed process: platform wrapper
|
|
128
|
+
* (setsid/bwrap/sandbox-exec) around the base command. Shared by the
|
|
129
|
+
* one-shot runner and persistent sessions. */
|
|
130
|
+
export function buildWrappedArgv(baseArgv, isolation, dir) {
|
|
131
|
+
const hasSetsid = Bun.which("setsid") !== null;
|
|
132
|
+
const wantsNetworkCut = isolation?.noNetwork === true;
|
|
133
|
+
const wantsDenyRead = (isolation?.denyReadDirs?.length ?? 0) > 0;
|
|
134
|
+
const isolationRequested = wantsNetworkCut || wantsDenyRead;
|
|
135
|
+
const onLinux = process.platform === "linux";
|
|
136
|
+
const onMacos = process.platform === "darwin";
|
|
137
|
+
const hasBwrap = Bun.which("bwrap") !== null;
|
|
138
|
+
const hasSandboxExec = Bun.which("sandbox-exec") !== null;
|
|
139
|
+
let argv = [...baseArgv];
|
|
140
|
+
if (isolationRequested) {
|
|
141
|
+
if (onLinux && hasBwrap) {
|
|
142
|
+
argv = bwrapArgv(argv, isolation, dirname(process.execPath));
|
|
143
|
+
}
|
|
144
|
+
else if (onMacos && hasSandboxExec) {
|
|
145
|
+
argv = sandboxExecArgv(argv, dir, isolation);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.warn("[sandbox] isolation requested but no bwrap/sandbox-exec available — running with process isolation only");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return hasSetsid ? ["setsid", ...argv] : argv;
|
|
152
|
+
}
|
|
49
153
|
export async function runInSandbox(input) {
|
|
50
|
-
|
|
154
|
+
// pi semantics: 0 disables the deadline entirely (a cell may legitimately
|
|
155
|
+
// run long); undefined keeps the historical 30s default.
|
|
156
|
+
const timeoutMs = input.timeoutMs === 0 ? 0 : (input.timeoutMs ?? 30_000);
|
|
157
|
+
const hasDeadline = timeoutMs > 0;
|
|
51
158
|
const dir = input.cwd ?? mkTempDir();
|
|
52
159
|
mkdirSync(dir, { recursive: true });
|
|
53
160
|
writeFileSync(join(dir, "script.ts"), input.code);
|
|
54
161
|
writeFileSync(join(dir, "__sandbox_main.ts"), WRAPPER);
|
|
55
162
|
let timedOut = false;
|
|
163
|
+
// Cap buffered output: a hostile `while(true) console.log(...)` would
|
|
164
|
+
// otherwise OOM the backend before the timeout fires.
|
|
165
|
+
const MAX_STREAM_BYTES = 10 * 1024 * 1024;
|
|
166
|
+
async function cappedText(stream) {
|
|
167
|
+
const reader = stream.getReader();
|
|
168
|
+
const chunks = [];
|
|
169
|
+
let total = 0;
|
|
170
|
+
let exitedSeen = false;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const read = reader.read();
|
|
173
|
+
// During the run: wait on data OR process exit. Losing to exit means
|
|
174
|
+
// EOF is pending (H3: an orphaned grandchild inheriting the pipe can
|
|
175
|
+
// hold it open forever) — switch to the bounded drain below.
|
|
176
|
+
const res = exitedSeen
|
|
177
|
+
? await Promise.race([read, stallAfter(1_000)])
|
|
178
|
+
: await Promise.race([read, exited.then(() => "exited")]);
|
|
179
|
+
if (res === "exited") {
|
|
180
|
+
exitedSeen = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (res === "stalled" || res.done) {
|
|
184
|
+
// Post-exit the kernel buffer delivers instantly; stalling past the
|
|
185
|
+
// grace means an orphan still holds the pipe — cancel and use what
|
|
186
|
+
// we got instead of hanging on EOF.
|
|
187
|
+
if (res === "stalled")
|
|
188
|
+
reader.cancel().catch(() => { });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
total += res.value.byteLength;
|
|
192
|
+
if (total <= MAX_STREAM_BYTES)
|
|
193
|
+
chunks.push(res.value);
|
|
194
|
+
}
|
|
195
|
+
const buf = Buffer.concat(chunks).toString("utf8");
|
|
196
|
+
return total > MAX_STREAM_BYTES
|
|
197
|
+
? `${buf.slice(0, MAX_STREAM_BYTES)}\n[sandbox: output truncated at ${MAX_STREAM_BYTES} bytes]`
|
|
198
|
+
: buf;
|
|
199
|
+
}
|
|
200
|
+
const argv = buildWrappedArgv(["bun", "run", join(dir, "__sandbox_main.ts")], input.isolation, dir);
|
|
201
|
+
const proc = Bun.spawn(argv, {
|
|
202
|
+
cwd: dir,
|
|
203
|
+
env: { ...BASE_ENV, ...(input.env ?? {}) },
|
|
204
|
+
stdin: "pipe",
|
|
205
|
+
stdout: "pipe",
|
|
206
|
+
stderr: "pipe",
|
|
207
|
+
});
|
|
208
|
+
proc.stdin.write(JSON.stringify(input.input ?? {}));
|
|
209
|
+
proc.stdin.end();
|
|
210
|
+
const exited = proc.exited;
|
|
56
211
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
212
|
+
const killTree = (signal) => {
|
|
213
|
+
// Group kill first (setsid made the child a leader); fall back to
|
|
214
|
+
// direct child + a depth-first descendant reap when no group exists
|
|
215
|
+
// (macOS has no setsid).
|
|
216
|
+
try {
|
|
217
|
+
process.kill(-proc.pid, signal);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
proc.kill(signal);
|
|
221
|
+
killDescendants(proc.pid, signal);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
let escalation;
|
|
225
|
+
const timer = hasDeadline
|
|
226
|
+
? setTimeout(() => {
|
|
227
|
+
timedOut = true;
|
|
228
|
+
killTree("SIGTERM");
|
|
229
|
+
escalation = setTimeout(() => killTree("SIGKILL"), 2_000);
|
|
230
|
+
escalation.unref?.();
|
|
231
|
+
}, timeoutMs)
|
|
232
|
+
: undefined;
|
|
233
|
+
// H3: completion is gated on proc.exited, NEVER on pipe EOF.
|
|
234
|
+
const exitCode = await exited;
|
|
75
235
|
clearTimeout(timer);
|
|
236
|
+
clearTimeout(escalation);
|
|
237
|
+
const [stdout, stderr] = await Promise.all([
|
|
238
|
+
cappedText(proc.stdout),
|
|
239
|
+
cappedText(proc.stderr),
|
|
240
|
+
]);
|
|
76
241
|
const marker = stdout.lastIndexOf("__SANDBOX_OUTPUT__:");
|
|
77
242
|
let output = null;
|
|
78
243
|
let cleanStdout = stdout;
|
package/dist/index.test.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { runInSandbox } from "./index.js";
|
|
3
6
|
test("runs a script and returns its output", async () => {
|
|
4
7
|
const r = await runInSandbox({
|
|
@@ -34,6 +37,26 @@ test("timeout kills the process", async () => {
|
|
|
34
37
|
timeoutMs: 1_000,
|
|
35
38
|
})).rejects.toThrow(/timed out/);
|
|
36
39
|
});
|
|
40
|
+
const HAS_SETSID = Bun.which("setsid") !== null;
|
|
41
|
+
test.skipIf(!HAS_SETSID)("timeout throws even when a detached grandchild holds stdout (H3)", async () => {
|
|
42
|
+
// `setsid sleep` escapes the process group and inherits our stdout
|
|
43
|
+
// pipe: EOF never arrives after the tree kill, so the old EOF-gated
|
|
44
|
+
// Promise.all hung until the daemon exited. Completion is now gated
|
|
45
|
+
// on proc.exited with a bounded post-exit drain.
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
await expect(runInSandbox({
|
|
48
|
+
// Explicit inherit: the daemon's stdout IS the sandbox's pipe (a
|
|
49
|
+
// default-stdio spawn gets its own pipe and holds nothing).
|
|
50
|
+
code: `export default async () => {
|
|
51
|
+
const daemon = Bun.spawn(["setsid", "sleep", "5"], { stdout: "inherit", stderr: "inherit" });
|
|
52
|
+
await daemon.exited;
|
|
53
|
+
return { ok: true };
|
|
54
|
+
};`,
|
|
55
|
+
input: {},
|
|
56
|
+
timeoutMs: 1_500,
|
|
57
|
+
})).rejects.toThrow(/timed out/);
|
|
58
|
+
expect(Date.now() - start).toBeLessThan(5_000);
|
|
59
|
+
}, 30_000);
|
|
37
60
|
test("minimal env — no host env leakage by default", async () => {
|
|
38
61
|
const r = await runInSandbox({
|
|
39
62
|
code: `export default async () => ({ hasSecret: typeof process.env.SANDBOX_LEAK_TEST !== "undefined" });`,
|
|
@@ -42,3 +65,43 @@ test("minimal env — no host env leakage by default", async () => {
|
|
|
42
65
|
});
|
|
43
66
|
expect(r.output).toEqual({ hasSecret: false });
|
|
44
67
|
});
|
|
68
|
+
const HAS_BWRAP = Bun.which("bwrap") !== null;
|
|
69
|
+
test("isolation cuts network and hides denied dirs (H2)", async () => {
|
|
70
|
+
if (!HAS_BWRAP)
|
|
71
|
+
return;
|
|
72
|
+
const secretDir = mkdtempSync(join(tmpdir(), "sbx-secret-"));
|
|
73
|
+
writeFileSync(join(secretDir, "secret.txt"), "topsecret");
|
|
74
|
+
const r = await runInSandbox({
|
|
75
|
+
code: `export default async () => {
|
|
76
|
+
let netBlocked = false;
|
|
77
|
+
try {
|
|
78
|
+
await fetch("http://example.com/", { signal: AbortSignal.timeout(2_000) });
|
|
79
|
+
} catch {
|
|
80
|
+
netBlocked = true;
|
|
81
|
+
}
|
|
82
|
+
let readBlocked = false;
|
|
83
|
+
try {
|
|
84
|
+
await Bun.file(${JSON.stringify(join(secretDir, "secret.txt"))}).text();
|
|
85
|
+
} catch {
|
|
86
|
+
readBlocked = true;
|
|
87
|
+
}
|
|
88
|
+
return { netBlocked, readBlocked };
|
|
89
|
+
};`,
|
|
90
|
+
isolation: { noNetwork: true, denyReadDirs: [secretDir] },
|
|
91
|
+
timeoutMs: 20_000,
|
|
92
|
+
});
|
|
93
|
+
expect(r.exitCode).toBe(0);
|
|
94
|
+
expect(r.output).toEqual({ netBlocked: true, readBlocked: true });
|
|
95
|
+
rmSync(secretDir, { recursive: true, force: true });
|
|
96
|
+
}, 30_000);
|
|
97
|
+
test("isolation wrapper does not disturb normal execution (H2)", async () => {
|
|
98
|
+
if (!HAS_BWRAP)
|
|
99
|
+
return;
|
|
100
|
+
const r = await runInSandbox({
|
|
101
|
+
code: `export default async () => ({ echo: 41 + 1 });`,
|
|
102
|
+
isolation: { noNetwork: true, denyReadDirs: ["/nonexistent-h2"] },
|
|
103
|
+
timeoutMs: 20_000,
|
|
104
|
+
});
|
|
105
|
+
expect(r.exitCode).toBe(0);
|
|
106
|
+
expect(r.output).toEqual({ echo: 42 });
|
|
107
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chengchenccc/sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-rc.2",
|
|
4
4
|
"description": "Process-isolated sandbox for executing untrusted workflow/eval scripts (Bun subprocess, timeout kill, JSON stdio)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc -p tsconfig.json",
|
|
13
13
|
"lint": "biome check . && eslint .",
|
|
14
|
-
"test": "bun test --pass-with-no-tests",
|
|
14
|
+
"test": "bun test src --pass-with-no-tests",
|
|
15
15
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {},
|