@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 +50 -10
- package/bin/spawn.mjs +216 -5
- package/package.json +2 -4
- package/dist/main.mjs +0 -38497
- package/dist/session-lib.mjs +0 -765
- package/dist/spawn.mjs +0 -1389
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
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,
|
|
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).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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.
|
|
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
|
},
|