@spawnco/client 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,52 +2,92 @@
2
2
 
3
3
  The Spawn client for agents. Join a world as a real player with your own token, look around, walk, and run play scripts against the live game — the same `spawn client` Savi uses from her workshop.
4
4
 
5
+ The package is a loader. The client it runs is the one the stack you join serves: `spawn` asks the stack (`SPAWN_ORIGIN`, default `https://www.spawn.co`) for its current client, fetches it by content (integrity-checked, cached under `~/.cache/spawnco-client/`), and runs it — so an installed copy is never older than the doors it dials. A stack that cannot be reached runs the client it last served you.
6
+
5
7
  ```sh
6
8
  curl -fsSL https://bun.sh/install | bash # once — the client runs on Bun
7
9
  bun add -g @spawnco/client # the `spawn` command
8
10
 
9
11
  export SPAWN_TOKEN=sak_… # your account token (POST /api/agent/v1/signup, or the invite you were handed)
10
12
  spawn client join @alice/gauntlet # a real body in the room, wearing your name
13
+ spawn client join @alice/gauntlet --as bob # a second body of yours — its own player, its own play
11
14
  spawn client where # your pose + the nearest objects
12
15
  spawn client players # every player body: who is here, who is parked
13
16
  spawn client run play.js # play
14
17
  spawn client leave # the body despawns
15
18
  ```
16
19
 
20
+ ## Verify a round without a person
21
+
22
+ A room boots for a player, never for a door — so the room you need to look into is one you boot yourself, as the player you are. After a push:
23
+
24
+ ```sh
25
+ spawn client join @@you/<slug> --ttl 600 # the world boots for your body; the boot verdict prints
26
+ curl -H "Authorization: Bearer $SPAWN_TOKEN" -X POST $ORIGIN/api/sdk/v1/<worldId>/agent/exec \
27
+ -H 'content-type: application/json' -d '{"script":"return api.getPlayers().length"}'
28
+ curl -H "Authorization: Bearer $SPAWN_TOKEN" $ORIGIN/api/sdk/v1/<worldId>/agent/logs
29
+ spawn client leave # the room empties; the seat is returned
30
+ ```
31
+
32
+ `join` prints what the room did for your body once the body stands:
33
+
34
+ ```
35
+ boot: the room is live for your body — tree booted +512 ms · session live +640 ms · can move +1.5 s · body observed +2.3 s (engine e1ac83c7)
36
+ body: player/c_actor:pa_… in <worldId>|main at (0.0, 0.0, 0.0)
37
+ room: 6 entities · 1 player (You (you)) · this seat hosts <worldId>|main
38
+ verify: agent/rooms lists this room (+3.1 s); agent/exec and agent/logs read it while the body stands (ttl 600 s) — `spawn client leave` ends it
39
+ ```
40
+
41
+ `join` waits (bounded, 45 s) for `agent/rooms` to list the room before it prints that line — a room announces itself to the registry within 30 s of booting, and the line is true when printed; `agent/rooms` lists the room while the body stands; `agent/exec` (read-only JS in the live room — state, spawns, SQL through the world's own API) and `agent/logs` (the room's script log: a script fault names its path) answer from it; `spawn client run play.js` plays it with a body. A headless seat costs a seat like a person's (an unlinked account plays about 2 sessions at a time). A body that does not spawn inside the wait prints `body: not spawned inside the wait` and the session stands — `spawn client status` keeps reading it.
42
+
17
43
  ## What `join` does
18
44
 
19
- `spawn client join <world>` takes an address (`@alice/gauntlet`), a play URL, or a world id. With your `sak_` in `SPAWN_TOKEN` it reads your account's doors (`GET /api/agent/v1/me`), resolves the address (`GET /api/resolve-adventure?address=`), mints a session grant for that one world (`POST /api/session/grant/agent { world }`), and attaches a headless engine client through the kernel's session door — byte for byte the boot a browser tab performs. The room sees an agent-class player with a body; your play counts the way a person's does.
45
+ `spawn client join <world>` takes an address (`@alice/gauntlet`), a play URL, or a world id. With your `sak_` in `SPAWN_TOKEN` it reads your account (`GET /api/agent/v1/me` the doors, and your own worlds: a spelling that names one of them is answered there, by id), mints a session grant for that one world (`POST /api/session/grant/agent { world }` — the address goes in as you spelled it and kiln resolves it; the answer names the world and the door), and attaches a headless engine client through the kernel's session door — byte for byte the boot a browser tab performs. The room sees an agent-class player with a body; your play counts the way a person's does. A world that moved is named by its new address; a spelling no world answers to says so.
46
+
47
+ The session is a detached process on your machine (its verbs answer on a loopback port); it self-expires at `--ttl` (default 900 s) and `leave` despawns the body deliberately. Several sessions run side by side under `--name`: a bare verb drives the FIRST-JOINED live session, every later one is the one you name.
20
48
 
21
- The session is a detached process on your machine (its verbs answer on a loopback port); it self-expires at `--ttl` (default 900 s) and `leave` despawns the body deliberately. Several sessions run side by side under `--name`.
49
+ **Several bodies, one account.** `--as <name>` stands a body of your own under the account `spawn client join @alice/gauntlet --as alice` and `--as bob` are two players in the room at once, each wearing its name, each counting its own play (heartbeat with the same `persona` so the row is the body's). The account stays the owner behind every one of them; the session's handle defaults to the body's name (`spawn client move --name bob …`); joining a name you already stand re-joins that body. A join with no `--as` is your account's own body, wearing the account's name. An unlinked agent account stands 2 bodies at a time (`agent_session_budget_exceeded` past that — `leave` one); a linked account is not dialed.
22
50
 
23
51
  Flags an operator may need: `--origin <kiln>` (`SPAWN_ORIGIN`, default `https://www.spawn.co`), `--door <kernel origin>` (`SPAWN_DOOR_ORIGIN`, when the grant names no session door), `--engine <32-hex>` (`SPAWN_ENGINE`, when the door names no engine build), `--actor <agentActorId>` (a person's token embodying a seat it minted).
24
52
 
25
53
  ## Play scripts
26
54
 
27
- `spawn client run play.js` (or `run -e "<source>"`) runs a module against the live client:
55
+ `spawn client run play.js` (or `run -e "<source>"`) runs a module against the live client. `spawn client run --help` opens on the verb list. The spellings a programmer tries are the same calls: move, goto → moveTo · look → face · click, mouse, cursor → point · me, self → where · find, getObject → entity · read → state · spawnAt, setPosition → teleport · sleep, delay → wait · screenshot → witness.
28
56
 
29
57
  ```js
30
58
  // play.js — export default async (play) => { … }
31
59
  export default async (play) => {
32
60
  const cube = (await play.where()).nearby.find((e) => e.specId === "cube-1");
33
- await play.moveTo(cube.entityId, { arriveRadius: 2 });
61
+ await play.moveTo(cube.id, { arriveRadius: 2 }); // default 0.5 m
34
62
  const before = await play.state(); // the world's world/state ledger
35
63
  const pressed = await play.key("KeyE"); // resolved through THIS world's bindings
36
64
  const changed = await play.until(
37
65
  async () => JSON.stringify(await play.state()) !== JSON.stringify(before),
38
66
  { timeoutSec: 5 },
39
67
  );
40
- return { pressed, changed: changed.met };
68
+ const ball = await play.entity("ball"); // { id, position, heading, state, simulator }
69
+ const others = (await play.players()).filter((p) => !p.isSelf);
70
+ await play.face(ball.id); // turn to face it — the frame hold() then walks in
71
+ play.walk(0, -1); // walk north (world-space) until released
72
+ await play.seconds(1);
73
+ play.release();
74
+ return {
75
+ pressed: pressed.ok,
76
+ changed: changed.met,
77
+ ball: ball.position,
78
+ others: others.length,
79
+ };
41
80
  };
42
81
  ```
43
82
 
44
- `play` is your body and your senses:
83
+ `play` is your body and your senses. While the script runs your body counts as a player — the game's `getPlayers`, `ctx.place.players` and `tags: ["player"]` list it, so the referee you wrote sees you; a helper body is a helper again when the script ends (the transcript's first line says which). Every read answers plain objects:
45
84
 
46
- - hands — `hold(axis, value)` / `release(axis?)`, `press(action)` (hard-fails an action the world never declared), `key(code)` / `keyDown` / `keyUp` (a `KeyboardEvent.code`, a bare letter, or `space`; resolved through the world's own `inputs` an unbound key is a finding, never a silent no-op), `moveTo(target)`, `lookAt(target)`, `teleport(x, y, z)` (the one labeled direct writestage-setup, never play).
47
- - senses — `pose()` (position, camera yaw/pitch, `heading`, `grounded`, `contacts`), `where()` (pose + nearby), `players()`, `state(entityId?)` (an entity's `tome/state`; none = the world's `world/state`), `entities(components?)`, `inputs()`, `worldTick()`.
48
- - pacing — `seconds(n)`, `until(check, { timeoutSec })` (a timeout is a finding: `{ met: false }`), `log(line)`.
85
+ - senses — `players()` → `[{ id, name, position, x, y, z, isSelf, parked }]`; `entities()` `[{ id, specId, name, position, x, y, z, heading, state, simulator }]` (`feetPosition` is the same vector as `position` — the spec row's word; `simulator` is who drives it right now — `{ clientId, by, epoch }`; `clientId: null` = the place's host drives it, `by: "suspend"` = parked; the whole field `null` = no row); `entity(id)` → one of those or `null`; `where()` `{ id, position, x, y, z, yaw, pitch, heading, placeId, worldId, tick, nearby: [{ id, specId, name, position, x, y, z, distance }] }` (`tick` is the world tick the read was cut at) — every row with a position carries `x`, `y`, `z` flat beside it (`where().y` and `where().position.y` are one number); `pose()` (+ `grounded`, `contacts`); `camera()` → `{ position, yaw, pitch, eyeRise }` (the camera your client presents — `eyeRise` is the eye over your feet in metres; `null` with none; a district's own camera reads here the tick you land in it); `hud()` `{ ref, html, place }` (the HUD your place renders for you, as html the same `render` call the screen makes); `state(id?)` (an entity's `tome/state` by id; none or `"world"` = the world's `world/state` — `{}` while it holds no rows; `"place"` = your place's own bag, what `ctx.place.state` reads); `logs({ level?, objectId?, limit? })` the console rail your client holds (`[{ level, message, tick, entityId, data, count }]` a script's faults, a clipped foreign verb's `law.faculty.refused`; `data` is the row's JSON text); `inputs()`; `worldTick()`.
86
+ - hands — `hold(axis, value)` / `release(axis?)` is the HUMAN's stick: `moveX`/`moveZ` are camera-relative, exactly what WASD feeds, so a script with no viewport cannot know where a raw hold walks. `hold(action)` keeps the action's key down until `release(action)` `pressed` on the down sample, `held` while down, `released` at the up (a held jump, a brake pedal); `press(action)` taps the same key. The world-frame pair: `walk(dx, dz)` walks a world-space direction (+X east, −Z north; the vector's length is the deflection) until `walk(0, 0)`, `release()`, or the script ends; `face(target)` turns to face a point or an entity id — the frame `hold` then walks in. `point(target)` puts your cursor on a point or an entity — the pointer ray a click aims under a cursor camera (a locked crosshair ignores the cursor; `face` is the aim there); `point(null)` lifts it, and the script's end lifts it too. `moveTo(target, { arriveRadius = 0.5, speed, timeoutSec })` walks there closed-loop under the game's own movement rules. `press(action)` (hard-fails an action the world never declared), `key(code)` / `keyDown(code)` / `keyUp(code)` (a `KeyboardEvent.code`, a bare letter, or `space`; resolved through the world's own `inputs` — an unbound key is a finding, never a silent no-op), `teleport(x, y, z)` or `teleport({ x, y, z })` (the one labeled direct write — stage-setup, never play).
87
+ - eyes without a browser — `witness(outPath?)` writes the room top-down as SVG from the sim's facts: every entity's feet, facing and name, you marked; the file's text lists them too, so a kick's direction reads without a person (`spawn client witness --out kick.svg` is the same picture from the shell). `screenshot` still needs a connected browser player for pixels.
88
+ - pacing — `wait(ms)` / `seconds(n)` (a wait under one sim tick — `wait(0.15)` — is read as seconds, and the transcript says so), `until(check, { timeoutSec })` (a timeout is a finding: `{ met: false }`; every `timeoutSec` on the handle is seconds — `timeout` spells the same), `settle(dropM)` (after a `teleport` that put you `dropM` metres above where you stand: waits out the fall under THIS world's gravity, stepped on its own tick — a moon world and a 30 Hz world each wait their own fall, never a wall-clock guess — then reads that you stand or float; `{ settled, how, grounded, y, gravityY, tickHz, ticks, ms }`, `settled: false` a finding), `log(line)`.
49
89
 
50
- The transcript (every input, in order, as the session saw it) and the return value come back; a thrown error or a timeout is an `ok: false` transcript, and every held input is released when the script ends. `god:*` actions are refused — a play script plays with a player's controls.
90
+ The transcript (every input, in order, as the session saw it) and the return value come back; a thrown error or a timeout is an `ok: false` transcript. When the script ends — returned, threw, timed out — every held input is released AND every move it started is cancelled, so the next run starts clean; a callback that outlives its run finds the play handle closed. `god:*` actions are refused — a play script plays with a player's controls.
51
91
 
52
92
  ## Runtime
53
93
 
package/bin/spawn.mjs CHANGED
@@ -1,6 +1,217 @@
1
1
  #!/usr/bin/env bun
2
- // `spawn` — the Spawn client for agents (@spawnco/client). Runs under Bun: the session shell
3
- // hosts a real engine client in a Bun Worker and serves its verbs over a loopback HTTP port.
4
- // The CLI, the session library and the shell supervisor are built into ../dist by
5
- // scripts/build.ts (prepack); this shim only points the CLI at its own bundles.
6
- import "../dist/spawn.mjs";
2
+ // `spawn` — the Spawn client for agents (@spawnco/client). THE PACKAGE HOLDS NO CLIENT. The client an
3
+ // agent runs is the one the stack it joins serves: this loader asks the stack for its current client
4
+ // (`<origin>/api/client/manifest` the stack's session door `/v4/client/<hash>/agent/manifest.json`),
5
+ // fetches the bundles named there by content (SRI-pinned, immutable by address), caches them, and runs
6
+ // the CLI from the cache — the runtime worker's own law (the shell downloads the world's pinned engine
7
+ // at boot) applied to the shell, the session library and the CLI, so an installed copy can never be
8
+ // older than the doors it dials.
9
+ //
10
+ // SPAWN_ORIGIN / --origin <kiln> the stack (default https://www.spawn.co)
11
+ // SPAWN_CLIENT_DIST=<dir> run the bundles in <dir> instead (a built dist, a downloaded copy)
12
+ // SPAWN_CLIENT_CACHE_DIR=<dir> where fetched clients live (default ~/.cache/spawnco-client)
13
+ //
14
+ // A repo checkout runs the tree's client from source (src/bin.ts beside this file); no fetch, no build.
15
+
16
+ import { createHash } from "node:crypto";
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ renameSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import path from "node:path";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
+
28
+ const DEFAULT_ORIGIN = "https://www.spawn.co";
29
+ const MANIFEST_PATH = "/api/client/manifest";
30
+ const CLI_FILE = "spawn.mjs";
31
+ const FETCH_TIMEOUT_MS = 30_000;
32
+ const VERSION_SEGMENT = /\/v4\/client\/([0-9a-f]{8,64})\//;
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url));
35
+
36
+ const treeEntry = path.join(here, "..", "src", "bin.ts");
37
+ if (existsSync(treeEntry)) {
38
+ await import(pathToFileURL(treeEntry).href);
39
+ } else if (process.env.SPAWN_CLIENT_DIST) {
40
+ await runDist(
41
+ path.resolve(process.env.SPAWN_CLIENT_DIST),
42
+ `SPAWN_CLIENT_DIST=${process.env.SPAWN_CLIENT_DIST}`,
43
+ );
44
+ } else {
45
+ const origin = resolveOrigin(process.argv.slice(2), process.env);
46
+ const dist = await fetchClient(origin);
47
+ await runDist(dist, `the client ${origin} serves`);
48
+ }
49
+
50
+ /** `--origin <kiln>` / `--origin=<kiln>` anywhere on the line, else SPAWN_ORIGIN, else the product. */
51
+ function resolveOrigin(argv, env) {
52
+ let origin = null;
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ if (argv[i] === "--origin" && argv[i + 1]) origin = argv[i + 1];
55
+ else if (argv[i].startsWith("--origin="))
56
+ origin = argv[i].slice("--origin=".length);
57
+ }
58
+ origin = origin ?? env.SPAWN_ORIGIN ?? DEFAULT_ORIGIN;
59
+ return origin.trim().replace(/\/+$/, "");
60
+ }
61
+
62
+ async function runDist(dir, named) {
63
+ const cli = path.join(dir, CLI_FILE);
64
+ if (!existsSync(cli))
65
+ die(
66
+ `no client at ${dir} (${named}): ${CLI_FILE} is missing beside its session library and shell`,
67
+ );
68
+ await import(pathToFileURL(cli).href);
69
+ }
70
+
71
+ /**
72
+ * The stack's current client, on disk: the manifest is read every run (it is small and names the
73
+ * bytes); a bundle already cached under the version it names is re-hashed and kept, anything else
74
+ * is fetched, verified against the manifest's SRI and written atomically. A stack that cannot be
75
+ * reached runs the client it last served this machine, and says so.
76
+ */
77
+ async function fetchClient(origin) {
78
+ const cacheRoot =
79
+ process.env.SPAWN_CLIENT_CACHE_DIR ||
80
+ path.join(homedir(), ".cache", "spawnco-client");
81
+ const pointer = path.join(
82
+ cacheRoot,
83
+ "origins",
84
+ sha256Hex(origin).slice(0, 16),
85
+ );
86
+ const manifestUrl = `${origin}${MANIFEST_PATH}`;
87
+ let response;
88
+ try {
89
+ response = await fetch(manifestUrl, {
90
+ redirect: "follow",
91
+ headers: { accept: "application/json" },
92
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
93
+ });
94
+ } catch (error) {
95
+ return cachedOrDie(
96
+ pointer,
97
+ cacheRoot,
98
+ `${origin} could not be reached (${errorText(error)})`,
99
+ );
100
+ }
101
+ if (!response.ok) {
102
+ const body = (await response.text().catch(() => ""))
103
+ .slice(0, 300)
104
+ .replace(/\s+/g, " ")
105
+ .trim();
106
+ return cachedOrDie(
107
+ pointer,
108
+ cacheRoot,
109
+ `${manifestUrl} answered HTTP ${response.status}${body ? ` — ${body}` : ""}`,
110
+ );
111
+ }
112
+ const manifestText = await response.text();
113
+ let manifest;
114
+ try {
115
+ manifest = JSON.parse(manifestText);
116
+ } catch {
117
+ return cachedOrDie(
118
+ pointer,
119
+ cacheRoot,
120
+ `${response.url} is not a client manifest (not JSON)`,
121
+ );
122
+ }
123
+ const files =
124
+ manifest &&
125
+ typeof manifest === "object" &&
126
+ manifest.files &&
127
+ typeof manifest.files === "object"
128
+ ? manifest.files
129
+ : null;
130
+ const cliName = typeof manifest?.cli === "string" ? manifest.cli : CLI_FILE;
131
+ if (!files || typeof files[cliName] !== "string") {
132
+ return cachedOrDie(
133
+ pointer,
134
+ cacheRoot,
135
+ `${response.url} names no ${cliName} — not a client manifest`,
136
+ );
137
+ }
138
+ const version =
139
+ VERSION_SEGMENT.exec(new URL(response.url).pathname)?.[1] ??
140
+ sha256Hex(manifestText).slice(0, 32);
141
+ const dir = path.join(cacheRoot, version);
142
+ mkdirSync(dir, { recursive: true });
143
+ for (const [name, sri] of Object.entries(files)) {
144
+ if (!/^[A-Za-z0-9._-]+\.mjs$/.test(name))
145
+ die(
146
+ `the client manifest at ${response.url} names a suspicious file "${name}" — refusing`,
147
+ );
148
+ if (typeof sri !== "string" || !/^sha256-[A-Za-z0-9+/=]+$/.test(sri))
149
+ die(
150
+ `the client manifest at ${response.url} carries no sha256 integrity for ${name} — refusing`,
151
+ );
152
+ const target = path.join(dir, name);
153
+ if (existsSync(target) && sha256Sri(readFileSync(target)) === sri) continue;
154
+ const fileUrl = new URL(name, response.url).href;
155
+ let fileResponse;
156
+ try {
157
+ fileResponse = await fetch(fileUrl, {
158
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
159
+ });
160
+ } catch (error) {
161
+ die(`${fileUrl} could not be fetched (${errorText(error)})`);
162
+ }
163
+ if (!fileResponse.ok)
164
+ die(
165
+ `${fileUrl} answered HTTP ${fileResponse.status} while fetching the client the stack named`,
166
+ );
167
+ const bytes = Buffer.from(await fileResponse.arrayBuffer());
168
+ const actual = sha256Sri(bytes);
169
+ if (actual !== sri)
170
+ die(
171
+ `${fileUrl} failed its integrity check (manifest ${sri}, got ${actual}) — the stack's client and its manifest disagree; nothing was run`,
172
+ );
173
+ const tmp = `${target}.${process.pid}.tmp`;
174
+ writeFileSync(tmp, bytes);
175
+ renameSync(tmp, target);
176
+ }
177
+ if (cliName !== CLI_FILE)
178
+ die(
179
+ `the client manifest names its CLI "${cliName}"; this loader runs ${CLI_FILE} — update the loader (bun add -g @spawnco/client)`,
180
+ );
181
+ mkdirSync(path.dirname(pointer), { recursive: true });
182
+ writeFileSync(pointer, `${version}\n`);
183
+ return dir;
184
+ }
185
+
186
+ function cachedOrDie(pointer, cacheRoot, why) {
187
+ if (existsSync(pointer)) {
188
+ const version = readFileSync(pointer, "utf8").trim();
189
+ const dir = path.join(cacheRoot, version);
190
+ if (version && existsSync(path.join(dir, CLI_FILE))) {
191
+ console.error(
192
+ `[spawn] ${why}; running the client this stack last served here (${version.slice(0, 12)})`,
193
+ );
194
+ return dir;
195
+ }
196
+ }
197
+ die(
198
+ `${why}\n The client's bytes come from the stack you join: point SPAWN_ORIGIN (or --origin) at it — the product is ${DEFAULT_ORIGIN}; a dev stack is its kiln, e.g. http://localhost:3000.`,
199
+ );
200
+ }
201
+
202
+ function die(message) {
203
+ console.error(`spawn: ${message}`);
204
+ process.exit(2);
205
+ }
206
+
207
+ function errorText(error) {
208
+ return error instanceof Error ? error.message : String(error);
209
+ }
210
+
211
+ function sha256Hex(text) {
212
+ return createHash("sha256").update(text).digest("hex");
213
+ }
214
+
215
+ function sha256Sri(bytes) {
216
+ return `sha256-${createHash("sha256").update(bytes).digest("base64")}`;
217
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spawnco/client",
3
- "version": "0.1.0",
4
- "description": "The Spawn client for agents: join a world as a real player with your own token and run play scripts against it — the same `spawn client` Savi's workshop uses.",
3
+ "version": "0.2.0",
4
+ "description": "The Spawn client for agents: join a world as a real player with your own token and run play scripts against it — the same `spawn client` Savi's workshop uses. The package is a loader: the client it runs is the one the stack you join serves.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "files": [
11
11
  "bin",
12
- "dist",
13
12
  "README.md"
14
13
  ],
15
14
  "engines": {
@@ -17,7 +16,6 @@
17
16
  },
18
17
  "scripts": {
19
18
  "build": "bun run scripts/build.ts",
20
- "prepack": "bun run scripts/build.ts",
21
19
  "test": "bun test scripts",
22
20
  "tsc": "tsc --noEmit -p tsconfig.json"
23
21
  },