@homie-rocks/studio 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/homie-studio.mjs +10 -3
  3. package/lib/cloudflare.mjs +40 -6
  4. package/lib/dev.mjs +1 -1
  5. package/lib/doctor.mjs +7 -7
  6. package/lib/domain.mjs +43 -0
  7. package/lib/jobs.mjs +17 -7
  8. package/lib/mcp-tools.mjs +49 -18
  9. package/lib/mcp.mjs +6 -11
  10. package/lib/parts-build.mjs +23 -4
  11. package/lib/parts-cli.mjs +54 -3
  12. package/lib/parts-purchase.mjs +174 -0
  13. package/lib/parts-store.mjs +50 -19
  14. package/lib/parts-tools.mjs +15 -12
  15. package/lib/parts-upload.mjs +43 -0
  16. package/lib/parts.mjs +49 -5
  17. package/lib/prepare.mjs +47 -0
  18. package/lib/purchase-links.mjs +42 -0
  19. package/lib/purchase-wallet.mjs +114 -0
  20. package/lib/scaffold.mjs +35 -5
  21. package/lib/shoot.mjs +1 -1
  22. package/lib/shop-links.mjs +15 -6
  23. package/lib/shop.mjs +91 -21
  24. package/lib/stripe-connect.mjs +2 -2
  25. package/lib/template-history.json +82 -0
  26. package/package.json +12 -2
  27. package/parts/COMMERCIAL-LICENCE.md +21 -0
  28. package/parts/PARTS.md +238 -10
  29. package/worker/extensions.mjs +13 -0
  30. package/worker/index.mjs +3 -2
  31. package/worker/machine-payments.mjs +72 -0
  32. package/worker/office.mjs +4 -1
  33. package/worker/parts-protected.mjs +111 -0
  34. package/worker/parts-purchase-catalogue.mjs +34 -0
  35. package/worker/parts-resource.mjs +32 -0
  36. package/worker/parts-sale.mjs +59 -0
  37. package/worker/parts.mjs +24 -11
  38. package/worker/payment-capabilities.mjs +70 -0
  39. package/worker/payment-dry-exchange.mjs +30 -0
  40. package/worker/payment-manual-refund.mjs +40 -0
  41. package/worker/payment-policy.mjs +40 -0
  42. package/worker/payment-recovery.mjs +163 -0
  43. package/worker/payment-test-exchange.mjs +19 -0
  44. package/worker/purchase-core.mjs +140 -0
  45. package/worker/purchase-crypto.mjs +17 -0
  46. package/worker/purchase-delivery.mjs +108 -0
  47. package/worker/purchase-discovery.mjs +26 -0
  48. package/worker/purchase-hosted-readiness.mjs +16 -0
  49. package/worker/purchase-keys.mjs +20 -0
  50. package/worker/purchase-links.mjs +9 -0
  51. package/worker/purchase-machine.mjs +279 -0
  52. package/worker/purchase-mcp.mjs +42 -0
  53. package/worker/purchase-office-routes.mjs +17 -0
  54. package/worker/purchase-office-script.mjs +38 -0
  55. package/worker/purchase-office.mjs +35 -0
  56. package/worker/purchase-owner.mjs +243 -0
  57. package/worker/purchase-pricing.mjs +8 -0
  58. package/worker/purchase-problem.mjs +4 -0
  59. package/worker/purchase-reconciliation.mjs +582 -0
  60. package/worker/purchase-response.mjs +82 -0
  61. package/worker/purchase-routes.mjs +611 -0
  62. package/worker/purchase-schema.mjs +50 -0
  63. package/worker/purchase-store.mjs +148 -0
  64. package/worker/purchases.mjs +6 -0
  65. package/worker/resource-kinds.mjs +20 -0
  66. package/worker/selling/index.mjs +8 -0
  67. package/worker/selling/runtime.mjs +42 -0
  68. package/worker/settlement-journal.mjs +138 -0
  69. package/worker/shop-page.mjs +7 -4
  70. package/worker/shop-refunds.mjs +22 -0
  71. package/worker/shop-rules.mjs +1 -1
  72. package/worker/shop.mjs +18 -18
  73. package/worker/stripe.mjs +36 -3
  74. package/worker/version.mjs +1 -1
@@ -1,3 +1,4 @@
1
+ import { purchasePortal } from './parts-purchase.mjs';
1
2
  /**
2
3
  * GAME PARTS AS CHAT TOOLS (parts/PARTS.md): parts_find, part_add, part_new and part_share, named and worded like
3
4
  * the asset tools beside them (assets_find, asset_add). Four tools are the whole surface: a newer version of a part
@@ -11,7 +12,7 @@ import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
11
12
  import { partsLines } from './parts-cli.mjs';
12
13
 
13
14
  /** What the planning and game-making tools say about parts, in one place so a test can hold them to it. */
14
- export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of real games other studios shared: a creature from one game, a level from another, a bot brain from a third, each brought in with part_add. Record in the game\'s CODEX what came from where: which packages, which parts and the game and studio each came from, and what was written here and why. After building a piece another game could use, offer the person to make it a part (part_new lifts it out of the game) and, only if they ask, to share it (part_share).';
15
+ export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of games, apps, music and video other studios shared: a creature from one game, a level from another, a bot brain from a third, each brought in with part_add. Record in the project notes or CODEX what came from where: which packages, which parts and the work and studio each came from, and what was written here and why. After building a reusable piece, offer the person to make it a part (part_new lifts it out of the game) and, only if they ask, to share it (part_share).';
15
16
 
16
17
  /** A fix-it line that names the plumbing, said as the chat tool that does the same. */
17
18
  export const inChat = (t) => String(t).replace(/homie-studio parts add (\S+?)(?:@(\S+))?(?: --game (\S+))?(?=[\s).,;]|$)/g, (m, ref, v, g) => `part_add { "part": "${ref}${v ? `@${v}` : ''}"${g ? `, "game": "${g}"` : ''} }`);
@@ -24,36 +25,38 @@ export function partsToolDefs(ctx, h) {
24
25
  {
25
26
  name: 'parts_find', title: 'Find game parts',
26
27
  description: 'Search the pieces of real games that studios shared (a creature, a level generator, a chase camera, a bot brain, a pickup mechanic, an audio pack), and this studio\'s own parts. The first move when planning or building a game: run it for each system the game needs before writing one from scratch, so a new game can be made with parts from several games. Each result has its licence, the game and studio it came from, its costs, what it builds on and what part_add takes. If the catalogue cannot be reached it says so (that is not "nothing exists"). Homie\'s @homie-rocks/* packages are not parts and are not listed here: they come from npm.',
27
- inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'Only this kind of part' }, tag: str('Only parts with this tag'), license: str('Only this licence (an SPDX identifier, e.g. CC0-1.0)'), builds: str('Only parts that build on this: a package ("@homie-rocks/camera") or a skeleton ("humanoid-v1")'), ...STUDIO_ARG } },
28
+ inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string', description: 'Only this kind of part' }, tag: str('Only parts with this tag'), license: str('Only this licence (an SPDX identifier, e.g. CC0-1.0)'), builds: str('Only parts that build on this: a package ("@homie-rocks/camera") or a skeleton ("humanoid-v1")'), ...STUDIO_ARG } },
28
29
  annotations: { title: 'Find game parts', ...RO, openWorldHint: true },
29
30
  run: async (a) => {
30
31
  const root = ctx.root(a.studio, { need: false });
31
32
  const r = await findParts(root, String(a.query ?? ''), { kind: a.kind ?? null, tag: a.tag ?? null, license: a.license ?? null, builds: a.builds ?? null, fetch: net().fetch, hub: ctx.directory ?? null });
32
- return ok([text(r), '', r.results.length ? 'Tell the person what you found and which game each piece is from, with your pick; part_add brings one in. Write in the game\'s CODEX which parts it uses and what you wrote yourself and why.' : 'Nothing fits: write it, and say so in the game\'s CODEX. If it turns out reusable, offer to make it a part (part_new).'].join('\n'), { kind: 'parts', ...r });
33
+ return ok([text(r), '', r.results.length ? 'Tell the person what you found and which work and studio each piece is from, with your pick; part_add brings one in. Write in the project notes or CODEX which parts it uses and what you wrote yourself and why.' : 'Nothing fits: write it, and say so in the project notes or CODEX. If it turns out reusable, offer to make it a part (part_new).'].join('\n'), { kind: 'parts', ...r });
33
34
  },
34
35
  },
35
36
  {
36
- name: 'part_add', title: 'Add a game part',
37
- description: 'Bring a part another studio shared into this studio, for a game: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
38
- inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game that will use it (its credits get the line)'), overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
39
- annotations: { title: 'Add a game part', ...RW, openWorldHint: true },
37
+ name: 'part_add', title: 'Add a studio part',
38
+ description: 'Bring a part another studio shared into this studio, for a game or app, music or video: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
39
+ inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game or app that will use it (its credits get the line)'), approve: str('The exact quote hash returned by part_add, ONLY with owner or wallet authority covering its exact price, currency, quantity, recurrence and terms'), wallet: { type: 'string', enum: ['link-cli', 'tempo', 'purl'], description: 'The person’s installed wallet client; its authority and limits govern payment' }, quantity: { type: 'integer', minimum: 1, description: 'Quantity under the seller’s licence terms' }, offline: { type: 'boolean', description: 'Restore the verified private purchase backup without the seller; revocation status is only last known' }, manage: { type: 'boolean', description: 'Return the purchase Stripe portal link to manage or cancel a subscription' }, overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
40
+ annotations: { title: 'Add a studio part', ...RW, openWorldHint: true },
40
41
  run: async (a) => {
41
42
  const root = ctx.root(a.studio);
43
+ if (a.manage) { const r = await purchasePortal(root, String(a.part ?? ''), { game: a.game ?? null, ...net() }); return ok(r.message, { kind: 'part', ...r }); }
42
44
  const said = [];
43
- const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, say: (l) => said.push(l), ...net() });
45
+ const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, approve: a.approve ?? null, quantity: a.quantity ?? 1, offline: a.offline === true, wallet: a.wallet, say: (l) => said.push(l), ...net() });
46
+ if (!r.ok && r.purchase) return ok(r.why, { kind: 'part', ...r, awaitingPurchase: true });
44
47
  if (!r.ok) return fail(`Not added: ${inChat(r.why)}`, { kind: 'part', ...r });
45
48
  const next = [];
46
49
  if (r.import) next.push(`In the game: import … from '${r.import}', then build. Its part.json says what it takes ("contract") and its tuning.json holds the values to tune.`);
47
50
  for (const n of r.needs) next.push(`It needs another part, ${n.ref}: part_add { "part": "${n.ref}"${a.game ? `, "game": "${a.game}"` : ''} }.`);
48
51
  if (r.licences.some((c) => c.level === 'conflict')) next.push('The licences cannot be combined (above): tell the person plainly before building on it.');
49
52
  if (r.packages.some((p) => p.state === 'pinned' || p.state === 'failed')) next.push('A package it builds on is not in place (above): the part will not build until it is.');
50
- return ok([...said, text(r), ...next, 'Write in the game\'s CODEX that it uses this part and which game and studio it came from.'].join('\n'), { kind: 'part', ...r });
53
+ return ok([...said, text(r), ...next, 'Write in the project notes or CODEX that it uses this part and which work and studio it came from.'].join('\n'), { kind: 'part', ...r });
51
54
  },
52
55
  },
53
56
  {
54
57
  name: 'part_new', title: 'Make a shared part',
55
- description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
56
- inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
58
+ description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game or app id) and files (paths inside that experience: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
59
+ inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game or app to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ or apps/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
57
60
  annotations: { title: 'Make a shared part', ...RW },
58
61
  run: async (a) => {
59
62
  const root = ctx.root(a.studio);
@@ -64,7 +67,7 @@ export function partsToolDefs(ctx, h) {
64
67
  },
65
68
  {
66
69
  name: 'part_share', title: 'Share a part',
67
- description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
70
+ description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and optional sale and licenseTerms fields in part.json for a paid licence, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
68
71
  inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
69
72
  annotations: { title: 'Share a part', ...RW },
70
73
  run: async (a) => {
@@ -0,0 +1,43 @@
1
+ /** Private R2 releases. Upload and verify before a deploy exposes the public offer. */
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { listOwnParts, packedDir, packedVersions, publicPart, readPart, sha256, verifyFiles } from './parts.mjs';
6
+ import { objectKey, releaseKey } from "../worker/parts-sale.mjs";
7
+
8
+ export function paidReleases(root) {
9
+ return listOwnParts(root).flatMap(({ id }) => packedVersions(root, id).map((version) => {
10
+ const dir = packedDir(root, id, version); const part = readPart(dir);
11
+ return part.sale ? { dir, part: publicPart({ ...part, share: true }) } : null;
12
+ }).filter(Boolean));
13
+ }
14
+
15
+ /** The uploader uses the same Wrangler execution and read-back hashing as mediaMove. */
16
+ export async function uploadPaidParts(root, { bucket, put, hash, log = () => {} }) {
17
+ const releases = paidReleases(root);
18
+ if (!releases.length) return { releases: 0, files: 0 };
19
+ if (!bucket) throw new Error('Paid parts need private R2 storage. Set up storage with homie-studio storage add before deploying. Never enable a public bucket domain.');
20
+ const temp = mkdtempSync(join(tmpdir(), 'homie-parts-upload-')); const seen = new Set();
21
+ try {
22
+ const upload = async (key, path, expected) => {
23
+ const before = await hash(`${bucket}/${key}`);
24
+ if (before.code === 0 && before.sha256 === expected.sha256 && before.bytes === expected.bytes) return;
25
+ if (before.code === 0 && key.startsWith('paid-parts/releases/')) throw new Error(`${key} already exists with different terms. A release is immutable`);
26
+ const sent = await put(`${bucket}/${key}`, path);
27
+ if (sent.code !== 0) throw new Error(`Paid part upload failed for ${key}. Nothing may be offered until storage is verified`);
28
+ const got = await hash(`${bucket}/${key}`);
29
+ if (got.code !== 0 || got.sha256 !== expected.sha256 || got.bytes !== expected.bytes) throw new Error(`Paid part upload failed integrity verification for ${key}`);
30
+ };
31
+ for (const { dir, part } of releases) {
32
+ if (!verifyFiles(dir, part).ok) throw new Error(`Packed part ${part.id} ${part.version} no longer matches its files`);
33
+ for (const f of part.files) {
34
+ if (seen.has(f.sha256)) continue;
35
+ await upload(objectKey(f.sha256), join(dir, f.path), f); seen.add(f.sha256);
36
+ }
37
+ const bytes = Buffer.from(JSON.stringify(part)); const file = join(temp, 'part.json'); writeFileSync(file, bytes);
38
+ await upload(releaseKey(part.id, part.version), file, { sha256: sha256(bytes), bytes: bytes.length });
39
+ log(`verified private part ${part.id} ${part.version} in R2`);
40
+ }
41
+ return { releases: releases.length, files: seen.size };
42
+ } finally { rmSync(temp, { recursive: true, force: true }); }
43
+ }
package/lib/parts.mjs CHANGED
@@ -20,12 +20,17 @@ import { experienceDir, experienceFile } from './studio.mjs';
20
20
  * NOTHING HERE IS A PACKAGE MANAGER: packages are npm's (lib/parts-store.mjs hands them to npm), and a part is copied
21
21
  * in and then the studio's own. There are no version ranges, no resolver and no lockfile of Homie's.
22
22
  */
23
+ import { canonicalJson } from '../worker/referrals.mjs';
24
+ import { createRequire } from 'node:module';
25
+ const require = createRequire(import.meta.url);
26
+ const parseSpdx = (value) => require('spdx-expression-parse')(value);
27
+ import { saleProblems } from "../worker/parts-sale.mjs";
23
28
  import { createHash } from 'node:crypto';
24
29
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
30
  import { dirname, join, relative } from 'node:path';
26
31
 
27
32
  export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
28
- export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
33
+ export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template', 'app', 'music', 'video']);
29
34
  export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
30
35
  export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
31
36
  export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
@@ -107,7 +112,10 @@ export function licenseOfPart(value) {
107
112
  const id = typeof value === 'string' ? value.trim() : '';
108
113
  // Not an identifier at all; a word people write instead of one (cc0, proprietary); or a real
109
114
  // identifier in the wrong case (SPDX is case-sensitive, and "mit" must not pass as some unknown licence).
110
- if (!id || !SPDX_SHAPE.test(id)) return null;
115
+ if (!id) return null;
116
+ if (!SPDX_SHAPE.test(id)) {
117
+ try { parseSpdx(id); return { id, class: 'unrecognised', credit: true, asks: 'read this SPDX expression and its licence texts; no alternative was selected', known: false }; } catch { return null; }
118
+ }
111
119
  const cased = Object.values(CLASSES).flatMap((c) => c.ids).find((k) => k.toLowerCase() === id.toLowerCase());
112
120
  if (cased ? cased !== id : Object.hasOwn(NOT_SPDX, id.toLowerCase())) return null;
113
121
  for (const [name, c] of Object.entries(CLASSES)) if (c.ids.includes(id)) return { id, class: name, credit: c.credit, asks: c.asks, known: true };
@@ -231,7 +239,7 @@ export function checkPart(part, { id = null } = {}) {
231
239
  if (!PART_ID.test(String(part.id ?? ''))) bad('id', `"${String(part.id ?? '').slice(0, 60)}" is not a part id`, 'lowercase letters, digits and hyphens, starting with a letter or digit (chase-camera)');
232
240
  else if (id && part.id !== id) bad('id', `the folder is parts/${id} but part.json says "${part.id}"`, 'name them the same');
233
241
  if (!clean(part.name, 80)) bad('name', 'it has no name', 'a few words a person would search for ("Third-person chase camera")');
234
- if (!PART_KINDS.includes(part.kind)) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, `one of: ${PART_KINDS.join(', ')}`);
242
+ if (!(typeof part.kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(part.kind))) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, 'a lowercase label using letters, digits and hyphens');
235
243
  if (!isVersion(part.version)) bad('version', part.version === undefined ? 'it has no version' : `"${String(part.version).slice(0, 40)}" is not a version`, 'three numbers: 1.0.0');
236
244
  // A part being written has no summary yet; one that is shared must (shareProblems).
237
245
  if (!clean(part.summary)) warn('summary', 'it has no summary', 'one sentence: what it does and what it needs from a game');
@@ -239,6 +247,8 @@ export function checkPart(part, { id = null } = {}) {
239
247
  if (part.share !== undefined && typeof part.share !== 'boolean') bad('share', 'it is neither true nor false', 'false keeps the part private (the default); sharing the part sets it true');
240
248
  if (part.license !== undefined && part.license !== null && part.license !== '' && !licenseOfPart(part.license)) bad('license', 'it is not a licence a stranger can rely on', licenceHint(part.license));
241
249
  if (part.attribution !== undefined && typeof part.attribution !== 'string') bad('attribution', 'it is not text', 'the line a game using the part shows in its credits, or ""');
250
+ for (const problem of saleProblems(part)) bad('sale', problem);
251
+ if (part.licenseTerms !== undefined && !safePath(part.licenseTerms)) bad('licenseTerms', 'must be a file inside the part');
242
252
  if (part.tags !== undefined) {
243
253
  if (!Array.isArray(part.tags) || part.tags.some((t) => typeof t !== 'string' || !/^[a-z0-9][a-z0-9-]{0,29}$/.test(t))) bad('tags', 'a tag is not a lowercase word', 'up to 12 words such as "camera", "3d", "top-down"');
244
254
  else if (part.tags.length > 12) warn('tags', 'more than 12 tags; the first 12 are used', null);
@@ -444,6 +454,24 @@ export function packPart(root, id, { bump = null } = {}) {
444
454
  const dir = partDir(root, id);
445
455
  let part = readPart(dir);
446
456
  if (bump) { part.version = bumpVersion(part.version, bump); writePart(dir, part); }
457
+ // REUSE places custom licence texts under LICENSES and annotates binary files.
458
+ if (part.sale && part.licenseTerms && safePath(part.licenseTerms) && existsSync(join(dir, part.licenseTerms)) && !existsSync(packedDir(root, id, part.version))) {
459
+ const refs = [...new Set(String(part.license).match(/LicenseRef-[A-Za-z0-9.-]+/g) ?? [])];
460
+ if (refs.length > 1 && refs.some((ref) => !existsSync(join(dir, 'LICENSES', `${ref}.txt`)))) return { ok: false, command: 'parts pack', id, why: 'An expression with multiple custom licences needs each full text under LICENSES before packing' };
461
+ for (const ref of refs) {
462
+ const target = join(dir, 'LICENSES', `${ref}.txt`);
463
+ mkdirSync(dirname(target), { recursive: true });
464
+ if (!existsSync(target)) cpSync(join(dir, part.licenseTerms), target);
465
+ }
466
+ if (!existsSync(join(dir, 'REUSE.toml'))) writeFileSync(join(dir, 'REUSE.toml'), `version = 1
467
+
468
+ [[annotations]]
469
+ path = ["**"]
470
+ precedence = "aggregate"
471
+ SPDX-FileCopyrightText = ${JSON.stringify(part.attribution || 'The selling studio')}
472
+ SPDX-License-Identifier = ${JSON.stringify(part.license)}
473
+ `);
474
+ }
447
475
  part = writeHashes(dir);
448
476
  const shape = checkPart(part, { id });
449
477
  if (!shape.ok) return { ok: false, command: 'parts pack', id, why: `parts/${id} is not a part yet: ${shape.problems.filter((p) => p.level === 'refuse').map((p) => `${p.field}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).slice(0, 4).join('; ')}`, problems: shape.problems };
@@ -451,6 +479,9 @@ export function packPart(root, id, { bump = null } = {}) {
451
479
  if (existsSync(out)) {
452
480
  const was = readPart(out);
453
481
  const same = JSON.stringify((was.files ?? []).map((f) => [f.path, f.sha256])) === JSON.stringify(part.files.map((f) => [f.path, f.sha256]));
482
+ if ((was.sale || part.sale) && canonicalJson(publicPart({ ...was, share: true })) !== canonicalJson(publicPart({ ...part, share: true }))) return { ok: false, command: 'parts pack', id, why: 'A paid release is immutable, including its description, price and licence. Give the change a new version.' };
483
+ const legalSame = JSON.stringify({ license: was.license, licenseTerms: was.licenseTerms, sale: was.sale }) === JSON.stringify({ license: part.license, licenseTerms: part.licenseTerms, sale: part.sale });
484
+ if (!legalSame) return { ok: false, command: 'parts pack', id, why: 'A packed version cannot change its price or licence terms. Give it a new version.' };
454
485
  if (!same) return { ok: false, command: 'parts pack', id, version: part.version, why: `parts/${id} ${part.version} is already packed with different files, and a shared version never changes (another studio may have copied it). Give the change a new version: raise "version" in parts/${id}/part.json, then share it again.` };
455
486
  // The same bytes: only the description may have moved (a summary, `share`), so the frozen copy takes it.
456
487
  writePart(out, part);
@@ -566,7 +597,7 @@ function liftPlan(root, game, paths) {
566
597
  */
567
598
  export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
568
599
  if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
569
- if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
600
+ if (!(typeof kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(kind))) return { ok: false, command: 'parts new', why: `"${kind}" is not a lowercase kind label` };
570
601
  if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
571
602
  const dir = partDir(root, id);
572
603
  if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
@@ -653,6 +684,19 @@ export function licenceIssues(items, { game = null } = {}) {
653
684
  const add = (level, parts, problem, fix = null) => out.push({ level, parts, problem, fix });
654
685
  const sa = new Map();
655
686
  for (const x of items.filter((y) => isObj(y.part))) {
687
+ if (x.vendored && x.part.sale) {
688
+ const s = x.part.sale; const p = x.purchase;
689
+ if (!p) add('conflict', [x.ref], 'No purchase evidence is recorded for this paid part', 'add it through part_add');
690
+ else {
691
+ if (p.version !== x.part.version) add('conflict', [x.ref], 'This game has no recorded grant for the installed paid release', 'call part_add for this game and version to check its update rights');
692
+ if (p.scope === 'game' && game && p.game !== game.id) add('conflict', [x.ref], `This licence covers game ${p.game}, not ${game.id}`, 'buy a licence for this game');
693
+ if (p.paidUntil && p.paidUntil <= Date.now() && s.onExpiry === 'terminate') add('conflict', [x.ref], 'The subscription licence expired', 'renew through the seller');
694
+ if (['refunded', 'lost'].includes(p.status) && s.onRefund === 'terminate') add('conflict', [x.ref], 'This purchase was revoked under its refund terms', 'settle the licence before publishing');
695
+ add('note', [x.ref], `${p.mode} purchase; ${p.scope} scope${p.scope === 'seat' ? `, ${p.quantity} seats` : ''}; last checked ${p.checkedAt ?? 'unknown'}. Offline evidence cannot prove current refund status.`);
696
+ }
697
+ if (game?.sells && !s.commercialUse) add('conflict', [x.ref], 'This paid licence excludes commercial use', 'obtain commercial rights before selling');
698
+ add('note', [x.ref], `Read ${x.part.licenseTerms}: source ${s.source ? 'included' : 'not included'}, transfer ${s.transferable ? 'permitted under the written terms' : 'requires a new licence'}. Publishing a game does not grant standalone part redistribution rights.`);
699
+ }
656
700
  const lic = licenseOfPart(x.part.license);
657
701
  if (!lic) { if (x.vendored) add('conflict', [x.ref], 'it names no licence, so nothing says a game may use it', 'ask its studio, or use another part'); continue; }
658
702
  if (lic.class === 'unrecognised') add('warn', [x.ref], `${lic.id} is not a licence the toolkit can reason about`, 'read it with the person before the game goes online');
@@ -677,7 +721,7 @@ export function creditLine(ref, entry) {
677
721
  url: entry.page ?? null,
678
722
  licence: lic?.id ?? null,
679
723
  licenceUrl: lic?.known ? `https://spdx.org/licenses/${lic.id}.html` : null,
680
- note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A game part from ${ref.split('/')[0]}`,
724
+ note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A studio part from ${ref.split('/')[0]}`,
681
725
  part: ref,
682
726
  };
683
727
  }
@@ -0,0 +1,47 @@
1
+ /** Private Node installation for a Desktop host with a bundled runtime but no system Node.
2
+ * Official archive + SHA-256, no shell, administrator rights or shell profile edits.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export const nodeHome = (home = homedir()) => join(home, '.cache', 'homie-studio', 'node');
11
+ export async function prepareNode({ home = homedir(), platform = process.platform, arch = process.arch, fetchFn = fetch, exec = spawnSync } = {}) {
12
+ const base = nodeHome(home);
13
+ const bin = join(base, platform === 'win32' ? 'node.exe' : 'bin/node');
14
+ if (existsSync(bin)) return { ok: true, bin, installed: false };
15
+ if (!['darwin', 'linux', 'win32'].includes(platform) || !['arm64', 'x64'].includes(arch)) {
16
+ return { ok: false, next: 'Ask the connected AI to install Node.js 22 or newer for this operating system.', open: 'https://nodejs.org/en/download' };
17
+ }
18
+ let scratch;
19
+ try {
20
+ const get = async (url) => {
21
+ const r = await fetchFn(url, { signal: AbortSignal.timeout(15_000) });
22
+ if (!r.ok) throw new Error(`Node download answered ${r.status}`);
23
+ return r;
24
+ };
25
+ const url = 'https://nodejs.org/dist/latest-v22.x';
26
+ const sums = await (await get(`${url}/SHASUMS256.txt`)).text();
27
+ const suffix = `${platform === 'win32' ? 'win' : platform}-${arch}.${platform === 'win32' ? 'zip' : 'tar.gz'}`;
28
+ const entry = sums.split('\n').map((s) => s.trim().split(/\s+/)).find(([hash, file]) => /^[a-f0-9]{64}$/.test(hash) && new RegExp(`^node-v22\\.\\d+\\.\\d+-${suffix.replaceAll('.', '\\.')}$`).test(file ?? ''));
29
+ if (!entry) throw new Error('No supported Node archive in the official checksum list');
30
+ const [hash, file] = entry;
31
+ const data = Buffer.from(await (await get(`https://nodejs.org/dist/${file.match(/^node-(v22\.\d+\.\d+)-/)[1]}/${file}`)).arrayBuffer());
32
+ if (createHash('sha256').update(data).digest('hex') !== hash) throw new Error('Node archive checksum did not match');
33
+ mkdirSync(join(home, '.cache', 'homie-studio'), { recursive: true });
34
+ scratch = mkdtempSync(join(home, '.cache', 'homie-studio', 'node-'));
35
+ const archive = join(scratch, file);
36
+ writeFileSync(archive, data);
37
+ const r = exec('tar', ['-xf', archive, '-C', scratch], { encoding: 'utf8', timeout: 10_000 });
38
+ if (r.status !== 0) throw new Error('Could not unpack Node with the system archive tool');
39
+ const unpacked = join(scratch, file.replace(/\.(tar\.gz|zip)$/, ''));
40
+ const check = exec(join(unpacked, platform === 'win32' ? 'node.exe' : 'bin/node'), ['--version'], { encoding: 'utf8', timeout: 3000 });
41
+ if (check.status !== 0 || !/^v22\./.test(check.stdout ?? '')) throw new Error('Downloaded Node did not start');
42
+ renameSync(unpacked, base);
43
+ return { ok: true, bin, installed: true };
44
+ } catch (error) {
45
+ return { ok: false, why: error.message, next: 'AI: retry setup_prepare; if the network still refuses, use the Node installer.', open: 'https://nodejs.org/en/download' };
46
+ } finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); }
47
+ }
@@ -0,0 +1,42 @@
1
+ /** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { sharedPartsOf, readPart, packedDir } from './parts.mjs';
6
+ import { canonicalJson } from '../worker/referrals.mjs';
7
+ import { paymentLinkURL } from '../worker/shop-links.mjs';
8
+ const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
9
+ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, till }) {
10
+ const file = join(root, 'parts', 'offers.json');
11
+ const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
12
+ const offers = sharedPartsOf(root).flatMap(({id, versions, part}) => versions.flatMap((version) => {
13
+ const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
14
+ return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
15
+ }));
16
+ const configured = offers.flatMap(o => {
17
+ const quantities = settings[o.resource]?.quantities ?? [1];
18
+ if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
19
+ return [...new Set(quantities)].map(quantity=>({...o,quantity}));
20
+ });
21
+ const links = (await list('/v1/payment_links')).filter((l) => l.metadata?.homie === 'purchase-links-v1' && l.metadata?.studio === slug && l.metadata?.origin === site);
22
+ const config = {v:1,mode,offers:[]}, keep = new Set();
23
+ for (const o of configured) {
24
+ const revision = hash({o,till});
25
+ let link = links.find((l)=>l.metadata?.revision === revision);
26
+ if (!link) {
27
+ const metadata = {homie:'purchase-links-v1',studio:slug,origin:site,revision};
28
+ const product = (await list('/v1/products')).find(p=>p.metadata?.homie === 'purchase-links-v1' && p.metadata?.studio === slug && p.metadata?.origin === site && p.metadata?.revision === revision) ?? await api('POST','/v1/products',{name:`${o.name} ${o.version}`, ...(o.sale.taxCode ? {tax_code:o.sale.taxCode}:{}),metadata},`purchase-product-${revision}`);
29
+ const price = (await list('/v1/prices')).find(p=>p.product === product.id && p.metadata?.revision === revision) ?? await api('POST','/v1/prices',{product:product.id,currency:o.sale.currency,unit_amount:o.sale.amount,...(o.sale.taxBehavior ? {tax_behavior:o.sale.taxBehavior}:{}),...(o.sale.billing !== 'one-time' ? {recurring:{interval:o.sale.billing}}:{}),metadata},`purchase-price-${revision}`);
30
+ link = await api('POST','/v1/payment_links',{line_items:[{price:price.id,quantity:o.quantity}],metadata,
31
+ ...(o.sale.billing === 'one-time' ? {payment_intent_data:{metadata}}:{subscription_data:{metadata}}),
32
+ ...(till === 'stripe-managed' ? {managed_payments:{enabled:true}} : {automatic_tax:{enabled:o.sale.automaticTax === true},...(o.sale.taxIdCollection?{tax_id_collection:{enabled:true}}:{}),...(o.sale.billing === 'one-time' && o.sale.invoiceCreation?{invoice_creation:{enabled:true}}:{}),...(o.sale.adaptivePricing === undefined?{}:{adaptive_pricing:{enabled:o.sale.adaptivePricing}})}),
33
+ ...(o.sale.billing === 'one-time' && o.sale.customerCreation?{customer_creation:'always'}:{}),allow_promotion_codes:o.sale.promotionCodes === true,
34
+ after_completion:{type:'redirect',redirect:{url:`${site}/purchases/thanks`}}},`purchase-link-${revision}`);
35
+ }
36
+ if (!paymentLinkURL(link.url)) throw new Error('Stripe returned an invalid Payment Link');
37
+ if (!link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:true});
38
+ keep.add(link.id);
39
+ config.offers.push({kind:o.kind,resource:o.resource,version:o.version,quantity:o.quantity,offer:canonicalJson(o.sale),id:link.id,url:link.url,revision});
40
+ }
41
+ return { config, retire:async()=>{ for (const l of links) if(l.active && !keep.has(l.id)) await api('POST',`/v1/payment_links/${l.id}`,{active:false}); } };
42
+ }
@@ -0,0 +1,114 @@
1
+ /** Payment authority and limits stay in the person's existing wallet client. */
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ const execute = promisify(execFile);
5
+ export async function payWithWallet(
6
+ url,
7
+ body,
8
+ { client = process.env.HOMIE_PARTS_WALLET, pending, run = execute } = {},
9
+ ) {
10
+ if (!client) return null;
11
+ if (!["link-cli", "purl", "tempo"].includes(client))
12
+ return { data: { detail: "Choose link-cli, purl or tempo as the wallet; hosted Checkout is available." } };
13
+ let args;
14
+ if (client === "link-cli") {
15
+ args = [
16
+ "mpp",
17
+ "pay",
18
+ url,
19
+ "-X",
20
+ "POST",
21
+ "-d",
22
+ JSON.stringify(body),
23
+ "--format",
24
+ "json",
25
+ ];
26
+ if (pending?.id)
27
+ args.push(
28
+ "--spend-request-id",
29
+ pending.id,
30
+ "--approved-challenge",
31
+ pending.challenge,
32
+ );
33
+ else
34
+ args.push(
35
+ "--amount",
36
+ String(body.amount),
37
+ "--context",
38
+ `Purchase the digital resource ${body.resource} version ${body.version} from ${new URL(url).host} under the exact price and licence terms approved in this conversation. The selling studio delivers the files and signed purchase proof.`,
39
+ );
40
+ } else {
41
+ const atomic = BigInt(body.amount) * 10000n;
42
+ const purlLimit = process.env.PURL_MAX_AMOUNT;
43
+ if (purlLimit && !/^\d+$/.test(purlLimit))
44
+ return { data: { detail: "Invalid wallet maximum amount; use hosted Checkout or correct the wallet setting." } };
45
+ const tempoLimit = process.env.TEMPO_MAX_SPEND;
46
+ if (
47
+ tempoLimit &&
48
+ (!Number.isFinite(Number(tempoLimit)) || Number(tempoLimit) < 0)
49
+ )
50
+ return { data: { detail: "Invalid wallet maximum spend; use hosted Checkout or correct the wallet setting." } };
51
+ // Use the wallet's native per-request ceiling in its documented units.
52
+ const ceiling =
53
+ client === "tempo"
54
+ ? [
55
+ "--max-spend",
56
+ String(
57
+ Math.min(
58
+ body.amount / 100,
59
+ tempoLimit ? Number(tempoLimit) : Infinity,
60
+ ),
61
+ ),
62
+ ]
63
+ : [
64
+ "--max-amount",
65
+ String(
66
+ purlLimit && BigInt(purlLimit) < atomic
67
+ ? BigInt(purlLimit)
68
+ : atomic,
69
+ ),
70
+ "--network",
71
+ "base,base-sepolia",
72
+ "--output-format",
73
+ "json",
74
+ ];
75
+ args = [
76
+ ...(client === "tempo" ? ["request"] : []),
77
+ ...ceiling,
78
+ "-X",
79
+ "POST",
80
+ "--json",
81
+ JSON.stringify(body),
82
+ url,
83
+ ];
84
+ }
85
+ let stdout;
86
+ try { ({ stdout } = await run(client, args, {
87
+ encoding: "utf8",
88
+ maxBuffer: 256 * 1024 * 1024,
89
+ timeout: 60000,
90
+ })); } catch (error) {
91
+ if (error.code === 'ENOENT') return { data: { detail: `The ${client} wallet is not installed; use hosted Checkout.` } };
92
+ // Wallets often return a structured refusal with a nonzero exit status.
93
+ // A process timeout or broken output is ambiguous: never start a second payment.
94
+ if (error.stdout?.trim()) stdout = error.stdout;
95
+ else if (/declined|insufficient funds|spend limit|not authenticated|not logged in|unsupported (network|method)|user rejected|user denied/i.test(error.stderr ?? ''))
96
+ return { data: { detail: String(error.stderr).trim(), paymentStatus: 'not_paid' } };
97
+ else return { uncertain: true, data: { detail: 'The wallet stopped without a payment result. Retry this purchase to reconcile it; do not pay again.' } };
98
+ }
99
+ let result;
100
+ try { result = JSON.parse(stdout); }
101
+ catch { return { uncertain: true, data: { detail: 'The wallet returned an unreadable payment result. Retry this purchase to reconcile it; do not pay again.' } }; }
102
+ if (result._next?.pay_argv) {
103
+ const next = result._next.pay_argv.args;
104
+ return {
105
+ pending: {
106
+ id: result.id,
107
+ challenge: next[next.indexOf("--approved-challenge") + 1],
108
+ },
109
+ approval: result.approval_url,
110
+ };
111
+ }
112
+ const value = result.body ?? result;
113
+ return { data: typeof value === "string" ? JSON.parse(value) : value };
114
+ }
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
2
+ import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
2
3
  /**
3
4
  * `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
4
5
  * monorepo: a visible folder the person opens in Claude or Codex.
@@ -65,7 +66,7 @@ export function slugify(name) {
65
66
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
66
67
  * the binding names and keep it linked.
67
68
  */
68
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
69
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
69
70
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
70
71
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
71
72
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -79,19 +80,20 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
79
80
  // subdomain) reaches it instead of failing with 1042.
80
81
  // `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
81
82
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
82
- compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup'],
83
+ compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup', ...(paidParts ? ['nodejs_compat'] : [])],
84
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
83
85
  workers_dev: true,
84
86
  preview_urls: true,
85
87
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
86
88
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
87
89
  // (lib/routes.mjs). A studio without routes gets no key at all.
88
90
  ...(routes?.length ? { routes } : {}),
89
- ...(triggers ? { triggers } : {}),
91
+ ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
90
92
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
91
93
  durable_objects: { bindings: rooms },
92
94
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
93
95
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
94
- ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
96
+ ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
95
97
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
96
98
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
97
99
  ...(ai ? { ai: { binding: 'AI' } } : {}),
@@ -132,6 +134,16 @@ directory lists its games; homie.rocks does not host them.
132
134
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
133
135
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
134
136
 
137
+ The person describes an outcome; you carry it through setup, building and checks without a tutorial
138
+ or a planning interview. Choose engineering and design defaults from their words and record them
139
+ in the codex. Use "My Studio" when unnamed, phones and computers, local sound and CC0 art.
140
+ They can change anything by asking. A business or charity may want an app, not a game.
141
+ Install missing prerequisites yourself; never ask the person to run a command or handle a key,
142
+ account id, zone id or setting. Honour an existing request to go live or list, subject to host holds.
143
+ For a requested custom hostname, add its custom_domain route in Wrangler along with cloudflare.domain;
144
+ Cloudflare creates DNS and TLS during deploy with the existing sign-in.
145
+
146
+
135
147
  ## Apps: one screen, roles and parts
136
148
 
137
149
  An app lives in \`apps/<id>/app.json\` and \`src/\`. Make one with \`homie-studio app new <id>\` (or \`app_make\`). Use the Homie plugin's \`app\` skill for businesses, venues, causes and customer apps. No game demo, rounds, scores, bots or mandatory fun: build what the person needs with the fewest human steps.
@@ -737,6 +749,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
737
749
  [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
738
750
  [`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
739
751
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
752
+ [`site/migrations/${PURCHASE_MIGRATION_FILE}`]: PURCHASE_MIGRATION,
753
+ [`site/migrations/${PURCHASE_STATE_FILE}`]: PURCHASE_STATE,
740
754
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
741
755
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
742
756
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
@@ -896,8 +910,24 @@ function ensureAppsMigration(root) {
896
910
  mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
897
911
  return `site/migrations/${APPS_MIGRATION_FILE}`;
898
912
  }
913
+ export function ensurePartsMigration(root) {
914
+ const file = join(root, 'site', 'migrations', PURCHASE_MIGRATION_FILE);
915
+ if (existsSync(file)) return null;
916
+ mkdirSync(dirname(file), { recursive: true });
917
+ writeFileSync(file, PURCHASE_MIGRATION);
918
+ return `site/migrations/${PURCHASE_MIGRATION_FILE}`;
919
+ }
920
+
921
+ function ensurePartsStateMigration(root) {
922
+ const file = join(root, 'site', 'migrations', PURCHASE_STATE_FILE);
923
+ if (existsSync(file)) return null;
924
+ mkdirSync(dirname(file), { recursive: true });
925
+ writeFileSync(file, PURCHASE_STATE);
926
+ return `site/migrations/${PURCHASE_STATE_FILE}`;
927
+ }
928
+
899
929
  export function ensureMigrations(root) {
900
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
930
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
901
931
  }
902
932
 
903
933
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shoot.mjs CHANGED
@@ -115,7 +115,7 @@ export const VIRTUAL_CLOCK = `(() => {
115
115
  window.__homieClock = {
116
116
  get frozen() { return frozen; },
117
117
  now: perfNow,
118
- freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; stepAnims(0); return vPerf; },
118
+ freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; runTimers(vPerf); stepAnims(0); return vPerf; },
119
119
  step(ms) {
120
120
  if (!frozen) this.freeze();
121
121
  const d = Math.max(0, Number(ms) || 0);