@homie-rocks/studio 0.11.0 → 0.12.1
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 +51 -6
- package/bin/homie-studio.mjs +25 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +19 -7
- package/lib/codex.mjs +1 -0
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +39 -43
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +31 -1
- package/lib/setup.mjs +15 -23
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/index.mjs +36 -8
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +1 -0
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
package/README.md
CHANGED
|
@@ -72,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
72
72
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
73
73
|
|
|
74
74
|
```sh
|
|
75
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
75
|
+
npx -y @homie-rocks/studio@0.12.1 upgrade # the plan; changes nothing
|
|
76
|
+
npx -y @homie-rocks/studio@0.12.1 upgrade --apply # after the person agrees
|
|
77
77
|
npm install && npm run build
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -94,6 +94,32 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
|
|
|
94
94
|
the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
|
|
95
95
|
on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
|
|
96
96
|
|
|
97
|
+
## Player accounts and cloud saves
|
|
98
|
+
|
|
99
|
+
A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
|
|
100
|
+
keeps it in **saves**, not in its room: a room forgets everything 60 s after its last player leaves.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "id": "my-rpg", "saves": true }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createSaves } from '@homie-rocks/studio/saves';
|
|
108
|
+
const saves = createSaves({ game: 'my-rpg' });
|
|
109
|
+
const hero = (await saves.get('hero')) ?? newHero();
|
|
110
|
+
await saves.set('hero', hero); // on this device at once, in the studio's D1 when online
|
|
111
|
+
saves.stats.add({ kills: 1 }); // lifetime numbers that outlive any hero
|
|
112
|
+
await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pressing Play needs no account. A player who saves something is a guest on that browser; with a **passkey**
|
|
116
|
+
(Face ID, a fingerprint, a PIN, no password, no email) it becomes an account on this studio only, and the same
|
|
117
|
+
saves follow them to every device they sign in on. Saves are per player and game, versioned (a stale offline
|
|
118
|
+
copy is a conflict, never an overwrite), capped in size, and offline-tolerant. Players see, download and delete
|
|
119
|
+
everything at `/account/`; the owner sees counts (`homie-studio players`) and, through the back office, names,
|
|
120
|
+
never a passkey or an email. The `ember-vale` starter is a small persistent-character game on the same public
|
|
121
|
+
rooms. Everything, including what is stored and the back office's API: [saves/SAVES.md](saves/SAVES.md).
|
|
122
|
+
|
|
97
123
|
## The studio's own stats
|
|
98
124
|
|
|
99
125
|
The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
|
|
@@ -116,7 +142,7 @@ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
|
116
142
|
## Setup status
|
|
117
143
|
|
|
118
144
|
```sh
|
|
119
|
-
npx -y @homie-rocks/studio@0.
|
|
145
|
+
npx -y @homie-rocks/studio@0.12.1 setup status --connector yes # before a studio exists
|
|
120
146
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
121
147
|
```
|
|
122
148
|
|
|
@@ -205,9 +231,27 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
|
|
|
205
231
|
person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
|
|
206
232
|
manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
|
|
207
233
|
|
|
208
|
-
A Claude Code cloud session
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
A Claude Code cloud session sends all its traffic through a proxy (`HTTPS_PROXY`). curl, npm and git use it;
|
|
235
|
+
Node's own `fetch` does not unless Node is started with `NODE_USE_ENV_PROXY=1` (Node.js 22.21 and later). So from
|
|
236
|
+
0.12.1 the CLI restarts itself once with that set whenever a proxy is in the environment. Before that, `setup attach`
|
|
237
|
+
and `progress attach` connected directly, failed the lookup, and said "did not answer", which read like a blocked
|
|
238
|
+
network in a session with full network access. A failed request now says what happened (`lib/net.mjs`):
|
|
239
|
+
|
|
240
|
+
- the directory's status and its own words (`homie.rocks answered 409: …`);
|
|
241
|
+
- or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, an untrusted certificate) and whether the
|
|
242
|
+
proxy was used.
|
|
243
|
+
|
|
244
|
+
Only a refusal by the proxy itself (a 403 with `x-deny-reason: host_not_allowed`, or a refused CONNECT) names the
|
|
245
|
+
network setting (the environment's Network access: Custom, add homie.rocks). The build goes on with its local feed.
|
|
246
|
+
|
|
247
|
+
**The hand-off opens the studio's repository.** The chat's "Start building" and "Build it" open a Claude Code
|
|
248
|
+
session in one repository, and homie.rocks never asks GitHub which one Deploy to Cloudflare made. So the studio's
|
|
249
|
+
Worker tells it: `deploy` (and Workers Builds, which runs it) passes the repository as the Worker's private
|
|
250
|
+
`HOMIE_REPO` variable, from `HOMIE_REPO`, studio.json `github`, or the git remote, in that order (`lib/repo.mjs`;
|
|
251
|
+
Workers Builds sets no repository variable of its own). The Worker says it with its claim, a request Cloudflare
|
|
252
|
+
stamps with the Worker's own zone. It is never in the public manifest. `setup attach` writes it into studio.json
|
|
253
|
+
`github` too. When the directory does not know it, the card asks the person for it. It never opens Homie's engine
|
|
254
|
+
repository (`homie-rocks/homie`), which is also never accepted as a studio's.
|
|
211
255
|
|
|
212
256
|
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
213
257
|
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
|
@@ -270,6 +314,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
270
314
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
|
|
271
315
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
272
316
|
| `lib/progress.mjs`, `lib/setup.mjs` | The progress feed (`progress …`), a build the chat opened (`progress attach`), a change as a pull request (`progress change`, `progress pr`), and `setup attach`. |
|
|
317
|
+
| `lib/net.mjs`, `lib/repo.mjs` | The toolkit's own web requests: through the environment's proxy, and an honest reason when one fails. The studio's repository (`owner/name`), from its remote, never the engine's. |
|
|
273
318
|
| `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
|
|
274
319
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
275
320
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
38
38
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
39
39
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
40
|
+
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
|
+
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
40
42
|
*
|
|
41
43
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
42
44
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
@@ -96,19 +98,21 @@ import { look } from '../lib/look.mjs';
|
|
|
96
98
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
97
99
|
import { portCheck } from '../lib/port-check.mjs';
|
|
98
100
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
99
|
-
import {
|
|
101
|
+
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
100
102
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
101
103
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
102
104
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
103
105
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
|
+
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
104
107
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
105
108
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
106
109
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
107
110
|
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
111
|
+
import { restartWithProxy } from '../lib/net.mjs';
|
|
108
112
|
|
|
109
113
|
const argv = process.argv.slice(2);
|
|
110
114
|
const flags = new Map();
|
|
111
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
115
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
112
116
|
const positional = [];
|
|
113
117
|
for (let i = 0; i < argv.length; i++) {
|
|
114
118
|
const a = argv[i];
|
|
@@ -207,8 +211,17 @@ function print(result) {
|
|
|
207
211
|
for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
|
|
208
212
|
for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
|
|
209
213
|
for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
|
|
214
|
+
if (result.players) lines.push(` players: ${n(result.players.accounts)} accounts (${n(result.players.newAccounts7d)} new this week), ${n(result.players.guests)} guests with saves, ${n(result.players.active7d)} played this week`);
|
|
210
215
|
break;
|
|
211
216
|
}
|
|
217
|
+
case 'players': {
|
|
218
|
+
const p = result.players;
|
|
219
|
+
lines.push(`${p.accounts} player accounts (${p.newAccounts7d} new this week), ${p.guests} guests with saves, ${p.active7d} played this week.`, result.note);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'players owner':
|
|
223
|
+
lines.push(result.revoked ? result.message : `One-time link (until ${result.expiresAt}): ${result.link}`, ...(result.revoked ? [] : [result.use]));
|
|
224
|
+
break;
|
|
212
225
|
case 'stats key':
|
|
213
226
|
lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
214
227
|
break;
|
|
@@ -388,6 +401,8 @@ async function main() {
|
|
|
388
401
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
389
402
|
}
|
|
390
403
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
404
|
+
if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
|
|
405
|
+
if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
|
|
391
406
|
if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
|
|
392
407
|
if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
|
|
393
408
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
@@ -562,8 +577,7 @@ async function dev(root) {
|
|
|
562
577
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
563
578
|
const studio = readStudio(root);
|
|
564
579
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
565
|
-
const added
|
|
566
|
-
if (added) log(`added ${added} (the studio's own stats)`);
|
|
580
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
|
|
567
581
|
await new Promise((done) => {
|
|
568
582
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
569
583
|
m.on('close', done);
|
|
@@ -603,6 +617,13 @@ function mediaPut(root, file, as) {
|
|
|
603
617
|
});
|
|
604
618
|
}
|
|
605
619
|
|
|
620
|
+
/*
|
|
621
|
+
* A machine whose web traffic goes through a proxy (a Claude Code cloud session: HTTPS_PROXY) runs this command once
|
|
622
|
+
* more with Node's own proxy support on, so the toolkit's requests go the way curl's and npm's do (lib/net.mjs).
|
|
623
|
+
*/
|
|
624
|
+
const proxied = await restartWithProxy();
|
|
625
|
+
if (proxied !== null) process.exit(proxied);
|
|
626
|
+
|
|
606
627
|
try {
|
|
607
628
|
const result = await main();
|
|
608
629
|
// A shared progress feed sends its last word before the command exits.
|
package/lib/build.mjs
CHANGED
|
@@ -220,6 +220,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
220
220
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
221
221
|
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
222
222
|
...(g.screen ? { screen: g.screen } : {}),
|
|
223
|
+
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
|
+
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
|
+
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
223
226
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
224
227
|
};
|
|
225
228
|
});
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { repoOf } from './repo.mjs';
|
|
26
|
+
import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
|
|
26
27
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
27
28
|
|
|
28
29
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -96,7 +97,7 @@ export function deployPlan(root) {
|
|
|
96
97
|
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
97
98
|
cloudflare: [
|
|
98
99
|
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
99
|
-
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round,
|
|
100
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
100
101
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
101
102
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
102
103
|
storage
|
|
@@ -192,14 +193,13 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
192
193
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
193
194
|
|
|
194
195
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
195
|
-
const added
|
|
196
|
-
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
196
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats: counts, never tracks'})`);
|
|
197
197
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
198
198
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
199
199
|
step('D1 migrations applied');
|
|
200
200
|
|
|
201
201
|
const started = Date.now();
|
|
202
|
-
const dep = w(['deploy']);
|
|
202
|
+
const dep = w(['deploy', ...repoVar(root)]);
|
|
203
203
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
204
204
|
remember(`worker:${cf.worker}`);
|
|
205
205
|
// The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
|
|
@@ -239,6 +239,16 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/*
|
|
243
|
+
* THE STUDIO'S REPOSITORY, FOR ITS WORKER (lib/repo.mjs). The Claude app's hand-off opens Claude Code on the studio's
|
|
244
|
+
* own GitHub repository, and only the studio knows which it is: the deploy hands it to the Worker as a variable
|
|
245
|
+
* (HOMIE_REPO), never into a public file, and the Worker tells its directory alongside its claim.
|
|
246
|
+
*/
|
|
247
|
+
export function repoVar(root) {
|
|
248
|
+
const repo = repoOf(root);
|
|
249
|
+
return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
/**
|
|
243
253
|
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
244
254
|
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
@@ -283,16 +293,18 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
283
293
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
284
294
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
285
295
|
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
286
|
-
|
|
296
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
|
|
287
297
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
288
298
|
let migrate = apply();
|
|
289
299
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
290
300
|
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
291
301
|
if (!first) step('D1 migrations applied');
|
|
292
302
|
const started = Date.now();
|
|
293
|
-
const dep = w(['deploy']);
|
|
303
|
+
const dep = w(['deploy', ...repoVar(root)]);
|
|
294
304
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
295
305
|
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
306
|
+
const repo = repoOf(root);
|
|
307
|
+
step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
|
|
296
308
|
if (first) {
|
|
297
309
|
migrate = apply();
|
|
298
310
|
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
package/lib/codex.mjs
CHANGED
|
@@ -637,6 +637,7 @@ ${game.blurb ?? ''}
|
|
|
637
637
|
- **Players in a room:** ${players}
|
|
638
638
|
- **Round:** ${game.roundSeconds ? `${game.roundSeconds} s` : 'how long, and how it ends'}
|
|
639
639
|
- **Bots:** fill empty seats; a person who arrives takes a bot's place
|
|
640
|
+
- **Progress that lasts:** ${game.saves ? 'the player\'s own saves (cloud saves: what is kept, lifetime stats, hardcore or not)' : 'none: a round is all there is (or cloud saves, if the plan says progress persists across sessions or devices)'}
|
|
640
641
|
|
|
641
642
|
## Music and sound
|
|
642
643
|
|
package/lib/directory.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
|
+
import { request } from './net.mjs';
|
|
7
8
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
8
9
|
|
|
9
10
|
export async function publish(root, { homie, site } = {}) {
|
|
@@ -11,9 +12,9 @@ export async function publish(root, { homie, site } = {}) {
|
|
|
11
12
|
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
12
13
|
const url = site || siteUrl(root, studio);
|
|
13
14
|
if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
|
|
14
|
-
const
|
|
15
|
+
const sent = await request(`${directory}/api/studio/publish`, {
|
|
15
16
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return {
|
|
17
|
+
}, { timeout: 30_000 });
|
|
18
|
+
// The directory's own answer (what it listed, or why not), or the connection's error as it is.
|
|
19
|
+
return { ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, ...(sent.ok ? {} : { why: sent.body?.message ?? sent.why, ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) }) };
|
|
19
20
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn } from 'node:child_process';
|
|
|
24
24
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { findChrome } from './chrome.mjs';
|
|
27
|
+
import { whyFailed } from './net.mjs';
|
|
27
28
|
import { isOurs } from './statusline.mjs';
|
|
28
29
|
import { findStudio, readLocal, readStudio } from './studio.mjs';
|
|
29
30
|
import { STUDIO_VERSION } from './version.mjs';
|
|
@@ -47,13 +48,17 @@ const MARK = { ok: '✓', act: '→', missing: '✗', optional: '○', later: '
|
|
|
47
48
|
const RANK = { missing: 0, act: 1, unknown: 2, later: 3, optional: 4, ok: 5 };
|
|
48
49
|
const worst = (states) => states.reduce((a, b) => (RANK[b] < RANK[a] ? b : a), 'ok');
|
|
49
50
|
|
|
51
|
+
/** Whether the directory answers at all (any status is an answer), and if not, the connection's own error. */
|
|
50
52
|
async function reach(url, fetchFn, ms = 5000) {
|
|
51
53
|
try {
|
|
52
54
|
const res = await fetchFn(url, { method: 'GET', headers: { 'user-agent': `homie-studio/${STUDIO_VERSION}`, accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
|
|
53
55
|
const deny = res.headers?.get?.('x-deny-reason') ?? '';
|
|
54
56
|
if (res.status === 403 && /host_not_allowed/i.test(deny)) return { ok: false, blocked: true };
|
|
55
57
|
return { ok: true, status: res.status };
|
|
56
|
-
} catch (error) {
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const w = whyFailed(error, url);
|
|
60
|
+
return { ok: false, blocked: w.needs === 'network', why: w.why, code: w.code };
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
@@ -133,16 +138,17 @@ export async function setupStatus({
|
|
|
133
138
|
say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. The Claude app: Settings, Connectors, Add custom connector, https://homie.rocks/mcp; then its setup card (studio_setup) makes the studio on your own Cloudflare and GitHub in three taps.',
|
|
134
139
|
};
|
|
135
140
|
let state; let detail;
|
|
136
|
-
if (net.blocked) { state = 'act'; detail = `
|
|
137
|
-
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here;
|
|
141
|
+
if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${new URL(directory).host}`; }
|
|
142
|
+
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; this computer's request to the directory failed just now: ${net.why}`; }
|
|
138
143
|
else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
|
|
139
|
-
|
|
144
|
+
// The directory not answering this computer says nothing about the connector: say what failed, as it is.
|
|
145
|
+
else { state = 'unknown'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
|
|
140
146
|
rows.push({
|
|
141
147
|
id: 'connector', label: 'Homie connector', need: 'required', state, detail,
|
|
142
148
|
unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the cards in the Claude app',
|
|
143
149
|
fix: state === 'ok' ? null : net.blocked
|
|
144
150
|
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
|
|
145
|
-
: fixConnector,
|
|
151
|
+
: said === 'no' || net.ok ? fixConnector : null,
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
|
package/lib/net.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TOOLKIT'S OWN WEB REQUESTS, AND WHAT TO SAY WHEN ONE FAILS.
|
|
3
|
+
*
|
|
4
|
+
* A Claude Code cloud session sends ALL of its traffic through an HTTP proxy named in HTTPS_PROXY. curl, npm and
|
|
5
|
+
* git use that proxy. Node's own fetch() does not, unless the process started with NODE_USE_ENV_PROXY=1 (Node
|
|
6
|
+
* 22.21 and later): it connects directly, the VM's direct egress fails ("getaddrinfo ENOTFOUND"), and the toolkit
|
|
7
|
+
* used to report "did not answer". On 2026-09-30 a session with FULL network access read that as a blocked network
|
|
8
|
+
* and told the person to change their network settings, while curl in the same session reached homie.rocks.
|
|
9
|
+
*
|
|
10
|
+
* So:
|
|
11
|
+
* - The CLI restarts itself once with NODE_USE_ENV_PROXY=1 when a proxy is set and this Node can use it
|
|
12
|
+
* (`restartWithProxy`), so every request it makes goes the way curl's do. Loopback addresses never go
|
|
13
|
+
* through the proxy.
|
|
14
|
+
* - A failed request is reported as what it is (`request`, `whyFailed`):
|
|
15
|
+
* * the status and the server's own message;
|
|
16
|
+
* * or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, a TLS error) and whether a proxy
|
|
17
|
+
* was used.
|
|
18
|
+
* - The network setting is named ONLY when the proxy itself refused the host: a 403 with
|
|
19
|
+
* `x-deny-reason: host_not_allowed`, or a CONNECT it refused.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
24
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '::1', '[::1]'];
|
|
25
|
+
|
|
26
|
+
/** The proxy this machine's web requests go through (the environment's HTTPS_PROXY and friends), or null. */
|
|
27
|
+
export function proxyOf(env = process.env) {
|
|
28
|
+
for (const k of PROXY_VARS) if (String(env[k] ?? '').trim()) return { variable: k, url: String(env[k]).trim() };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this process's fetch() uses that proxy: Node's NODE_USE_ENV_PROXY / --use-env-proxy is on, and the variable
|
|
34
|
+
* Node reads for https (https_proxy, then HTTPS_PROXY; an empty one counts, as "none") names one.
|
|
35
|
+
*/
|
|
36
|
+
export function proxyInUse(env = process.env, execArgv = process.execArgv) {
|
|
37
|
+
const on = env.NODE_USE_ENV_PROXY === '1' || execArgv.includes('--use-env-proxy');
|
|
38
|
+
return on && Boolean(String(env.https_proxy ?? env.HTTPS_PROXY ?? '').trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether this Node.js can use the environment's proxy at all (22.21 and later have --use-env-proxy). */
|
|
42
|
+
export function canUseProxy() {
|
|
43
|
+
try { return Boolean(process.allowedNodeEnvironmentFlags?.has('--use-env-proxy')); } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** NO_PROXY with this machine's own loopback addresses in it (a dev site or a local directory never goes out). */
|
|
47
|
+
export function noProxyWithLoopback(value) {
|
|
48
|
+
const have = String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
return [...new Set([...have, ...LOOPBACK.filter((h) => h !== '[::1]')])].join(',');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run this CLI once more with Node's proxy support on, when a proxy is set and not yet in use. Resolves to the exit
|
|
54
|
+
* status of that run, or null when there was nothing to do (no proxy, already on, or a Node without it). A signal
|
|
55
|
+
* this process gets (Ctrl-C, or a kill by its process id) is passed on, so the run never outlives it.
|
|
56
|
+
*/
|
|
57
|
+
export function restartWithProxy({ argv = process.argv, env = process.env, execArgv = process.execArgv } = {}) {
|
|
58
|
+
if (!proxyOf(env) || proxyInUse(env, execArgv) || !canUseProxy() || env.HOMIE_STUDIO_NO_PROXY_RESTART === '1') return Promise.resolve(null);
|
|
59
|
+
// Node reads https_proxy before HTTPS_PROXY, and an EMPTY one counts: it would silently mean "no proxy".
|
|
60
|
+
const next = Object.fromEntries(Object.entries(env).filter(([k, v]) => !(PROXY_VARS.includes(k) && !String(v ?? '').trim())));
|
|
61
|
+
const quiet = execArgv.some((a) => a.startsWith('--disable-warning=UNDICI-EHPA')) ? [] : ['--disable-warning=UNDICI-EHPA'];
|
|
62
|
+
return new Promise((done) => {
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(process.execPath, [...quiet, ...execArgv, ...argv.slice(1)], {
|
|
66
|
+
stdio: 'inherit',
|
|
67
|
+
env: { ...next, NODE_USE_ENV_PROXY: '1', HOMIE_STUDIO_NO_PROXY_RESTART: '1', NO_PROXY: noProxyWithLoopback(env.NO_PROXY ?? env.no_proxy), no_proxy: noProxyWithLoopback(env.no_proxy ?? env.NO_PROXY) },
|
|
68
|
+
});
|
|
69
|
+
} catch { done(null); return; } // could not start it: carry on in this process, and the errors will say why
|
|
70
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
71
|
+
// Ctrl-C at a terminal reaches the whole process group already: pass SIGINT on only when nothing else will.
|
|
72
|
+
const pass = (signal) => { if (signal === 'SIGINT' && process.stdin.isTTY) return; try { child.kill(signal); } catch { /* already gone */ } };
|
|
73
|
+
for (const signal of signals) process.on(signal, pass);
|
|
74
|
+
child.on('error', () => { for (const signal of signals) process.off(signal, pass); done(null); });
|
|
75
|
+
child.on('exit', (code, signal) => { for (const signal of signals) process.off(signal, pass); done(code ?? (signal ? 1 : 0)); });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hostOf = (url) => { try { return new URL(String(url)).host; } catch { return String(url); } };
|
|
80
|
+
const oneLine = (text, max = 200) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
81
|
+
|
|
82
|
+
/** The one network setting a Claude Code cloud session can change, said only when its proxy refused the host. */
|
|
83
|
+
export function networkSetting(host) {
|
|
84
|
+
return `the network proxy of this machine refused ${host}. In a Claude Code cloud session that means the environment's network access leaves it out: in claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"; Full works too), and start a new session`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why a request that never got an answer failed: the connection error's own code and words, and the proxy's part.
|
|
89
|
+
* { why, code, needs } where needs is 'network' only for a refusal by the proxy itself.
|
|
90
|
+
*/
|
|
91
|
+
export function whyFailed(error, url, { env = process.env, execArgv = process.execArgv } = {}) {
|
|
92
|
+
const host = hostOf(url);
|
|
93
|
+
// fetch() wraps the real error: TypeError "fetch failed" > (a DOMException) > the socket's or the proxy's own.
|
|
94
|
+
const chain = [];
|
|
95
|
+
for (let e = error, i = 0; e && i < 6; e = e.cause, i += 1) chain.push(e);
|
|
96
|
+
const deepest = chain.at(-1) ?? {};
|
|
97
|
+
const code = chain.map((e) => e?.code).reverse().find((c) => typeof c === 'string' && c.trim())
|
|
98
|
+
?? (chain.some((e) => e?.name === 'TimeoutError') ? 'TIMEOUT' : null);
|
|
99
|
+
const words = oneLine(deepest.message ?? String(error));
|
|
100
|
+
const proxy = proxyOf(env);
|
|
101
|
+
const used = Boolean(proxy) && proxyInUse(env, execArgv);
|
|
102
|
+
const refused = /Proxy response \((\d{3})\)/i.exec(chain.map((e) => e?.message ?? '').join(' '));
|
|
103
|
+
if (refused && refused[1] === '403') return { why: networkSetting(host), code: 'PROXY_403', needs: 'network' };
|
|
104
|
+
if (refused) return { why: `the network proxy (${proxy?.variable ?? 'HTTPS_PROXY'}) answered ${refused[1]} when asked to connect to ${host}`, code: `PROXY_${refused[1]}` };
|
|
105
|
+
const via = proxy ? (used ? ` (through the proxy in ${proxy.variable})` : ` (directly: this machine's proxy in ${proxy.variable} was not used${canUseProxy() ? '' : `, because Node.js ${process.versions.node} cannot use it; Node.js 22.21 or newer can`})`) : '';
|
|
106
|
+
if (code === 'TIMEOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return { why: `no answer from ${host} in time${via}`, code: code ?? 'TIMEOUT' };
|
|
107
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { why: `could not look up ${host} (${code})${via}`, code };
|
|
108
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return { why: `could not connect to ${host} (${code})${via}`, code };
|
|
109
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/i.test(code ?? '') || /certificate/i.test(words)) {
|
|
110
|
+
return { why: `the TLS certificate ${host} presented is not trusted here (${code ?? words})${via}; when a proxy inspects traffic, its CA belongs in NODE_EXTRA_CA_CERTS`, code: code ?? 'CERT' };
|
|
111
|
+
}
|
|
112
|
+
return { why: `the request to ${host} failed (${code ? `${code}: ` : ''}${words})${via}`, code };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Why an answer is a refusal: the proxy's (and then the one network setting), or the server's own words. */
|
|
116
|
+
export function whyRefused(res, body, text, url) {
|
|
117
|
+
const host = hostOf(url);
|
|
118
|
+
if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) return { why: networkSetting(host), needs: 'network', status: 403 };
|
|
119
|
+
const said = oneLine(body?.message ?? body?.error ?? (body ? '' : text), 300);
|
|
120
|
+
return { why: `${host} answered ${res?.status}${said ? `: ${said}` : ''}`, status: res?.status ?? null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One JSON request with an honest result: { ok: true, status, body } or { ok: false, why, status?, code?, needs? }.
|
|
125
|
+
* A 2xx answer whose JSON says `ok: false` is a refusal too.
|
|
126
|
+
*/
|
|
127
|
+
export async function request(url, init = {}, { timeout = 10_000, fetchFn = globalThis.fetch } = {}) {
|
|
128
|
+
let res; let text = '';
|
|
129
|
+
try {
|
|
130
|
+
res = await fetchFn(url, { ...init, signal: init.signal ?? AbortSignal.timeout(timeout) });
|
|
131
|
+
text = await res.text();
|
|
132
|
+
} catch (error) { return { ok: false, ...whyFailed(error, url) }; }
|
|
133
|
+
let body = null;
|
|
134
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
135
|
+
if (!res.ok || body?.ok === false) return { ok: false, body, ...whyRefused(res, body, text, url) };
|
|
136
|
+
return { ok: true, status: res.status, body, headers: res.headers };
|
|
137
|
+
}
|
package/lib/players.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio players` — the studio's players, for its owner (worker/players.mjs, saves/SAVES.md).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio players [--url <site>] how many have accounts, guests with saves, who played this week
|
|
5
|
+
* homie-studio players owner [--url <site>] a one-time link that marks the owner's OWN player account as the
|
|
6
|
+
* owner's (open it in the browser where they are signed in on the site)
|
|
7
|
+
* homie-studio players owner --revoke no account is the owner's any more
|
|
8
|
+
*
|
|
9
|
+
* The proof of ownership is the studio's own Cloudflare login, as for `stats`: the link's key is minted here and
|
|
10
|
+
* only its SHA-256 goes into the studio's D1 (through Wrangler). The link works once, for 30 minutes.
|
|
11
|
+
*/
|
|
12
|
+
import { runner } from './cloudflare.mjs';
|
|
13
|
+
import { loopback, mint, siteOf, statsShow } from './stats.mjs';
|
|
14
|
+
|
|
15
|
+
export async function playersShow(root, { url } = {}) {
|
|
16
|
+
const s = await statsShow(root, { url, range: '7d' });
|
|
17
|
+
if (!s.ok) return { ...s, command: 'players' };
|
|
18
|
+
if (!s.players) return { ok: false, command: 'players', why: 'this studio\'s D1 has no player tables yet: `npm run deploy` applies migration 0004_players.sql (or `npm run dev` for a local site)' };
|
|
19
|
+
return { ok: true, command: 'players', site: s.site, players: s.players, note: 'Counts only. A player\'s name, passkeys and saves are theirs; the back office lists names, never a passkey or an email.' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function playersOwner(root, { url, revoke = false } = {}) {
|
|
23
|
+
const { studio, site } = siteOf(root, url);
|
|
24
|
+
if (!site) return { ok: false, command: 'players owner', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
25
|
+
const local = loopback(site);
|
|
26
|
+
if (revoke) {
|
|
27
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
28
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "UPDATE players SET owner = 0; DELETE FROM stats_keys WHERE kind = 'player-owner';"]);
|
|
29
|
+
return res.code === 0 ? { ok: true, command: 'players owner', site, revoked: true, message: 'No player account is the owner\'s now. `homie-studio players owner` marks one again.' }
|
|
30
|
+
: { ok: false, command: 'players owner', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
31
|
+
}
|
|
32
|
+
const k = mint(root, studio, 'player-owner', 30 * 60_000, { local });
|
|
33
|
+
if (!k.ok) return { ok: false, command: 'players owner', why: k.why };
|
|
34
|
+
return {
|
|
35
|
+
ok: true, command: 'players owner', site, link: `${site}/account/?owner=${k.key}`, expiresAt: k.expiresAt,
|
|
36
|
+
use: 'Give this link to the studio\'s owner to open in the browser where they play: they sign in (or make an account with a passkey) on that page, then press "Yes, this account is mine". It works once, within 30 minutes. It is theirs alone; do not post it anywhere.',
|
|
37
|
+
};
|
|
38
|
+
}
|