@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 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 the row says so; Grok Build 1.0.41 runs no plugin's hooks at all, so
500
- there the row is off until a Grok that does.
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 listed,
663
- changed or removed. When the zone already has a wildcard route on another Worker (`*example.com/*`), that Worker
664
- answers the studio's hostname first; the deploy notices that its own domain answers as something else and prints the
665
- exact-host route to add, once. A wildcard or catch-all route (`*/*`) in the studio's own config would hand every
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
 
@@ -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 (the directory's beta has a daily cap: it says how many publishes are left)
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')) return { ...deployPlan(root), parts: partsPublishReport(root) };
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') });
@@ -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 calls nothing.
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
- const routes = keptRoutes(root, studio);
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 there is nothing to ask (the directory has no "how many are left" call this toolkit knows), so
20
- * the toolkit says what THIS COMPUTER knows: how many it has sent today (UTC, the day the cap counts in), and
21
- * what the directory said was left after the last one. That is a floor, never a promise: a publish from another
22
- * computer or from the Homie connector is not counted here.
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
- export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
86
- const studio = readStudio(root);
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 1.0.41 runs no plugin's hooks, so
15
- * there the row is off until a Grok that runs them). The app is the one the AI names
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: Codex runs the plugin's hooks only once the person has trusted them, Grok
275
- // Build 1.0.41 was seen to run no plugin's hooks at all, and neither app says so. The hooks leave a dated mark at
276
- // every message and tool call; a fresh one means they are on. Off in Grok is Grok's doing, so it is said as that.
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 in Codex is the person's to fix now (trust the hooks); off in Grok is nothing they can fix.
283
- id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : app === 'codex' ? 'act' : 'optional', on, app,
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
- : app === 'grok' ? 'off: Grok Build runs no plugin\'s hooks yet (1.0.41), so nothing is held in Grok' : `off: Homie's hooks have not run in ${name} on this computer`,
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
- : 'Nothing to do on your side: Grok Build 1.0.41 runs no plugin\'s hooks (none are registered in its headless and agent sessions, from any plugin; checked 2026-10-04), so Homie\'s holds are off in Grok. Nothing is held and no secret is taken out of what your AI reads, so your AI asks you before each of those itself. This row turns on by itself when a Grok that runs them leaves the hooks\' mark.',
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 { beforeLine, partsForListing, publishesSoFar } from './directory.mjs';
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 only says what this computer knows (how many it has sent today, what the directory last said was left) without publishing.',
1123
- inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Only say how many publishes this computer has sent today and what the directory last said was left; publish nothing' }, ...STUDIO_ARG } },
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
- if (a.before === true) return ok([`${soFar.replace(/^Publishing to the directory: /, 'Not published. ')} A publish from another computer or from the Homie connector is not counted here.`, ...partsSaid].join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...(partsSaid.length ? { parts: partsNow } : {}) });
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);