@homie-rocks/studio 0.22.0 → 0.24.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/CHANGELOG.md +61 -1
- package/README.md +59 -0
- package/bin/homie-studio.mjs +68 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +20 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +59 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +77 -0
- package/netplay/NETPLAY.md +194 -4
- package/netplay/netplay.ts +156 -3
- package/package.json +7 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +22 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/src/main.ts +22 -1
- package/worker/account-page.mjs +14 -2
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +189 -7
- package/worker/office-page.mjs +120 -5
- package/worker/office.mjs +173 -6
- package/worker/pages.mjs +25 -8
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +291 -11
- package/worker/servers.mjs +5 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/lib/chat-cli.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio chat` — a studio's room chat from the studio folder (worker/chat.mjs says what it is; NETPLAY.md
|
|
3
|
+
* section 19 the rules and the wire). The office key it uses is the same as `office`'s: minted with the studio's own
|
|
4
|
+
* Cloudflare login, ten minutes long, dropped as soon as the answer is in.
|
|
5
|
+
*
|
|
6
|
+
* homie-studio chat [--game <id>] each game's chat rules, every live room's last minutes, the
|
|
7
|
+
* reports, and the review's day (neurons used of its budget)
|
|
8
|
+
* homie-studio chat rules <game> [--server <id>] [--mode off|emoji|lines|text] [--who anyone|signed-in|members]
|
|
9
|
+
* [--react …] [--slow <s>] [--max <n>] [--links block|allow] [--swears block|allow]
|
|
10
|
+
* [--ai on|off] [--bubbles on|off] [--watchers on|off] [--hub on|off] [--block "a,b"] [--reset]
|
|
11
|
+
* homie-studio chat remove <game> <room> <line id>|--all
|
|
12
|
+
* homie-studio chat budget <neurons> the review's day for the whole studio (default 2,000)
|
|
13
|
+
* homie-studio chat words the built-in word list the floor holds (worker/chat-words.mjs)
|
|
14
|
+
*
|
|
15
|
+
* A change that opens chat up (a wider mode or audience, less slow mode, links or swears allowed, the review off) or
|
|
16
|
+
* a bigger budget is only ASKED for here: the answer is the owner's one-tap link, as `office kick` gives.
|
|
17
|
+
*/
|
|
18
|
+
import { builtinWords } from '../worker/chat.mjs';
|
|
19
|
+
import { askedFor, withKey } from './office.mjs';
|
|
20
|
+
|
|
21
|
+
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
22
|
+
const onOff = (v) => (v === undefined ? undefined : v === true || v === 'on' || v === 'true' || v === 'yes');
|
|
23
|
+
|
|
24
|
+
/** The flags `chat rules` reads, as the office API's fields. */
|
|
25
|
+
export function chatFields(flags) {
|
|
26
|
+
const f = {};
|
|
27
|
+
const get = (k) => (flags.has(k) ? flags.get(k) : undefined);
|
|
28
|
+
for (const k of ['mode', 'who', 'react', 'links', 'swears']) if (get(k) !== undefined) f[k] = String(get(k));
|
|
29
|
+
for (const k of ['slow', 'max']) if (get(k) !== undefined) f[k] = Number(get(k));
|
|
30
|
+
for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (get(k) !== undefined) f[k] = onOff(get(k));
|
|
31
|
+
if (get('block') !== undefined) f.block = String(get('block')).split(',').map((w) => w.trim()).filter(Boolean);
|
|
32
|
+
if (get('allow') !== undefined) f.allow = String(get('allow')).split(',').map((w) => w.trim()).filter(Boolean);
|
|
33
|
+
return f;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function chatShow(root, { url, game } = {}) {
|
|
37
|
+
const r = await withKey(root, url, (call) => call(`/_studio/api/chat${game ? `?game=${encodeURIComponent(game)}` : ''}`));
|
|
38
|
+
if (!r.ok) return { ok: false, command: 'chat', why: r.why ?? r.message ?? 'the office did not answer' };
|
|
39
|
+
return { command: 'chat', ...r, ok: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function chatRulesSet(root, game, flags, { url } = {}) {
|
|
43
|
+
if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'chat rules', why: 'usage: homie-studio chat rules <game> [--server <id>] --mode off|emoji|lines|text [--who …] [--slow <s>] … | --reset' };
|
|
44
|
+
const server = flags.get('server') ?? '';
|
|
45
|
+
const body = flags.has('reset') ? { game, server, reset: true } : { game, server, ...chatFields(flags) };
|
|
46
|
+
if (!body.reset && Object.keys(body).length <= 2) return { ok: false, command: 'chat rules', why: 'say what to change: --mode, --who, --react, --slow, --max, --links, --swears, --ai, --bubbles, --watchers, --hub, --block, --allow; or --reset' };
|
|
47
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/rules', body));
|
|
48
|
+
return askedFor(root, url, r, 'chat rules');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function chatRemove(root, game, room, id, { url, all = false } = {}) {
|
|
52
|
+
if (!GAME.test(String(game ?? '')) || !room || (!id && !all)) return { ok: false, command: 'chat remove', why: 'usage: homie-studio chat remove <game> <room> <line id> | --all (the ids are in `homie-studio chat`)' };
|
|
53
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/remove', { game, room, ...(all ? { all: true } : { id }) }));
|
|
54
|
+
return r.ok ? { ok: true, command: 'chat remove', removed: r.removed ?? 0, message: `Took ${r.removed ?? 0} message${r.removed === 1 ? '' : 's'} down in ${room}, on every screen.` } : { ok: false, command: 'chat remove', why: r.message ?? r.why };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function chatBudget(root, neurons, { url } = {}) {
|
|
58
|
+
const n = Math.floor(Number(neurons));
|
|
59
|
+
if (!(n >= 0 && n <= 1e7)) return { ok: false, command: 'chat budget', why: 'usage: homie-studio chat budget <neurons a day> (default 2000; Workers AI gives an account 10,000 a day free)' };
|
|
60
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/chat/budget', { neurons: n }));
|
|
61
|
+
return askedFor(root, url, r, 'chat budget');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The built-in list, by group: what the floor always holds (swears only while a game holds swears). */
|
|
65
|
+
export function chatWords() {
|
|
66
|
+
const w = builtinWords();
|
|
67
|
+
return {
|
|
68
|
+
ok: true, command: 'chat words',
|
|
69
|
+
groups: {
|
|
70
|
+
slurs: [...w.slurs], slursInsideWords: w.slursAnywhere, sexual: [...w.sexual], sexualInsideWords: w.sexualAnywhere,
|
|
71
|
+
swears: [...w.swears], swearsInsideWords: w.swearsAnywhere, harm: w.harm, contact: w.contact,
|
|
72
|
+
},
|
|
73
|
+
note: 'Whole words unless "inside words". Shown as the floor reads them (lower case, letters only). A studio adds its own with chat rules --block, and lets one through with --allow.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function chatLines(r) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const d = r.day;
|
|
80
|
+
if (d) out.push(`Review today: ${d.ai ? `${Math.round(d.used.neurons)} of ${d.budget.neurons} neurons, ${d.used.reviews} reviewed, ${d.used.held} held${d.used.errors ? `, ${d.used.errors} unanswered (the word list decided)` : ''}` : 'no Workers AI binding yet (npm run deploy binds it); the word list decides'}.`);
|
|
81
|
+
for (const g of r.games ?? []) {
|
|
82
|
+
const c = g.chat?.rules;
|
|
83
|
+
out.push(`${g.name} (${g.id}): ${c ? `${c.mode}${c.mode === 'text' ? `, typing ${c.who}` : ''}, emoji ${c.react}, slow ${c.slow} s${c.ai ? ', reviewed' : ''}${c.capped ? ` (capped: ${c.capped})` : ''}` : 'no rules read'} · from ${g.chat?.from ?? 'default'}`);
|
|
84
|
+
for (const sv of g.chat?.servers ?? []) if (sv.office) out.push(` server ${sv.id}: ${sv.rules.mode}, typing ${sv.rules.who}`);
|
|
85
|
+
for (const rm of g.rooms ?? []) for (const l of (rm.chat?.lines ?? []).slice(-8)) out.push(` ${rm.room} ${l.id} ${l.by === 'studio' ? 'Studio' : l.name}: ${l.kind === 'react' ? l.glyph : l.text}`);
|
|
86
|
+
for (const p of g.chat?.reports ?? []) out.push(` report ${p.id}: "${p.text}" by ${p.name} (${p.reasonText}), ${p.room}`);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -218,7 +218,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
218
218
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
219
219
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
220
220
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
|
|
221
|
-
step('Workers AI bound (AI): a server\'s AI guides think with it, within
|
|
221
|
+
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const started = Date.now();
|
|
@@ -271,6 +271,9 @@ export function needsWorkersAi(root, w, db) {
|
|
|
271
271
|
try {
|
|
272
272
|
const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
|
|
273
273
|
if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
|
|
274
|
+
// Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
|
|
275
|
+
// (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
|
|
276
|
+
if ((cat.games ?? []).some((g) => g.chat !== false && (g.chat?.mode ?? 'text') === 'text' && g.chat?.ai !== false)) return true;
|
|
274
277
|
} catch { /* not built */ }
|
|
275
278
|
const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
|
|
276
279
|
if (r.code !== 0) return false;
|
|
@@ -343,7 +346,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
343
346
|
try {
|
|
344
347
|
const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
|
|
345
348
|
if (!json.ai) { json.ai = { binding: 'AI' }; writeFileSync(configPath(root), `${JSON.stringify(json, null, 2)}\n`); }
|
|
346
|
-
step('Workers AI bound (AI)
|
|
349
|
+
step('Workers AI bound (AI): the AI guides and the review of typed room chat');
|
|
347
350
|
} catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
|
|
348
351
|
}
|
|
349
352
|
const started = Date.now();
|
package/lib/scaffold.mjs
CHANGED
|
@@ -28,6 +28,8 @@ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
|
28
28
|
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
29
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
|
+
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
32
|
+
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE } from '../worker/shop-store.mjs';
|
|
31
33
|
import { themeFile } from './site.mjs';
|
|
32
34
|
|
|
33
35
|
export const COMPAT_DATE = '2026-06-01';
|
|
@@ -341,6 +343,37 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
341
343
|
budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
|
|
342
344
|
Claude in a guide's seat.
|
|
343
345
|
|
|
346
|
+
## Selling things (the shop)
|
|
347
|
+
|
|
348
|
+
- **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
|
|
349
|
+
disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
|
|
350
|
+
\`SELLING.md\` says what that means for the owner in plain words (not legal advice).
|
|
351
|
+
- **\`shop.json\`** (at the studio's root, reviewed in git): \`till\` (\`stripe\`, the studio is the seller with Stripe
|
|
352
|
+
Tax on; \`stripe-managed\`, Stripe Managed Payments is the seller of record and files the tax for 3.5% more; or
|
|
353
|
+
\`off\`), \`currency\`, \`refundDays\` (at least 14), \`capPerPlayerMonth\` (cents, at most 5000) and \`items\`:
|
|
354
|
+
\`{ "id", "kind": "cosmetic"|"supporter"|"pass"|"unlock"|"tip", "name", "price": <cents>, "gives": ["skin:ember"],
|
|
355
|
+
"days"?, "game"?, "advantage"? }\`. Real money only: no gems, coins or points. \`npx --no-install homie-studio shop
|
|
356
|
+
init --supporter\` writes a US$5 Supporter pack; \`shop check\` and every build refuse what the kit refuses.
|
|
357
|
+
- **The kids rules are the kit's, not the studio's to switch off:** nothing random for money (an item that names
|
|
358
|
+
chance, odds, a crate, a box or a mystery is refused), no countdown offers, no shop on a kids server or in a studio
|
|
359
|
+
with \`"audience": "kids"\` in studio.json, spending off on every account until a neutral age question says adult,
|
|
360
|
+
nothing ever for under-13s, 13-17 only through a parent's own checkout (a one-time link), nothing with
|
|
361
|
+
\`"advantage": true\` on a beginner server, one hosted Stripe checkout per purchase, a monthly cap. The television
|
|
362
|
+
never sells: its store sheet is a code to buy on a phone.
|
|
363
|
+
- **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
|
|
364
|
+
\`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
|
|
365
|
+
play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
|
|
366
|
+
(\`peer.badge\`, set by the Worker, never by a hello). The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
|
|
367
|
+
- **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
|
|
368
|
+
prints a 127.0.0.1 link; the owner pastes a restricted key and the webhook secret there, and they go straight to
|
|
369
|
+
the Worker secrets. Test keys only unless \`--live\`. Never ask for a key in the chat; never write one anywhere.
|
|
370
|
+
- **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
|
|
371
|
+
\`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
|
|
372
|
+
account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
|
|
373
|
+
- **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
|
|
374
|
+
player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
|
|
375
|
+
signs them; the referrer invoices the studio. Nothing moves through Homie.
|
|
376
|
+
|
|
344
377
|
## Continuing a build from the Claude app
|
|
345
378
|
|
|
346
379
|
A Claude Code session started from the Claude app's card gets one short line, like
|
|
@@ -571,6 +604,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
571
604
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
572
605
|
[`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
|
|
573
606
|
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
607
|
+
[`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
|
|
608
|
+
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
574
609
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
575
610
|
'.claude/skills/.gitkeep': '',
|
|
576
611
|
};
|
|
@@ -662,14 +697,36 @@ export function ensureServersMigration(root) {
|
|
|
662
697
|
return `site/migrations/${SERVERS_MIGRATION_FILE}`;
|
|
663
698
|
}
|
|
664
699
|
|
|
700
|
+
/**
|
|
701
|
+
* A studio made before 0.24.0 has no shop migration (worker/shop-store.mjs: orders, entitlements, the age band,
|
|
702
|
+
* referral lines): add it, so the next `d1 migrations apply` makes its tables. A studio with no shop.json keeps
|
|
703
|
+
* them empty. Returns the file it wrote, or null when it was there.
|
|
704
|
+
*/
|
|
705
|
+
export function ensureShopMigration(root) {
|
|
706
|
+
const file = join(root, 'site', 'migrations', SHOP_MIGRATION_FILE);
|
|
707
|
+
if (existsSync(file)) return null;
|
|
708
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
709
|
+
writeFileSync(file, SHOP_MIGRATION);
|
|
710
|
+
return `site/migrations/${SHOP_MIGRATION_FILE}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
665
713
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
714
|
+
/** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
|
|
715
|
+
function ensureChatMigration(root) {
|
|
716
|
+
const file = join(root, 'site', 'migrations', CHAT_MIGRATION_FILE);
|
|
717
|
+
if (existsSync(file)) return null;
|
|
718
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
719
|
+
writeFileSync(file, CHAT_MIGRATION);
|
|
720
|
+
return `site/migrations/${CHAT_MIGRATION_FILE}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
666
723
|
export function ensureMigrations(root) {
|
|
667
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root)].filter(Boolean);
|
|
724
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root)].filter(Boolean);
|
|
668
725
|
}
|
|
669
726
|
|
|
670
727
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
671
728
|
export function migrationWord(file) {
|
|
672
|
-
return /players/.test(file) ? 'player accounts and cloud saves' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
729
|
+
return /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
673
730
|
}
|
|
674
731
|
|
|
675
732
|
/**
|
package/lib/shop.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
|
|
3
|
+
* rules, shop/SHOP.md the guide, shop/SELLING.md the owner's plain words).
|
|
4
|
+
*
|
|
5
|
+
* homie-studio shop is it selling, and if not, what is missing (the live site's office view)
|
|
6
|
+
* homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
|
|
7
|
+
* writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
|
|
8
|
+
* homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
|
|
9
|
+
* homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
|
|
10
|
+
* pastes the studio's restricted Stripe key and webhook signing secret; they
|
|
11
|
+
* go straight to the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET on
|
|
12
|
+
* Wrangler's standard input: never a chat, a file, an argument or a log.
|
|
13
|
+
* The page also offers Stripe Managed Payments in plain words. Test keys
|
|
14
|
+
* only, unless --live (then live keys only).
|
|
15
|
+
* homie-studio shop disconnect removes both secrets (the shop closes at once)
|
|
16
|
+
* homie-studio shop orders the latest orders (never a card or an email)
|
|
17
|
+
* homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
|
|
18
|
+
* ASKS the owner (a one-tap link): the AI never refunds by itself
|
|
19
|
+
* homie-studio shop statements [--period YYYY-MM] [--send] the signed referral statements this studio owes
|
|
20
|
+
*
|
|
21
|
+
* The proof of ownership is the studio's own Cloudflare login, as for the office.
|
|
22
|
+
*/
|
|
23
|
+
import { createServer } from 'node:http';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { askedFor, withKey } from './office.mjs';
|
|
29
|
+
import { runner } from './cloudflare.mjs';
|
|
30
|
+
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
31
|
+
import { SHOP_FILE, audienceOf, checkShop, money } from '../worker/shop-rules.mjs';
|
|
32
|
+
import { KEY_SHAPE, WEBHOOK_SECRET_SHAPE, apiBase, modeOf } from '../worker/stripe.mjs';
|
|
33
|
+
|
|
34
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
|
|
36
|
+
/** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
|
|
37
|
+
export const HOOK_EVENTS = Object.freeze([
|
|
38
|
+
'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
|
|
39
|
+
'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
|
|
40
|
+
]);
|
|
41
|
+
/** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
|
|
42
|
+
export const KEY_PERMISSIONS = Object.freeze([
|
|
43
|
+
['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
|
|
44
|
+
['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
|
|
45
|
+
['PaymentIntents', 'Read', 'which payment an order was'],
|
|
46
|
+
['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** shop.json read and checked with the studio's games and audience: { ok, shop, errors, warnings, file }. */
|
|
50
|
+
export function readShop(root) {
|
|
51
|
+
const file = join(root, SHOP_FILE);
|
|
52
|
+
if (!existsSync(file)) return { ok: true, shop: null, errors: [], warnings: [], file, absent: true };
|
|
53
|
+
let raw = null;
|
|
54
|
+
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { return { ok: false, shop: null, errors: [{ at: '', message: `shop.json is not JSON: ${error.message}` }], warnings: [], file }; }
|
|
55
|
+
const games = listGames(root).map((g) => g.id);
|
|
56
|
+
const studio = readStudio(root);
|
|
57
|
+
return { ...checkShop(raw, { games, audience: audienceOf(studio) }), file };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The build's half: the checked shop for games.json, or a thrown error that says what to fix (nothing is published). */
|
|
61
|
+
export function shopForBuild(root, { log = () => {} } = {}) {
|
|
62
|
+
const r = readShop(root);
|
|
63
|
+
if (r.absent) return null;
|
|
64
|
+
if (!r.ok) throw new Error(`shop.json breaks the shop's rules, so the build stops (nothing is sold that the kit refuses):\n${r.errors.map((e) => ` ${e.at || 'shop.json'}: ${e.message}`).join('\n')}`);
|
|
65
|
+
for (const w of r.warnings) log(`shop.json ${w.at}: ${w.message}`);
|
|
66
|
+
return r.shop;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function shopCheck(root) {
|
|
70
|
+
const r = readShop(root);
|
|
71
|
+
if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
|
|
72
|
+
return { ok: r.ok, command: 'shop check', errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.kind === 'tip' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
|
|
76
|
+
export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
|
|
77
|
+
return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function shopInit(root, { supporter = false, currency = 'usd', price = 500, managed = false } = {}) {
|
|
81
|
+
const file = join(root, SHOP_FILE);
|
|
82
|
+
if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
|
|
83
|
+
const studio = readStudio(root);
|
|
84
|
+
if (audienceOf(studio) === 'kids') return { ok: false, command: 'shop init', why: 'studio.json says "audience": "kids": a studio made for children sells nothing in its games' };
|
|
85
|
+
const shop = {
|
|
86
|
+
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(), refundDays: 14, capPerPlayerMonth: 5000,
|
|
87
|
+
items: supporter ? [supporterItem({ price: Math.floor(Number(price)) || 500 })] : [],
|
|
88
|
+
referrals: { rate: 0.1, windowDays: 30, capPerPlayer: 1000, holdDays: 30, minimumInvoice: 2500, accept: ['stripe-invoice'] },
|
|
89
|
+
};
|
|
90
|
+
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
91
|
+
if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
|
|
92
|
+
writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
|
|
93
|
+
const wrote = [SHOP_FILE];
|
|
94
|
+
if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
|
|
95
|
+
return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key is in)', 'homie-studio shop connect (the owner pastes a restricted key into a page on this computer)'] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
|
|
99
|
+
export async function shopStatus(root, { url } = {}) {
|
|
100
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
|
|
101
|
+
if (!r.ok) return { ok: false, command: 'shop', why: r.message ?? r.why ?? 'the studio did not answer' };
|
|
102
|
+
const local = readShop(root);
|
|
103
|
+
return { ok: true, command: 'shop', ready: r.ready, mode: r.mode, missing: r.missing, totals: r.totals, stripe: r.stripe, hook: r.hook, till: r.shop?.till ?? null, items: r.shop?.items ?? [], local: { ok: local.ok, absent: Boolean(local.absent), errors: local.errors } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function shopOrders(root, { url } = {}) {
|
|
107
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
|
|
108
|
+
if (!r.ok) return { ok: false, command: 'shop orders', why: r.message ?? r.why ?? 'the studio did not answer' };
|
|
109
|
+
return { ok: true, command: 'shop orders', orders: (r.orders ?? []).map((o) => ({ id: o.id, at: new Date(o.createdAt).toISOString(), item: o.item, name: o.name, shown: o.shown, status: o.status, mode: o.mode, player: o.player?.name ?? null, via: o.via, stripe: o.stripe })) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The AI proposes a refund: an ask the owner confirms with one tap (an office key can never refund by itself). */
|
|
113
|
+
export async function shopRefund(root, order, { url, reason, note } = {}) {
|
|
114
|
+
if (!/^ord_[A-Za-z0-9]{20}$/.test(String(order ?? ''))) return { ok: false, command: 'shop refund', why: 'name the order: homie-studio shop refund ord_… (homie-studio shop orders lists them)' };
|
|
115
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/shop/refund', { order, ...(reason ? { reason } : {}), ...(note ? { note } : {}) }));
|
|
116
|
+
return askedFor(root, url, r, 'shop refund');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function shopStatements(root, { url, period, send = false } = {}) {
|
|
120
|
+
const r = await withKey(root, url, (call) => (send ? call('/_studio/api/shop/statements/send', { ...(period ? { period } : {}) }) : call(`/_studio/api/shop/statements${period ? `?period=${encodeURIComponent(period)}` : ''}`)));
|
|
121
|
+
if (!r.ok) return { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
|
|
122
|
+
return { ok: true, command: 'shop statements', period: r.period, ...(send ? { sent: r.sent } : { statements: r.statements }) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ the key, from a page on this computer */
|
|
126
|
+
|
|
127
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
128
|
+
|
|
129
|
+
/** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, two fields, one button. */
|
|
130
|
+
export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
|
|
131
|
+
const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
|
|
132
|
+
const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
|
|
133
|
+
const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
|
|
134
|
+
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Connect your shop to Stripe</title>
|
|
135
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}</style>
|
|
136
|
+
<h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
|
|
137
|
+
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to your studio's Worker as two secrets, and nowhere else: not to the chat, not to a file, not to a log.</p>
|
|
138
|
+
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe account in test mode (the keys start with <code>rk_test_</code>): nothing real is charged, and test cards work. Switch to live keys only when the house is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
|
|
139
|
+
<h2>1. In Stripe: a restricted key</h2>
|
|
140
|
+
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>.</li>
|
|
141
|
+
<li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
|
|
142
|
+
<li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
|
|
143
|
+
<h2>2. In Stripe: where Stripe tells your shop about payments</h2>
|
|
144
|
+
<ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
|
|
145
|
+
<li>Endpoint URL: <code>${esc(site)}/api/shop/hook</code></li>
|
|
146
|
+
<li>Events: ${events}</li>
|
|
147
|
+
<li>Create it, press <b>Reveal</b> on the signing secret, and copy it (it starts with <code>whsec_</code>).</li></ol>
|
|
148
|
+
<form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
|
|
149
|
+
<h2>3. Paste both here</h2>
|
|
150
|
+
<label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
|
|
151
|
+
<label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…" required>
|
|
152
|
+
<h2>4. Who is the seller?</h2>
|
|
153
|
+
<fieldset>
|
|
154
|
+
<label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Stripe Tax is on: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
|
|
155
|
+
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).</span></label>
|
|
156
|
+
</fieldset>
|
|
157
|
+
<button>Save to my Worker</button></form>
|
|
158
|
+
<p><small>This page works once and closes in ten minutes. You can change the seller later in shop.json ("till"). This is not legal or tax advice: an accountant can tell you where you must register.</small></p>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key and the secret go straight
|
|
163
|
+
* to `wrangler secret put` on its standard input. The command never prints them; it checks only their shape (and,
|
|
164
|
+
* when `verify` is on, that Stripe accepts the key with one read).
|
|
165
|
+
*/
|
|
166
|
+
export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
|
|
167
|
+
const studio = readStudio(root);
|
|
168
|
+
const site = siteUrl(root) ?? 'https://<your studio>';
|
|
169
|
+
const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
|
|
170
|
+
const w = runner(root, env);
|
|
171
|
+
const shopNow = readShop(root);
|
|
172
|
+
const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
|
|
173
|
+
const nonce = randomBytes(16).toString('hex');
|
|
174
|
+
const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
|
|
175
|
+
return await new Promise((done) => {
|
|
176
|
+
let finished = false;
|
|
177
|
+
const server = createServer((req, res) => {
|
|
178
|
+
if (req.method === 'GET' && req.url === `/${nonce}`) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(page); return; }
|
|
179
|
+
if (req.method === 'POST' && req.url === '/key') {
|
|
180
|
+
let body = '';
|
|
181
|
+
req.on('data', (c) => { body += c; if (body.length > 8192) req.destroy(); });
|
|
182
|
+
req.on('end', async () => {
|
|
183
|
+
const form = new URLSearchParams(body);
|
|
184
|
+
const key = String(form.get('key') ?? '').trim();
|
|
185
|
+
const hook = String(form.get('hook') ?? '').trim();
|
|
186
|
+
const chosen = form.get('till') === 'stripe-managed' ? 'stripe-managed' : 'stripe';
|
|
187
|
+
const say = (status, text) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
188
|
+
if (form.get('n') !== nonce || finished) { say(403, 'This page was used already.'); return; }
|
|
189
|
+
if (/^sk_live_/.test(key)) { say(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the four permissions on the page. Go back and make one.'); return; }
|
|
190
|
+
if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.'); return; }
|
|
191
|
+
// Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
|
|
192
|
+
if (modeOf(key) !== (live ? 'live' : 'test')) { say(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).'); return; }
|
|
193
|
+
if (!WEBHOOK_SECRET_SHAPE.test(hook)) { say(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again.'); return; }
|
|
194
|
+
finished = true;
|
|
195
|
+
// One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
|
|
196
|
+
// except to Stripe itself.
|
|
197
|
+
if (verify) {
|
|
198
|
+
let ok = false;
|
|
199
|
+
try {
|
|
200
|
+
const r = await fetcher(`${apiBase(process.env)}/v1/checkout/sessions?limit=1`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
201
|
+
ok = r.ok;
|
|
202
|
+
} catch { ok = false; }
|
|
203
|
+
if (!ok) { finished = false; say(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.'); return; }
|
|
204
|
+
}
|
|
205
|
+
const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
|
|
206
|
+
const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
|
|
207
|
+
const okay = a.code === 0 && b.code === 0;
|
|
208
|
+
let tillNote = '';
|
|
209
|
+
if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
|
|
210
|
+
try {
|
|
211
|
+
const raw = JSON.parse(readFileSync(shopNow.file, 'utf8'));
|
|
212
|
+
raw.till = chosen;
|
|
213
|
+
writeFileSync(shopNow.file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
214
|
+
tillNote = ` shop.json now says "till": "${chosen}" (deploy to use it).`;
|
|
215
|
+
} catch { tillNote = ''; }
|
|
216
|
+
}
|
|
217
|
+
say(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode). You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.');
|
|
218
|
+
server.close();
|
|
219
|
+
done(okay
|
|
220
|
+
? { ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen, message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${tillNote} homie-studio shop says whether anything else is missing.` }
|
|
221
|
+
: { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
res.writeHead(404); res.end();
|
|
226
|
+
});
|
|
227
|
+
server.listen(port, '127.0.0.1', () => {
|
|
228
|
+
const link = `http://127.0.0.1:${server.address().port}/${nonce}`;
|
|
229
|
+
log(`Open this page on this computer; the owner pastes the key there (never in a chat): ${link}`);
|
|
230
|
+
});
|
|
231
|
+
setTimeout(() => { if (!finished) { finished = true; server.close(); done({ ok: false, command: 'shop connect', why: 'nothing was saved in ten minutes; run it again when ready' }); } }, wait).unref?.();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function shopDisconnect(root) {
|
|
236
|
+
const studio = readStudio(root);
|
|
237
|
+
const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
238
|
+
const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
|
|
239
|
+
const b = w(['secret', 'delete', 'STRIPE_WEBHOOK_SECRET'], { input: 'y\n' });
|
|
240
|
+
return a.code === 0 || b.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Stripe key and webhook secret are gone from the Worker: the shop is closed. Delete the restricted key in Stripe too (Developers → API keys).' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Lines for a person (the CLI's print). */
|
|
244
|
+
export function shopLines(r) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
if (r.command === 'shop') {
|
|
247
|
+
lines.push(r.ready ? `The shop is open${r.mode === 'test' ? ' in TEST MODE (no real money)' : ', live'} (till: ${r.till}).` : 'The shop is not selling yet:');
|
|
248
|
+
for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
|
|
249
|
+
if (r.totals) lines.push(`Last 30 days: ${r.totals.sales} sales, ${money(r.totals.paid, r.totals.currency)} before tax and fees; ${r.totals.refunds} refunded; ${r.totals.disputes} disputed.`);
|
|
250
|
+
lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
|
|
251
|
+
if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
|
|
252
|
+
} else if (r.command === 'shop orders') {
|
|
253
|
+
for (const o of r.orders) lines.push(`${o.id} ${o.at.slice(0, 16)} ${o.name} ${o.shown} ${o.status}${o.mode === 'test' ? ' (test)' : ''} ${o.player ?? '(deleted account)'}${o.via ? ` via ${o.via}` : ''}`);
|
|
254
|
+
if (!r.orders.length) lines.push('No orders yet.');
|
|
255
|
+
} else if (r.command === 'shop check') {
|
|
256
|
+
if (r.absent) lines.push(r.message);
|
|
257
|
+
else {
|
|
258
|
+
lines.push(r.ok ? `shop.json follows the rules: ${r.items.join(', ') || 'no items yet'} (till: ${r.till}).` : 'shop.json breaks the rules:');
|
|
259
|
+
for (const e of r.errors ?? []) lines.push(` ${e.at}: ${e.message}`);
|
|
260
|
+
for (const w of r.warnings ?? []) lines.push(` note ${w.at}: ${w.message}`);
|
|
261
|
+
}
|
|
262
|
+
} else if (r.command === 'shop init') {
|
|
263
|
+
lines.push(`Wrote ${r.wrote.join(', ')} (till: ${r.till}${r.items.length ? `; ${r.items.join(', ')}` : ''}). Next:`, ...r.next.map((n) => ` ${n}`));
|
|
264
|
+
} else if (r.command === 'shop statements') {
|
|
265
|
+
if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
|
|
266
|
+
else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${money(s.statement.totals.due, s.statement.currency)} (${s.statement.lines.length} line(s)), signed`);
|
|
267
|
+
if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
|
|
268
|
+
} else lines.push(r.message ?? '');
|
|
269
|
+
return lines;
|
|
270
|
+
}
|
|
@@ -962,6 +962,83 @@
|
|
|
962
962
|
}
|
|
963
963
|
}
|
|
964
964
|
}
|
|
965
|
+
},
|
|
966
|
+
"0.23.0": {
|
|
967
|
+
"files": {
|
|
968
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
969
|
+
"README.md": "684b3cca8001673c",
|
|
970
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
971
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
972
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
973
|
+
"videos/README.md": "16babc3437d5545b",
|
|
974
|
+
"posts/README.md": "37841aae476bd540",
|
|
975
|
+
"site/README.md": "ca131cd0472464fb",
|
|
976
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
977
|
+
},
|
|
978
|
+
"sections": {
|
|
979
|
+
"AGENTS.md": {
|
|
980
|
+
"(intro)": "214aa46eb3a895d5",
|
|
981
|
+
"## Layout": "8baf49e4291e7e88",
|
|
982
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
983
|
+
"## The site": "ab922bb6bde23293",
|
|
984
|
+
"## Making games": "a8a893eeada33bf5",
|
|
985
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
986
|
+
"## Art direction and models": "124772c374de7390",
|
|
987
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
988
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
989
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
990
|
+
"## Rules": "7e8458adc1b81ece",
|
|
991
|
+
"## Beta": "9d02638207a26e73"
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
"named": {
|
|
995
|
+
"Homie Arcade": {
|
|
996
|
+
"sections": {
|
|
997
|
+
"AGENTS.md": {
|
|
998
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
"0.24.0": {
|
|
1005
|
+
"files": {
|
|
1006
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1007
|
+
"README.md": "684b3cca8001673c",
|
|
1008
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1009
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1010
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1011
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1012
|
+
"posts/README.md": "37841aae476bd540",
|
|
1013
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1014
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1015
|
+
},
|
|
1016
|
+
"sections": {
|
|
1017
|
+
"AGENTS.md": {
|
|
1018
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1019
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1020
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1021
|
+
"## The site": "ab922bb6bde23293",
|
|
1022
|
+
"## Making games": "a8a893eeada33bf5",
|
|
1023
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1024
|
+
"## Art direction and models": "124772c374de7390",
|
|
1025
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1026
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
1027
|
+
"## Selling things (the shop)": "9d5c3c58d446bfa5",
|
|
1028
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1029
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1030
|
+
"## Beta": "9d02638207a26e73"
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
"named": {
|
|
1034
|
+
"Homie Arcade": {
|
|
1035
|
+
"sections": {
|
|
1036
|
+
"AGENTS.md": {
|
|
1037
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
965
1042
|
}
|
|
966
1043
|
}
|
|
967
1044
|
}
|