@homie-rocks/studio 0.31.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +10 -6
- package/bin/homie-studio.mjs +71 -6
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +10 -9
- package/lib/mcp-tools.mjs +13 -9
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/template-history.json +40 -0
- package/netplay/NETPLAY.md +138 -10
- package/netplay/netplay.ts +220 -29
- package/package.json +1 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,41 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.31.1] - 2026-10-06
|
|
21
|
+
|
|
22
|
+
**Plugin 0.32.1** · [#53](https://github.com/homie-rocks/homie/pull/53)
|
|
23
|
+
|
|
24
|
+
Fixes from two creators' notes on 0.31.0: Homie's holds run in Grok, `publish --before` publishes nothing, a page that loses its room is never stuck, a missing setting is never a silent zero, and a part's preview fits a phone.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `@homie-rocks/camera/heading.js` (camera 0.2.1): `headingFrom({ zero: '+x', toward: '+z' })` converts your game's own heading into the camera's and back, and `headingOfVector(dx, dz)` does it from a forward vector. The package now says plainly which way its heading points (yaw 0 faces +Z, a positive yaw turns toward +X, in radians), with a picture in the README and in the types. In development the follow camera warns once when the subject keeps travelling one way while its heading says another, and names the adapter; mark reversing frames with `reversing: true`, or turn it off with `followState({ headingCheck: false })`.
|
|
29
|
+
- `net.prefs.number`, `net.prefs.boolean` and `net.prefs.string` read a setting with your fallback for a missing key, a null, a wrong type or a read before the prefs arrived, and say so once in the console. `net.prefs.set` refuses a `NaN` or anything else that would be kept as `null`.
|
|
30
|
+
- `createRoom({ fallback })`, seat or solo: `'solo'` plays a private round with bots until the room has a body for the player, `'spectate'` watches, and `room.standing` says where the player stands. NETPLAY.md section 28 is the recipe, and the late-join checklist has the slow-boot, mid-round join.
|
|
31
|
+
- A shared part can say the shape of its preview: `"aspect": "4:3"` and, for phones, `"phoneAspect": "3:4"` in `preview` (whole numbers 1 to 32, no flatter than 3:1, no taller than 1:2; 16:9 if you say nothing). Every part preview has an "Open the preview" link that shows it in a tab of its own.
|
|
32
|
+
- On a custom domain, a deploy and its plan warn before anything goes live when another site's catch-all or wildcard route covers your studio's address: leave that route alone, and add the one line shown (or `deploy --own-route`). When the routes cannot be read, it says so rather than saying all is well.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Homie's holds run in Grok once you trust the plugin.** 0.30.2 said Grok runs no plugin's hooks. That was wrong: Grok takes a plugin's hooks from its main manifest, ours named none there, and Grok was loading the Claude Code file instead and finding nothing. A secret is also now really taken out of what the AI reads in Grok (it was only being flagged beside the output). `setup status --client grok` says whether the holds ran and how to turn them on.
|
|
37
|
+
- **`publish --before` publishes nothing.** It asks the directory whether you are listed and how many publishes are left today. In 0.31.0 the command line ignored the flag and published.
|
|
38
|
+
- Commands that change something outside your computer (deploy, publish, storage, the office and others) stop at a flag they do not know, before anything is sent. `deploy --dry-run` was one such flag: it used to deploy.
|
|
39
|
+
- A page that loses its room no longer shows "Reconnecting…" for ever: after 20 seconds (`reconnectMaxMs`) it plays on its own with bots, says so, and rejoins when the room answers. The line over the game says what is true: "Reconnecting…" only to a page that was connected, and a full room is said in words with a way to another room.
|
|
40
|
+
- A player who is seated while every body is taken gets one as soon as a body frees up or the next round starts, instead of watching for the rest of the visit.
|
|
41
|
+
- A browser that freezes for a few seconds while a heavy game boots no longer drops its connection when it wakes.
|
|
42
|
+
- A part's preview is no longer cut off on a phone: a narrow frame is at least 320 px tall instead of about 197.
|
|
43
|
+
- The 3D starters say which of their angles a camera can take as it stands and which needs converting.
|
|
44
|
+
|
|
45
|
+
### Upgrade notes
|
|
46
|
+
|
|
47
|
+
- Grok: install or reinstall the plugin with trust, `grok plugin install homie-rocks/homie#plugins/homie --trust`. From then on a deploy, a Cloudflare change and a paid call are held for your "proceed", as in Codex.
|
|
48
|
+
- A studio that pins 0.31.0 still publishes on `publish --before` from its own command line until it upgrades.
|
|
49
|
+
- If your game reads settings with `net.prefs.get`, move numbers to `net.prefs.number(key, fallback)`: a `null` kept by an older build is now read as nothing kept.
|
|
50
|
+
- `@homie-rocks/camera` is 0.2.1.
|
|
51
|
+
|
|
20
52
|
## [0.31.0] - 2026-10-06
|
|
21
53
|
|
|
22
|
-
**Plugin 0.32.0** · [#50](https://github.com/homie-rocks/homie/pull/50)
|
|
54
|
+
**Plugin 0.32.0** · [#50](https://github.com/homie-rocks/homie/pull/50) · [release-2026-10-06-studio-0.31.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-06-studio-0.31.0)
|
|
23
55
|
|
|
24
56
|
Fixes from two creators' logs of building real studios: games now build on each other by sharing parts (remix is retired), a slow 3D game keeps its host, local play stays local, and synthesized bass plays at its written pitch.
|
|
25
57
|
|
package/README.md
CHANGED
|
@@ -496,8 +496,8 @@ this toolkit alone (`new`, `deploy`, `publish`), and the connector row says so.
|
|
|
496
496
|
an address or a command; the command itself is never read out.
|
|
497
497
|
- **Homie's holds**: whether the plugin's hooks ran in this app in the last ten minutes, from the dated mark
|
|
498
498
|
they leave in `.cache/homie-studio/holds/` of the home folder. Off means nothing is held: Codex runs no plugin's hooks
|
|
499
|
-
until the person trusts them in `/hooks`, and
|
|
500
|
-
|
|
499
|
+
until the person trusts them in `/hooks`, and Grok Build runs them once the plugin is trusted
|
|
500
|
+
(`grok plugin install … --trust`); the row says which.
|
|
501
501
|
|
|
502
502
|
`homie-studio demo` answers without a network too (0.30.2): where it cannot reach the arcade it names the arcade's
|
|
503
503
|
standing first pick, with `reached: false`.
|
|
@@ -659,10 +659,14 @@ as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `
|
|
|
659
659
|
|
|
660
660
|
**Routes.** `deploy` writes `wrangler.jsonc` again each time, and keeps the routes that are the studio's own exactly
|
|
661
661
|
as they were written: a custom domain (`{ "pattern": "play.example.com", "custom_domain": true }`) and an exact-host
|
|
662
|
-
route (`{ "pattern": "play.example.com/*", "zone_name": "example.com" }`). A zone's other routes are never
|
|
663
|
-
changed or removed.
|
|
664
|
-
|
|
665
|
-
|
|
662
|
+
route (`{ "pattern": "play.example.com/*", "zone_name": "example.com" }`). A zone's other routes are never
|
|
663
|
+
changed or removed. On a custom domain, `deploy` and `deploy --plan` read the domain's Worker routes first (two GETs
|
|
664
|
+
with the sign-in Wrangler already has) and warn, before anything is deployed, when another Worker's catch-all (`*/*`)
|
|
665
|
+
or wildcard (`*example.com/*`) covers the studio's hostname: that route answers the hostname before the studio does,
|
|
666
|
+
it belongs to another site on the domain, and it must not be edited or removed. The warning gives the one safe fix,
|
|
667
|
+
the studio's own exact-host route, as the exact line; `deploy --own-route` adds that line and nothing else, only when
|
|
668
|
+
the read showed it is needed. Routes that could not be read are said as unmeasured, never as fine; after the deploy,
|
|
669
|
+
a domain that answers as something else still gets the route to add. A wildcard or catch-all route (`*/*`) in the studio's own config would hand every
|
|
666
670
|
hostname of the zone to the studio's Worker, so `deploy` refuses it before it asks Cloudflare anything; a studio that
|
|
667
671
|
really owns the whole zone says so in studio.json (`"cloudflare": { "allowWildcardRoutes": true }`).
|
|
668
672
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -87,9 +87,17 @@
|
|
|
87
87
|
* One deploy of a studio at a time (a lock in .studio/); it says which games changed
|
|
88
88
|
* with each one's content hash, keeps the studio's own custom-domain and exact-host
|
|
89
89
|
* routes in wrangler.jsonc, and never deploys a wildcard or catch-all route.
|
|
90
|
+
* On a custom domain, the plan and the deploy read the domain's Worker routes and
|
|
91
|
+
* warn when another site's catch-all or wildcard covers the studio's hostname
|
|
92
|
+
* (never edit or remove that route). --own-route adds the studio's own
|
|
93
|
+
* exact-host route, the one safe fix, when that read shows it is needed.
|
|
90
94
|
* In Cloudflare's Workers Builds (WORKERS_CI=1, or --ci) it only applies the D1
|
|
91
95
|
* migrations and deploys: the Worker and database are the Deploy button's.
|
|
92
|
-
* homie-studio publish
|
|
96
|
+
* homie-studio publish [--before] (the directory's beta has a daily cap: it says how many publishes are left)
|
|
97
|
+
* --before publishes NOTHING: it asks the directory (a read) whether the site
|
|
98
|
+
* is listed and how many publishes are left today, and says the parts' licences.
|
|
99
|
+
* A command that changes something outside this computer (deploy, publish,
|
|
100
|
+
* storage add, the office, …) stops at a flag it does not know: nothing is sent.
|
|
93
101
|
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
94
102
|
* homie-studio media list (every song and video page, where each file is served from, what moves to R2)
|
|
95
103
|
* homie-studio media move [<file>...] [--dry-run] [--verify]
|
|
@@ -287,11 +295,11 @@ import { basename, join, relative, resolve } from 'node:path';
|
|
|
287
295
|
import { build } from '../lib/build.mjs';
|
|
288
296
|
import { PREVIEW_PORT, previewServer } from '../lib/preview.mjs';
|
|
289
297
|
import { check } from '../lib/check.mjs';
|
|
290
|
-
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
298
|
+
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin, zonePlan } from '../lib/cloudflare.mjs';
|
|
291
299
|
import { setupAttach } from '../lib/setup.mjs';
|
|
292
300
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
293
301
|
import { deployWords } from '../lib/deploy-state.mjs';
|
|
294
|
-
import { publish } from '../lib/directory.mjs';
|
|
302
|
+
import { publish, publishBefore } from '../lib/directory.mjs';
|
|
295
303
|
import { look } from '../lib/look.mjs';
|
|
296
304
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
297
305
|
import { portCheck } from '../lib/port-check.mjs';
|
|
@@ -333,7 +341,7 @@ import { trailerCommand, trailerLines } from '../lib/trailer.mjs';
|
|
|
333
341
|
|
|
334
342
|
const argv = process.argv.slice(2);
|
|
335
343
|
const flags = new Map();
|
|
336
|
-
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite'];
|
|
344
|
+
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before'];
|
|
337
345
|
const positional = [];
|
|
338
346
|
for (let i = 0; i < argv.length; i++) {
|
|
339
347
|
const a = argv[i];
|
|
@@ -459,11 +467,15 @@ function print(result) {
|
|
|
459
467
|
...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
|
|
460
468
|
`Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
|
|
461
469
|
`The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never,
|
|
470
|
+
...(result.zone?.warning ? ['', `Warning: ${result.zone.warning}`] : result.zone?.why ? ['', result.zone.why] : []),
|
|
462
471
|
...(result.parts && partsPlanLines(result.parts).length ? ['', ...partsPlanLines(result.parts)] : []));
|
|
463
472
|
break;
|
|
464
473
|
case 'storage add':
|
|
465
474
|
lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
466
475
|
break;
|
|
476
|
+
case 'publish before':
|
|
477
|
+
lines.push(...result.lines);
|
|
478
|
+
break;
|
|
467
479
|
case 'publish':
|
|
468
480
|
lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`), ...(result.publishes?.line ? [result.publishes.line] : []),
|
|
469
481
|
// The parts in what was listed, in the deploy plan's own lines (lib/parts-build.mjs).
|
|
@@ -743,6 +755,52 @@ function print(result) {
|
|
|
743
755
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
744
756
|
}
|
|
745
757
|
|
|
758
|
+
/*
|
|
759
|
+
* A COMMAND THAT CHANGES SOMETHING OUTSIDE THIS COMPUTER NEVER RUNS WITH A FLAG IT DOES NOT KNOW. The parser above
|
|
760
|
+
* keeps every `--word`, and a command reads the ones it knows: `publish --before` (a flag the MCP tool had and this
|
|
761
|
+
* command did not) was read as plain `publish`, and listed the studio while its caller believed it was a preflight.
|
|
762
|
+
* So each command below names every flag it takes, and anything else stops it before anything is sent: a typo, a
|
|
763
|
+
* flag of another command, or one from a newer toolkit's notes. The key is the command and its sub-command, else
|
|
764
|
+
* the command alone. Commands that only read, or only change files in this folder, are not in this table.
|
|
765
|
+
*/
|
|
766
|
+
const OUTWARD_FLAGS = {
|
|
767
|
+
publish: ['homie', 'site', 'before'],
|
|
768
|
+
deploy: ['plan', 'ci', 'homie', 'own-route'],
|
|
769
|
+
'storage add': [],
|
|
770
|
+
'media put': ['as'],
|
|
771
|
+
'media move': ['dry-run', 'plan', 'verify'],
|
|
772
|
+
'setup attach': ['homie', 'client'],
|
|
773
|
+
handoff: ['homie', 'client'],
|
|
774
|
+
'players owner': ['url', 'revoke'],
|
|
775
|
+
'stats key': ['url', 'hours'], 'stats link': ['url'], 'stats revoke': ['url'], 'stats share': [],
|
|
776
|
+
'office link': ['url', 'to'], 'office key': ['url', 'hours'], 'office announce': ['url', 'game', 'room', 'seconds'],
|
|
777
|
+
'office invite': ['url', 'label', 'uses', 'count', 'days', 'server'], 'office launch': ['url', 'max'],
|
|
778
|
+
'office kick': ['url', 'minutes'], 'office mute': ['url', 'minutes', 'off'], 'office close': ['url', 'minutes', 'reopen'], 'office revoke': ['url'],
|
|
779
|
+
'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
|
|
780
|
+
'chat remove': ['url', 'all'], 'chat budget': ['url'],
|
|
781
|
+
'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
|
|
782
|
+
'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send'],
|
|
783
|
+
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
784
|
+
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
785
|
+
};
|
|
786
|
+
/** Every command takes these: how it answers, never what it does. */
|
|
787
|
+
const ANY_COMMAND = ['json', 'help', 'version'];
|
|
788
|
+
|
|
789
|
+
/** The refusal for a flag an outward-writing command does not take, or null. Nothing has run when this is asked. */
|
|
790
|
+
function unknownFlags(words, given) {
|
|
791
|
+
const [cmd, sub] = words;
|
|
792
|
+
const key = sub !== undefined && OUTWARD_FLAGS[`${cmd} ${sub}`] ? `${cmd} ${sub}` : OUTWARD_FLAGS[cmd] ? cmd : null;
|
|
793
|
+
if (!key) return null;
|
|
794
|
+
const takes = OUTWARD_FLAGS[key];
|
|
795
|
+
const strange = [...given.keys()].filter((k) => !takes.includes(k) && !ANY_COMMAND.includes(k));
|
|
796
|
+
if (!strange.length) return null;
|
|
797
|
+
const named = strange.map((k) => `--${k}`).join(', ');
|
|
798
|
+
return {
|
|
799
|
+
ok: false, command: key, needs: 'flag', flags: strange, takes: takes.map((k) => `--${k}`),
|
|
800
|
+
why: `homie-studio ${key} does not take ${named}, so it did not run: nothing was sent or changed. It changes something outside this computer, and a flag it does not know is never guessed at. It takes: ${takes.length ? takes.map((k) => `--${k}`).join(', ') : 'no flags of its own'} (and --json).`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
746
804
|
async function main() {
|
|
747
805
|
const [cmd, sub] = positional;
|
|
748
806
|
if (!cmd || cmd === 'help' || flags.has('help')) {
|
|
@@ -751,6 +809,8 @@ async function main() {
|
|
|
751
809
|
return { ok: true, command: 'help' };
|
|
752
810
|
}
|
|
753
811
|
if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
|
|
812
|
+
const strange = unknownFlags(positional, flags);
|
|
813
|
+
if (strange) return strange;
|
|
754
814
|
if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
|
|
755
815
|
if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
|
|
756
816
|
// The floor's built-in words need no studio: anyone can read what a studio's chat always holds.
|
|
@@ -870,16 +930,21 @@ async function main() {
|
|
|
870
930
|
const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
|
|
871
931
|
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
872
932
|
}
|
|
873
|
-
if (cmd === 'deploy' && flags.has('plan'))
|
|
933
|
+
if (cmd === 'deploy' && flags.has('plan')) {
|
|
934
|
+
// On a custom domain the plan also reads the domain's Worker routes (lib/routes.mjs): a read, nothing changes.
|
|
935
|
+
const zone = await zonePlan(root);
|
|
936
|
+
return { ...deployPlan(root), parts: partsPublishReport(root), ...(zone ? { zone } : {}) };
|
|
937
|
+
}
|
|
874
938
|
// Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
|
|
875
939
|
if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
|
|
876
|
-
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
|
|
940
|
+
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie'), ownRoute: flags.has('own-route') }), 'deploy');
|
|
877
941
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
878
942
|
if (cmd === 'storage') {
|
|
879
943
|
const cf = readStudio(root).cloudflare ?? {};
|
|
880
944
|
const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
|
|
881
945
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
882
946
|
}
|
|
947
|
+
if (cmd === 'publish' && flags.has('before')) return publishBefore(root, { homie: flags.get('homie'), site: flags.get('site'), log: () => {} });
|
|
883
948
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site'), log });
|
|
884
949
|
if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
|
|
885
950
|
if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -19,21 +19,28 @@
|
|
|
19
19
|
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
20
20
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
21
21
|
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
22
|
-
* before anything happens and
|
|
22
|
+
* before anything happens and changes nothing.
|
|
23
|
+
*
|
|
24
|
+
* A studio on a CUSTOM DOMAIN shares that domain with whatever else its owner
|
|
25
|
+
* runs there. The plan and the deploy read the domain's Worker routes (a read,
|
|
26
|
+
* with Wrangler's own sign-in) and warn when another site's catch-all or
|
|
27
|
+
* wildcard covers the studio's hostname: that route is never the studio's to
|
|
28
|
+
* edit or remove (lib/routes.mjs, zoneCheck below).
|
|
23
29
|
*/
|
|
24
30
|
import { spawn, spawnSync } from 'node:child_process';
|
|
25
31
|
import { createHash } from 'node:crypto';
|
|
26
32
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
33
|
import { join } from 'node:path';
|
|
34
|
+
import { cloudflareAuth } from './brain-probe.mjs';
|
|
28
35
|
import { build } from './build.mjs';
|
|
29
36
|
import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
|
|
30
37
|
import { listedHere, publish } from './directory.mjs';
|
|
31
38
|
import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
|
|
32
39
|
import { whyFailed } from './net.mjs';
|
|
33
40
|
import { repoOf } from './repo.mjs';
|
|
34
|
-
import { keptRoutes, readConfig, shadowedDomain, wideRouteRefusal } from './routes.mjs';
|
|
41
|
+
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
35
42
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
36
|
-
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
43
|
+
import { LOCAL_STATE, configPath, domainOrigin, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
37
44
|
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
38
45
|
|
|
39
46
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -106,6 +113,54 @@ export function explainCloudflare(out, accountId = null) {
|
|
|
106
113
|
return null;
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
/** The studio's custom hostname ("play.example.com"), or null for a studio on its workers.dev address. */
|
|
117
|
+
export function customHost(studio) {
|
|
118
|
+
const cf = studio?.cloudflare ?? {};
|
|
119
|
+
const origin = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
|
|
120
|
+
return origin ? new URL(origin).hostname : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* For a studio on a custom domain: what the domain's other Worker routes mean for it (lib/routes.mjs zoneFinding),
|
|
125
|
+
* read from Cloudflare with the sign-in Wrangler already has (lib/brain-probe.mjs cloudflareAuth: a token in the
|
|
126
|
+
* environment, else `wrangler auth token`). Null for a studio with no custom domain, which is asked nothing. It
|
|
127
|
+
* only reads; a list that cannot be read comes back as state 'unmeasured' with the reason. `config` stands in for
|
|
128
|
+
* wrangler.jsonc (a deploy that is about to add a route), `fetchFn` for the network in tests.
|
|
129
|
+
*/
|
|
130
|
+
export async function zoneCheck(root, { studio = readStudio(root), config = readConfig(root), accountId = null, fetchFn = null } = {}) {
|
|
131
|
+
const hostname = customHost(studio);
|
|
132
|
+
if (!hostname) return null;
|
|
133
|
+
const worker = studio.cloudflare?.worker ?? null;
|
|
134
|
+
// The toolkit's own tests (HOMIE_STUDIO_WARM=0) with no stand-in network ask nobody, as readLiveSite does.
|
|
135
|
+
if (!fetchFn && process.env.HOMIE_STUDIO_WARM === '0') return zoneFinding({ hostname, worker, config, zone: { read: false, why: 'not asked' } });
|
|
136
|
+
const bin = wranglerBin(root);
|
|
137
|
+
let headers = null;
|
|
138
|
+
try {
|
|
139
|
+
const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
|
|
140
|
+
headers = await cloudflareAuth({
|
|
141
|
+
env: { ...process.env, ...projectsCloudflareEnv(root) }, bin, cwd: root,
|
|
142
|
+
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
|
|
143
|
+
});
|
|
144
|
+
} catch { headers = null; }
|
|
145
|
+
const zone = await readZoneRoutes({ hostname, config, headers, ...(fetchFn ? { fetchFn } : {}) });
|
|
146
|
+
return zoneFinding({ hostname, worker, config, zone });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A zone finding as a deploy's result and its plan carry it: the state, the routes that are not the studio's, the one line. */
|
|
150
|
+
function zoneView(z) {
|
|
151
|
+
return {
|
|
152
|
+
state: z.state, host: z.host, domain: z.zone, foreign: z.foreign.map((r) => ({ pattern: r.pattern, worker: r.script, kind: r.kind })),
|
|
153
|
+
...(z.state === 'foreign' || z.state === 'unmeasured' ? { route: z.route, line: z.line } : {}),
|
|
154
|
+
...(z.warning ? { warning: z.warning } : {}), ...(z.why ? { why: z.why } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `deploy --plan` for a studio on a custom domain: the plan, and what the domain's other routes mean for it. */
|
|
159
|
+
export async function zonePlan(root, opts = {}) {
|
|
160
|
+
const z = await zoneCheck(root, opts);
|
|
161
|
+
return z ? zoneView(z) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
109
164
|
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
110
165
|
export function deployPlan(root) {
|
|
111
166
|
const studio = readStudio(root);
|
|
@@ -150,7 +205,7 @@ export async function deploy(root, opts = {}) {
|
|
|
150
205
|
try { return await deployLocked(root, opts); } finally { process.off('exit', onExit); lock.release(); }
|
|
151
206
|
}
|
|
152
207
|
|
|
153
|
-
async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}) {
|
|
208
|
+
async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRoute = false } = {}) {
|
|
154
209
|
const studio = readStudio(root);
|
|
155
210
|
const cf = { r2: null, created: [], ...studio.cloudflare };
|
|
156
211
|
const created = new Set(cf.created ?? []);
|
|
@@ -159,7 +214,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}
|
|
|
159
214
|
// studio's own routes exactly as its owner wrote them.
|
|
160
215
|
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
161
216
|
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
162
|
-
|
|
217
|
+
let routes = keptRoutes(root, studio);
|
|
163
218
|
const who = whoami(root);
|
|
164
219
|
if (!who) {
|
|
165
220
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -186,6 +241,25 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}
|
|
|
186
241
|
};
|
|
187
242
|
const steps = [];
|
|
188
243
|
const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
|
|
244
|
+
const notes = [];
|
|
245
|
+
|
|
246
|
+
// A studio on a custom domain: is another site's catch-all or wildcard route on the same domain covering its
|
|
247
|
+
// hostname? Read (never written) and said before Wrangler changes anything: the repair that suggests itself,
|
|
248
|
+
// editing or removing that route, takes the other site down (lib/routes.mjs). `--own-route` adds the one line
|
|
249
|
+
// that is the studio's to add, and only when the domain's routes were read and show it is needed.
|
|
250
|
+
let zone = await zoneCheck(root, { studio, accountId, fetchFn });
|
|
251
|
+
if (zone) {
|
|
252
|
+
if (ownRoute && zone.state === 'foreign') {
|
|
253
|
+
routes = [...(routes ?? []), zone.route];
|
|
254
|
+
step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
|
|
255
|
+
const config = readConfig(root) ?? {};
|
|
256
|
+
zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
|
|
257
|
+
} else if (ownRoute) {
|
|
258
|
+
step(`--own-route added nothing: ${zone.state === 'unmeasured' ? 'the domain\'s routes could not be read, so the domain\'s name in Cloudflare is not known for sure' : zone.state === 'taken' ? `${zone.host} is routed to another Worker, which only the person can change` : zone.state === 'own-route' ? 'the studio\'s own exact-host route is already there' : `no other route covers ${zone.host}`}.`);
|
|
259
|
+
}
|
|
260
|
+
if (zone.warning) { notes.push(zone.warning); step(`warning: ${zone.warning}`, { needs: 'cloudflare-routes', ...(zone.state === 'foreign' ? { route: zone.route } : {}) }); }
|
|
261
|
+
else if (zone.why) step(zone.why);
|
|
262
|
+
} else if (ownRoute) step('--own-route added nothing: this studio has no custom domain (studio.json cloudflare.domain).');
|
|
189
263
|
|
|
190
264
|
// The first deploy says what it is about to create, and what it costs, before it creates anything: the person
|
|
191
265
|
// reads it in the transcript whether or not the AI repeated it.
|
|
@@ -290,7 +364,6 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}
|
|
|
290
364
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
291
365
|
recordDeploy(root, builtNow);
|
|
292
366
|
let claim = null;
|
|
293
|
-
const notes = [];
|
|
294
367
|
if (url) {
|
|
295
368
|
const read = await readLiveSite(url, fetchFn ? { fetchFn } : {});
|
|
296
369
|
claim = read.claim;
|
|
@@ -340,6 +413,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null } = {}
|
|
|
340
413
|
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
|
|
341
414
|
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
342
415
|
...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
|
|
416
|
+
...(zone ? { zone: zoneView(zone) } : {}),
|
|
343
417
|
...(notes.length ? { notes } : {}),
|
|
344
418
|
...(reread ? { reread } : {}),
|
|
345
419
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
package/lib/directory.mjs
CHANGED
|
@@ -16,10 +16,11 @@ import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
|
|
|
16
16
|
* `publishesLeft`, or those inside `quota` / `limits` / `rateLimit`), or the standard rate-limit headers
|
|
17
17
|
* (`ratelimit-remaining`, `x-ratelimit-remaining`, `retry-after`). What it finds is said after every publish.
|
|
18
18
|
*
|
|
19
|
-
* Before a publish
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Before a publish, `publishBefore` asks the directory itself, with a read (GET /api/studio/publish?site=<origin>
|
|
20
|
+
* answers { ok, site, listed, limit, remaining, resetsAt } and changes nothing). When the directory cannot be
|
|
21
|
+
* reached, or is one that has no such read, it says so plainly and falls back to what THIS COMPUTER knows: how many
|
|
22
|
+
* it has sent today (UTC, the day the cap counts in), and what the directory said was left after the last one. That
|
|
23
|
+
* is a floor, never a promise: a publish from another computer or from the Homie connector is not counted here.
|
|
23
24
|
*/
|
|
24
25
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : null);
|
|
25
26
|
const first = (...values) => { for (const v of values) { const n = num(v); if (n !== null) return n; } return null; };
|
|
@@ -82,16 +83,67 @@ export function partsForListing(root) {
|
|
|
82
83
|
return { ...report, games, ok: games.every((g) => g.ok) && !report.why };
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
88
|
-
const url = site || siteUrl(root, studio);
|
|
89
|
-
// Licences first: a public game may ship only assets with a licence record that allows it.
|
|
86
|
+
/** The asset licence problems that stop a listing: a public game may ship only assets with a licence record that allows it. */
|
|
87
|
+
function licenceRefusals(root) {
|
|
90
88
|
const refused = [];
|
|
91
89
|
for (const g of listGames(root)) {
|
|
92
90
|
if (g.launch === 'private' || g.launch === 'invite') continue;
|
|
93
91
|
for (const p of licenceProblems(root, g.id, readManifest(root, g.id))) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
|
|
94
92
|
}
|
|
93
|
+
return refused;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `homie-studio publish --before` and the studio_publish tool's `before: true`, one code path: what a publish would
|
|
98
|
+
* meet, and NOTHING is published. It sends one GET (the directory's own read-only count for this site), never a
|
|
99
|
+
* POST, and writes no file, not even this computer's count. { ok: true, published: false, listed, publishes: {
|
|
100
|
+
* remaining, limit, resetsAt, sentToday, from: 'directory' | 'this computer', line }, parts?, refused? }, and
|
|
101
|
+
* `unreached: { why }` when the directory could not be asked. `ok` says the preflight ran, never that a publish
|
|
102
|
+
* would go through.
|
|
103
|
+
*/
|
|
104
|
+
export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
105
|
+
const studio = readStudio(root);
|
|
106
|
+
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
107
|
+
const url = site || siteUrl(root, studio);
|
|
108
|
+
const refused = licenceRefusals(root);
|
|
109
|
+
const parts = partsForListing(root);
|
|
110
|
+
const local = publishesSoFar(root);
|
|
111
|
+
const mine = `this computer has sent ${local.sent} publish${local.sent === 1 ? '' : 'es'} today (UTC)${local.remaining !== null ? `, and the directory said ${local.remaining === 0 ? 'none were' : `${local.remaining} ${local.remaining === 1 ? 'was' : 'were'}`} left after the last one${local.limit !== null ? ` (of ${local.limit} a day)` : ''}` : ''}; a publish from another computer or from the Homie connector is not counted here`;
|
|
112
|
+
let origin = null;
|
|
113
|
+
try { origin = url ? new URL(url).origin : null; } catch { origin = null; }
|
|
114
|
+
let asked = null;
|
|
115
|
+
if (origin) asked = await request(`${directory}/api/studio/publish?site=${encodeURIComponent(origin)}`, { method: 'GET', headers: { accept: 'application/json' } }, { timeout: 15_000, ...(fetchFn ? { fetchFn } : {}) });
|
|
116
|
+
const q = asked?.ok ? quotaOf(asked.body, asked.headers) : null;
|
|
117
|
+
const counted = Boolean(q && q.remaining !== null);
|
|
118
|
+
const listed = asked?.ok && typeof asked.body?.listed === 'boolean' ? asked.body.listed : null;
|
|
119
|
+
const why = !origin ? null : asked.ok ? 'it answered without a count' : String(asked.body?.message ?? asked.why ?? 'no answer').split('\n')[0];
|
|
120
|
+
const line = !origin
|
|
121
|
+
? 'Not published. This studio has no live site yet, so there is nothing to list: put it online first (npm run deploy).'
|
|
122
|
+
: counted
|
|
123
|
+
? `Not published. The directory says: this site is ${listed === true ? 'listed' : listed === false ? 'not listed yet' : 'known to it or not, it did not say'}; ${quotaLine(q).replace(/^P/, 'p')}`
|
|
124
|
+
: `Not published. The directory could not be asked how many publishes are left (${why}), so this is only what this computer knows: ${mine}.`;
|
|
125
|
+
const lines = [
|
|
126
|
+
line,
|
|
127
|
+
...(refused.length ? [`A publish now would be refused: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'} (${refused.slice(0, 6).map((p) => `${p.game}/${p.asset}: ${p.problem}`).join('; ')}${refused.length > 6 ? '; …' : ''}). homie-studio assets check <id> says how to fix each.`] : []),
|
|
128
|
+
...partsPlanLines(parts, { at: 'publish' }),
|
|
129
|
+
];
|
|
130
|
+
for (const l of lines) log(l);
|
|
131
|
+
return {
|
|
132
|
+
ok: true, command: 'publish before', published: false, directory, site: url ?? null, listed,
|
|
133
|
+
publishes: { remaining: counted ? q.remaining : local.remaining, limit: (counted ? q.limit : null) ?? local.limit, resetsAt: q?.resetsAt ?? null, sentToday: local.sent, from: counted ? 'directory' : 'this computer', line },
|
|
134
|
+
...(origin && !counted ? { unreached: { why, ...(asked.status !== undefined ? { status: asked.status } : {}) } } : {}),
|
|
135
|
+
...(refused.length ? { refused } : {}),
|
|
136
|
+
...(parts.games.length || parts.sharing.length || parts.why ? { parts } : {}),
|
|
137
|
+
lines,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
142
|
+
const studio = readStudio(root);
|
|
143
|
+
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
144
|
+
const url = site || siteUrl(root, studio);
|
|
145
|
+
// Licences first: a public game may ship only assets with a licence record that allows it.
|
|
146
|
+
const refused = licenceRefusals(root);
|
|
95
147
|
if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
|
|
96
148
|
if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
|
|
97
149
|
// Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
|
package/lib/doctor.mjs
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* homie in its own config, the row says so: that name is the plugin's connector's
|
|
12
12
|
* Homie's holds in Codex and Grok Build only: whether the plugin's hooks ran just now (they leave a dated
|
|
13
13
|
* mark, hooks/codex.mjs), so a session where nothing is held says so, and how to turn
|
|
14
|
-
* them on (Codex: trust them in /hooks; Grok Build
|
|
15
|
-
*
|
|
14
|
+
* them on (Codex: trust them in /hooks; Grok Build: trust the plugin, which
|
|
15
|
+
* `grok plugin install … --trust` does). The app is the one the AI names
|
|
16
16
|
* (--client codex|grok) or its environment shows
|
|
17
17
|
* Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
|
|
18
18
|
* first deploy; a deploy that went through proves it)
|
|
@@ -271,26 +271,27 @@ export async function setupStatus({
|
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
// Homie's holds, in Codex and Grok Build:
|
|
275
|
-
//
|
|
276
|
-
// every message and tool call; a fresh one means they are on.
|
|
274
|
+
// Homie's holds, in Codex and Grok Build: each app runs a plugin's hooks only once the person has trusted them
|
|
275
|
+
// (Codex in /hooks, Grok by trusting the plugin), and neither says so when it skips them. The hooks leave a dated
|
|
276
|
+
// mark at every message and tool call; a fresh one means they are on. (0.30.2 said Grok runs no plugin's hooks.
|
|
277
|
+
// It does: the plugin's root manifest named no hooks file, so Grok loaded the Claude Code mod's and found none.)
|
|
277
278
|
if (app === 'codex' || app === 'grok') {
|
|
278
279
|
const name = APPS[app].name;
|
|
279
280
|
const seen = holdsMark(app, { env, now: nowMs });
|
|
280
281
|
const on = Boolean(seen && seen.ageMs <= HOLDS_FRESH_MS);
|
|
281
282
|
rows.push({
|
|
282
|
-
// Off
|
|
283
|
-
id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' :
|
|
283
|
+
// Off is the person's to fix now, in either app: trust the hooks.
|
|
284
|
+
id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : 'act', on, app,
|
|
284
285
|
detail: on ? `on: Homie's hooks ran in ${name} ${ago(seen.ageMs)} ago`
|
|
285
286
|
: seen ? `off: Homie's hooks last ran in ${name} ${ago(seen.ageMs)} ago, and not for this session's calls`
|
|
286
|
-
:
|
|
287
|
+
: `off: Homie's hooks have not run in ${name} on this computer`,
|
|
287
288
|
...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
|
|
288
289
|
unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, a paid call past the budget and a model download; and secrets taken out of what your AI reads',
|
|
289
290
|
fix: on ? null : {
|
|
290
291
|
who: 'person',
|
|
291
292
|
say: app === 'codex'
|
|
292
293
|
? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
|
|
293
|
-
: '
|
|
294
|
+
: 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Install it again with trust (grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
|
|
294
295
|
},
|
|
295
296
|
});
|
|
296
297
|
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
|
31
31
|
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
|
|
32
32
|
import { runningDev } from './dev.mjs';
|
|
33
33
|
import { deployWords } from './deploy-state.mjs';
|
|
34
|
-
import {
|
|
34
|
+
import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
|
|
35
35
|
import { STUDIO_VERSION } from './version.mjs';
|
|
36
36
|
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
37
37
|
import { pinnedVersion } from './upgrade.mjs';
|
|
@@ -1085,7 +1085,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1085
1085
|
{
|
|
1086
1086
|
name: 'studio_deploy', title: 'Put the studio online',
|
|
1087
1087
|
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, free plan, no payment method. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
|
|
1088
|
-
inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ...STUDIO_ARG } },
|
|
1088
|
+
inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ownRoute: { type: 'boolean', description: 'Only after the plan or a deploy warned that another site\'s route on the studio\'s custom domain covers its hostname, and the person agreed: add the studio\'s own exact-host route (the one line the warning gives) and deploy. It never edits or removes any other route' }, ...STUDIO_ARG } },
|
|
1089
1089
|
annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
|
|
1090
1090
|
run: async (a) => {
|
|
1091
1091
|
const root = ctx.root(a.studio);
|
|
@@ -1095,10 +1095,10 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1095
1095
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1096
1096
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1097
1097
|
const p = r.result;
|
|
1098
|
-
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1098
|
+
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1099
1099
|
}
|
|
1100
1100
|
const studio = readStudio(root);
|
|
1101
|
-
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
|
|
1101
|
+
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
|
1102
1102
|
},
|
|
1103
1103
|
},
|
|
1104
1104
|
{
|
|
@@ -1119,18 +1119,22 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1119
1119
|
},
|
|
1120
1120
|
{
|
|
1121
1121
|
name: 'studio_publish', title: 'List the studio in the Homie directory',
|
|
1122
|
-
description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true
|
|
1123
|
-
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: '
|
|
1122
|
+
description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true publishes nothing: it asks the directory how many are left and whether the site is listed (a read), and says so plainly when the directory cannot be reached.',
|
|
1123
|
+
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Publish nothing: ask the directory (a read) whether this site is listed and how many publishes are left today, and say the parts\' licence lines. When the directory cannot be reached it says so, with what this computer has sent today' }, ...STUDIO_ARG } },
|
|
1124
1124
|
annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1125
1125
|
run: async (a) => {
|
|
1126
1126
|
const root = ctx.root(a.studio);
|
|
1127
|
-
// What this computer knows of the day's cap, before anything is sent (lib/directory.mjs).
|
|
1128
|
-
const soFar = beforeLine(publishesSoFar(root));
|
|
1129
1127
|
// The parts in the games a listing shows, in the deploy plan's own lines (licences, credits, and plainly when
|
|
1130
1128
|
// two cannot be combined): said before the studio is listed, and again with the answer.
|
|
1131
1129
|
const partsNow = partsForListing(root);
|
|
1132
1130
|
const partsSaid = partsPlanLines(partsNow, { at: 'publish' });
|
|
1133
|
-
|
|
1131
|
+
// before: true is `publish --before`, the same function the terminal runs (lib/directory.mjs publishBefore):
|
|
1132
|
+
// the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
|
|
1133
|
+
// toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
|
|
1134
|
+
if (a.before === true) {
|
|
1135
|
+
const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
|
|
1136
|
+
return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
|
|
1137
|
+
}
|
|
1134
1138
|
const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
|
|
1135
1139
|
if (!r.ended) return stillRunning(r.job, 'Listing');
|
|
1136
1140
|
if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
|