@homie-rocks/studio 0.51.1 → 0.51.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +15 -4
- package/bin/homie-studio.mjs +7 -7
- package/guides/app/SKILL.md +14 -0
- package/guides/office/SKILL.md +9 -0
- package/guides/studio-setup/SKILL.md +9 -1
- package/lib/app-preset-history.json +840 -0
- package/lib/build.mjs +4 -2
- package/lib/cloudflare.mjs +19 -10
- package/lib/dev.mjs +1 -1
- package/lib/directory.mjs +22 -5
- package/lib/giving-preset.mjs +9 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/office.mjs +2 -1
- package/lib/scaffold.mjs +7 -4
- package/lib/shop.mjs +2 -1
- package/lib/template-history.json +50 -2
- package/lib/upgrade-app-presets.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/mcp/ui/build.js +3 -2
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/tools/TOOLS.md +9 -0
- package/worker/arrival.mjs +2 -1
- package/worker/build-id.mjs +1 -1
- package/worker/directory-choice.mjs +9 -0
- package/worker/index.mjs +18 -9
- package/worker/mcp.mjs +2 -2
- package/worker/pages.mjs +3 -2
- package/worker/records-page.mjs +1 -1
- package/worker/signin-target.mjs +8 -0
- package/worker/site.mjs +2 -0
- package/worker/stats-page.mjs +6 -3
- package/worker/version.mjs +1 -1
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
2
|
+
import { domainOrigin } from './studio.mjs';
|
|
1
3
|
import { appProblems } from '../worker/app-format.mjs';
|
|
2
4
|
/**
|
|
3
5
|
* `homie-studio build` — every game in games/ bundled into site/dist, plus the
|
|
@@ -707,8 +709,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
707
709
|
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
708
710
|
// The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
|
|
709
711
|
// HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
|
|
710
|
-
directory:
|
|
711
|
-
|
|
712
|
+
directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
|
|
713
|
+
canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
|
|
712
714
|
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
713
715
|
...(studio.template === true ? { template: true } : {}),
|
|
714
716
|
// studio.json audience is a studio label; shop policies are optional.
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
1
2
|
import { roomLayout } from '../worker/gate.mjs';
|
|
2
3
|
import {workerEntry} from './worker-build.mjs';
|
|
3
4
|
import { seatCount } from '../worker/seats.mjs';
|
|
@@ -51,6 +52,11 @@ import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
|
51
52
|
|
|
52
53
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
53
54
|
|
|
55
|
+
export function experienceAddresses(root, id, url) {
|
|
56
|
+
const app = listGames(root).find(g => g.id === id)?.kind === 'app';
|
|
57
|
+
return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
export function wranglerBin(root) {
|
|
55
61
|
const local = join(root, 'node_modules', '.bin', 'wrangler');
|
|
56
62
|
return existsSync(local) ? local : null;
|
|
@@ -65,6 +71,8 @@ export function runner(root, env) {
|
|
|
65
71
|
const bin = wranglerBin(root);
|
|
66
72
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
67
73
|
return (args, { cwd = workerDir(root), input } = {}) => {
|
|
74
|
+
const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
|
|
75
|
+
if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
|
|
68
76
|
const res = spawnSync(bin, args, {
|
|
69
77
|
cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
70
78
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
@@ -211,11 +219,13 @@ export function deployPlan(root) {
|
|
|
211
219
|
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
212
220
|
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
213
221
|
],
|
|
214
|
-
cost:
|
|
222
|
+
cost: null,
|
|
215
223
|
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
216
224
|
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
217
225
|
directory: {
|
|
218
|
-
site: studio.homie?.directory
|
|
226
|
+
site: directoryUrl(studio.homie?.directory),
|
|
227
|
+
requested: Boolean(directoryUrl(studio.homie?.directory)),
|
|
228
|
+
choice: 'A deploy claims a listing only when homie.directory is true or a directory URL. Existing claims stay honoured; studios that never asked are not listed.',
|
|
219
229
|
stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
|
|
220
230
|
},
|
|
221
231
|
never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
|
|
@@ -302,8 +312,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
302
312
|
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
303
313
|
announced.push(
|
|
304
314
|
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
305
|
-
|
|
306
|
-
`The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
|
|
315
|
+
deployPlan(root).directory.choice,
|
|
307
316
|
);
|
|
308
317
|
for (const line of announced) log(line);
|
|
309
318
|
}
|
|
@@ -425,7 +434,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
425
434
|
if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
|
|
426
435
|
step(`closed ${synced.expired} obsolete part Checkouts`);
|
|
427
436
|
}
|
|
428
|
-
const directory = homie
|
|
437
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory);
|
|
429
438
|
recordDeploy(root, builtNow);
|
|
430
439
|
let claim = null;
|
|
431
440
|
if (url) {
|
|
@@ -441,10 +450,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
441
450
|
} else if (read.state === 'unreachable') {
|
|
442
451
|
// This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
|
|
443
452
|
// verdict on the site that was just deployed.
|
|
444
|
-
const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'}
|
|
453
|
+
const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} Listing follows the studio’s directory choice.`;
|
|
445
454
|
notes.push(said);
|
|
446
455
|
step(said, { preflight: read.preflight ?? 'network' });
|
|
447
|
-
} else step('the directory claim is not there yet
|
|
456
|
+
} else step(directory ? 'the requested directory claim is not there yet' : 'no new listing requested; existing claims are honoured');
|
|
448
457
|
}
|
|
449
458
|
|
|
450
459
|
// The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
|
|
@@ -454,7 +463,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
454
463
|
let reread = null;
|
|
455
464
|
if (url && delta.withdrawn.length) {
|
|
456
465
|
const which = delta.withdrawn.join(', ');
|
|
457
|
-
const dir =
|
|
466
|
+
const dir = directory ?? readLocal(root).listed?.directory;
|
|
458
467
|
if (listedHere(root, { site: url, directory: dir })) {
|
|
459
468
|
const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
|
|
460
469
|
reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
|
|
@@ -474,7 +483,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
474
483
|
return {
|
|
475
484
|
ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
476
485
|
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
477
|
-
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null,
|
|
486
|
+
games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
|
|
478
487
|
...(delta.removed.length ? { removed: delta.removed } : {}),
|
|
479
488
|
...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
|
|
480
489
|
...(zone ? { zone: zoneView(zone) } : {}),
|
|
@@ -643,7 +652,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
643
652
|
ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
644
653
|
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
645
654
|
// A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
|
|
646
|
-
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null,
|
|
655
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
|
|
647
656
|
claim: Boolean(claim),
|
|
648
657
|
};
|
|
649
658
|
}
|
package/lib/dev.mjs
CHANGED
|
@@ -197,7 +197,7 @@ export function devConfig(root, remoteAi) {
|
|
|
197
197
|
const routed = ROUTE_KEYS.some((k) => k in json);
|
|
198
198
|
const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
|
|
199
199
|
const entry=workerEntry(root);
|
|
200
|
-
|
|
200
|
+
// Always retain the exact local binding configuration for CLI commands after a deploy.
|
|
201
201
|
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
202
202
|
delete json.$schema;
|
|
203
203
|
json.main = entry ?? abs(json.main);
|
package/lib/directory.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
|
+
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
7
8
|
import { request } from './net.mjs';
|
|
8
|
-
import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
|
|
9
|
+
import { domainOrigin, listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
|
|
9
10
|
import { licenceProblems, readManifest } from './asset-manifest.mjs';
|
|
10
11
|
import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
|
|
11
12
|
|
|
@@ -103,8 +104,8 @@ function licenceRefusals(root) {
|
|
|
103
104
|
*/
|
|
104
105
|
export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
105
106
|
const studio = readStudio(root);
|
|
106
|
-
const directory = (homie
|
|
107
|
-
const url = site || siteUrl(root, studio);
|
|
107
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
|
|
108
|
+
const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
|
|
108
109
|
const refused = licenceRefusals(root);
|
|
109
110
|
const parts = partsForListing(root);
|
|
110
111
|
const local = publishesSoFar(root);
|
|
@@ -140,8 +141,8 @@ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn
|
|
|
140
141
|
|
|
141
142
|
export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
|
|
142
143
|
const studio = readStudio(root);
|
|
143
|
-
const directory = (homie
|
|
144
|
-
const url = site || siteUrl(root, studio);
|
|
144
|
+
const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
|
|
145
|
+
const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
|
|
145
146
|
// Licences first: a public game may ship only assets with a licence record that allows it.
|
|
146
147
|
const refused = licenceRefusals(root);
|
|
147
148
|
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.` };
|
|
@@ -149,6 +150,22 @@ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {
|
|
|
149
150
|
// Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
|
|
150
151
|
// cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
|
|
151
152
|
// It does not stop the listing: the person decides, having been told.
|
|
153
|
+
// An explicit publish is itself the choice to list. Seed the same D1 claim the
|
|
154
|
+
// Worker uses, so a studio deployed without opt-in does not need another deploy.
|
|
155
|
+
if (!directoryUrl(studio.homie?.directory)) {
|
|
156
|
+
let origin;
|
|
157
|
+
try { origin = new URL(url).origin; } catch { return {ok:false,command:'publish',why:'Invalid studio address'}; }
|
|
158
|
+
const claimed = await request(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {}, {timeout:15_000,...(fetchFn?{fetchFn}:{})});
|
|
159
|
+
const token = claimed.body?.claim;
|
|
160
|
+
if (!claimed.ok || !/^[a-f0-9]{16,128}$/.test(token ?? '')) return {ok:false,command:'publish',why:claimed.why ?? 'The directory did not return a claim; nothing was listed.'};
|
|
161
|
+
const {runner} = await import('./cloudflare.mjs');
|
|
162
|
+
const {loopback} = await import('./stats.mjs');
|
|
163
|
+
const local = loopback(origin);
|
|
164
|
+
const w = runner(root, !local && studio.cloudflare?.accountId ? {CLOUDFLARE_ACCOUNT_ID:studio.cloudflare.accountId} : {});
|
|
165
|
+
const saved = w(['d1','execute',studio.cloudflare.d1,local?'--local':'--remote','--command',`INSERT OR REPLACE INTO meta (key,value) VALUES ('homie_claim:${origin.replaceAll("'", "''")}','${token}');`]);
|
|
166
|
+
if(saved.code!==0)return {ok:false,command:'publish',why:'Could not store the directory claim in the studio database: '+saved.out};
|
|
167
|
+
writeStudio(root,{...studio,homie:{...studio.homie,directory}});
|
|
168
|
+
}
|
|
152
169
|
const parts = partsForListing(root);
|
|
153
170
|
for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
|
|
154
171
|
const before = publishesSoFar(root);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Defaults shared by shop init and the upgrade of an untouched earlier preset. */
|
|
2
|
+
export function givingItems(amounts = [1000, 2000, 2500, 5000]) {
|
|
3
|
+
return amounts.flatMap(amount => [false, true].map(monthly => ({
|
|
4
|
+
id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation',
|
|
5
|
+
name: monthly ? 'Monthly gift' : 'One-time gift', price: amount,
|
|
6
|
+
...(monthly ? { interval: 'month' } : {}), gives: [],
|
|
7
|
+
blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.',
|
|
8
|
+
})));
|
|
9
|
+
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1171,7 +1171,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1171
1171
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1172
1172
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1173
1173
|
const p = r.result;
|
|
1174
|
-
return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}
|
|
1174
|
+
return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), ...(p.cost ? [`Cost: ${p.cost}`] : []), `Sign-in: ${p.login}`, ...(p.directory?.choice ? [p.directory.choice] : []), ...(p.directory?.site ? [`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 });
|
|
1175
1175
|
}
|
|
1176
1176
|
const studio = readStudio(root);
|
|
1177
1177
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
package/lib/office.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signinTarget } from '../worker/signin-target.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
|
|
3
4
|
*
|
|
@@ -83,7 +84,7 @@ export async function officeShow(root, { url } = {}) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export function officeLink(root, { url, to } = {}) {
|
|
86
|
-
const target = to
|
|
87
|
+
const target = signinTarget(to, '/_studio/office');
|
|
87
88
|
const r = signinTo(root, url, target);
|
|
88
89
|
if (!r.ok) return { ok: false, command: 'office link', why: r.why };
|
|
89
90
|
return {
|
package/lib/scaffold.mjs
CHANGED
|
@@ -245,8 +245,8 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
245
245
|
- **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
|
|
246
246
|
every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
|
|
247
247
|
the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
|
|
248
|
-
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms.
|
|
249
|
-
|
|
248
|
+
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. A live site requests a directory claim only with
|
|
249
|
+
studio.json \`homie.directory: true\` (homie.rocks) or a directory URL. Existing claims stay honoured.
|
|
250
250
|
- \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
|
|
251
251
|
bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
|
|
252
252
|
before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
|
|
@@ -1085,14 +1085,17 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
1085
1085
|
if (real) {
|
|
1086
1086
|
if (!statSync(real).isDirectory()) throw new Error(`${real} is a file. Nothing was written.`);
|
|
1087
1087
|
const entries = readdirSync(real).filter((n) => n !== '.DS_Store' && n !== '.git');
|
|
1088
|
-
|
|
1088
|
+
let resume = false;
|
|
1089
|
+
try { const existing = JSON.parse(readFileSync(join(real, 'studio.json'), 'utf8')); resume = existing.name === String(name).trim() && existing.slug === slugify(askedSlug || name) && Boolean(existing.homie?.studio); } catch {}
|
|
1090
|
+
if (entries.length && !resume) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
|
|
1089
1091
|
}
|
|
1090
1092
|
const slug = slugify(askedSlug || name);
|
|
1091
|
-
const files = studioFiles({ name: String(name).trim(), slug, homie: homie
|
|
1093
|
+
const files = studioFiles({ name: String(name).trim(), slug, homie: homie ?? null, template, kind });
|
|
1092
1094
|
mkdirSync(dir, { recursive: true });
|
|
1093
1095
|
const wrote = [];
|
|
1094
1096
|
for (const [rel, content] of Object.entries(files)) {
|
|
1095
1097
|
const path = join(dir, rel);
|
|
1098
|
+
if (existsSync(path)) continue;
|
|
1096
1099
|
mkdirSync(dirname(path), { recursive: true });
|
|
1097
1100
|
writeFileSync(path, content);
|
|
1098
1101
|
wrote.push(rel);
|
package/lib/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {givingItems} from './giving-preset.mjs';
|
|
1
2
|
import { paidReleases } from './parts-upload.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
|
|
@@ -115,7 +116,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
|
|
|
115
116
|
const shop = {
|
|
116
117
|
...(giving ? { giving: { receiptText: '' } } : {}),
|
|
117
118
|
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
|
|
118
|
-
items: giving ?
|
|
119
|
+
items: giving ? givingItems() : supporter ? [supporterItem({ price: Number(price) })] : [],
|
|
119
120
|
};
|
|
120
121
|
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
121
122
|
if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
|
|
@@ -3553,7 +3553,10 @@
|
|
|
3553
3553
|
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3554
3554
|
"## Layout": "f3a280b8c96cd068",
|
|
3555
3555
|
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3556
|
-
"## Commands (all through the pinned CLI in node_modules)":
|
|
3556
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
3557
|
+
"5222e08ca55e326e",
|
|
3558
|
+
"c20d90de21cde0c7"
|
|
3559
|
+
],
|
|
3557
3560
|
"## The site": "0cb8dd9a6f8a0096",
|
|
3558
3561
|
"## Making games": "bb715272fd1a615b",
|
|
3559
3562
|
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
@@ -3571,7 +3574,52 @@
|
|
|
3571
3574
|
"Homie Arcade": {
|
|
3572
3575
|
"sections": {
|
|
3573
3576
|
"AGENTS.md": {
|
|
3574
|
-
"## Commands (all through the pinned CLI in node_modules)":
|
|
3577
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
3578
|
+
"611b437f5b3303f0",
|
|
3579
|
+
"8364e05cebefcba4"
|
|
3580
|
+
]
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
},
|
|
3586
|
+
"0.51.2": {
|
|
3587
|
+
"files": {
|
|
3588
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3589
|
+
"README.md": "684b3cca8001673c",
|
|
3590
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3591
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3592
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3593
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3594
|
+
"posts/README.md": "37841aae476bd540",
|
|
3595
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3596
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3597
|
+
},
|
|
3598
|
+
"sections": {
|
|
3599
|
+
"AGENTS.md": {
|
|
3600
|
+
"(intro)": "394cbbe934e80722",
|
|
3601
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3602
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3603
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3604
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3605
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3606
|
+
"## Making games": "bb715272fd1a615b",
|
|
3607
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3608
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3609
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3610
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3611
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3612
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3613
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3614
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3615
|
+
"## Beta": "9d02638207a26e73"
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3618
|
+
"named": {
|
|
3619
|
+
"Homie Arcade": {
|
|
3620
|
+
"sections": {
|
|
3621
|
+
"AGENTS.md": {
|
|
3622
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3575
3623
|
}
|
|
3576
3624
|
}
|
|
3577
3625
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {givingItems} from './giving-preset.mjs';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {readFileSync,existsSync,readdirSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
const history=JSON.parse(readFileSync(new URL('./app-preset-history.json',import.meta.url),'utf8'));
|
|
7
|
+
const presets=fileURLToPath(new URL('../app-starters/',import.meta.url));
|
|
8
|
+
const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
9
|
+
const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
|
|
10
|
+
// A three-way merge: only values still equal to the old preset take the new default.
|
|
11
|
+
function merge(mine,old,next) {
|
|
12
|
+
if(equal(mine,old))return next;
|
|
13
|
+
if(!object(mine)||!object(old)||!object(next))return mine;
|
|
14
|
+
const result={...mine};
|
|
15
|
+
for(const key of new Set([...Object.keys(old),...Object.keys(next)])) {
|
|
16
|
+
const value=merge(mine[key],old[key],next[key]);
|
|
17
|
+
if(value===undefined)delete result[key];else result[key]=value;
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
export function appPresetChanges(root) {
|
|
22
|
+
const changes=[];
|
|
23
|
+
const shopFile=join(root,'shop.json');
|
|
24
|
+
if(existsSync(shopFile)) {
|
|
25
|
+
const from=readFileSync(shopFile,'utf8');let shop;try{shop=JSON.parse(from);}catch{}
|
|
26
|
+
if(shop?.giving && equal(shop.items,givingItems([1000,2500,5000]))) {
|
|
27
|
+
changes.push({file:'shop.json',kind:'update-file',what:'refresh untouched giving choices; keep currency and receipt wording',from,text:JSON.stringify({...shop,items:givingItems()},null,2)+'\n'});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const apps=join(root,'apps');if(!existsSync(apps))return changes;
|
|
31
|
+
for(const entry of readdirSync(apps,{withFileTypes:true})) {
|
|
32
|
+
if(!entry.isDirectory())continue;
|
|
33
|
+
const rel=`apps/${entry.name}`,file=join(root,rel,'app.json');if(!existsSync(file))continue;
|
|
34
|
+
const text=readFileSync(file,'utf8');let mine;try{mine=JSON.parse(text);}catch{continue;}
|
|
35
|
+
const kind=mine.starter?.kind,old=history[kind];if(!old)continue;
|
|
36
|
+
const next=JSON.parse(readFileSync(join(presets,kind,'app.json'),'utf8'));
|
|
37
|
+
const merged=merge(mine,old.meta,next);merged.id=mine.id;merged.name=mine.name;
|
|
38
|
+
if(!equal(mine,merged))changes.push({file:rel+'/app.json',kind:'update-file',what:'refresh unchanged app preset defaults; keep studio edits',from:text,text:JSON.stringify(merged,null,2)+'\n'});
|
|
39
|
+
for(const [path,hash] of Object.entries(old.files)) {
|
|
40
|
+
if(path==='app.json'||!existsSync(join(root,rel,path))||!existsSync(join(presets,kind,path)))continue;
|
|
41
|
+
const from=readFileSync(join(root,rel,path),'utf8');
|
|
42
|
+
const normalized=from.replaceAll(`game: '${mine.id}'`,"game: 'welcome'");
|
|
43
|
+
if(createHash('sha256').update(normalized).digest('hex')!==hash)continue;
|
|
44
|
+
const want=readFileSync(join(presets,kind,path),'utf8').replaceAll("game: 'welcome'",`game: '${mine.id}'`);
|
|
45
|
+
if(from!==want)changes.push({file:rel+'/'+path,kind:'update-file',what:'refresh untouched app preset file',from,text:want});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {appPresetChanges} from './upgrade-app-presets.mjs';
|
|
1
2
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
|
|
@@ -174,7 +175,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
174
175
|
const who = { name, slug };
|
|
175
176
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
176
177
|
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
177
|
-
const changes =
|
|
178
|
+
const changes = appPresetChanges(root);
|
|
178
179
|
const kept = [];
|
|
179
180
|
|
|
180
181
|
// package.json: the pin, and the scripts the template's studios have.
|
package/mcp/ui/build.js
CHANGED
|
@@ -70,9 +70,10 @@
|
|
|
70
70
|
C.add(C.el('div', 'cb'), C.el('b', '', x.kind + ' ' + x.name), C.el('span', '', x.state + (x.plan ? ' · ' + x.plan : '')))));
|
|
71
71
|
});
|
|
72
72
|
card.appendChild(ul);
|
|
73
|
-
if (p.cost) card.appendChild(C.el('p', 'note', p.cost));
|
|
73
|
+
if (p.cost) card.appendChild(C.el('p', 'note billing', p.cost));
|
|
74
74
|
card.appendChild(C.el('p', 'fine', 'Cloudflare asks you to approve once in your browser if this computer is not signed in (a free account works).'));
|
|
75
|
-
if (p.directory && p.directory.
|
|
75
|
+
if (p.directory && p.directory.choice) card.appendChild(C.el('p', 'directory-choice', p.directory.choice));
|
|
76
|
+
if (p.directory && p.directory.site && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
|
|
76
77
|
if (p.never) card.appendChild(C.el('p', 'fine', p.never));
|
|
77
78
|
return card;
|
|
78
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.2",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/site/SITE.md
CHANGED
|
@@ -591,3 +591,16 @@ credentials.
|
|
|
591
591
|
X decides what it shows, may cache tags, and may change or stop this behavior.
|
|
592
592
|
Local success does not prove that X will display a card. The studio can use the
|
|
593
593
|
off switch above if it does not want to offer a game through a video-oriented card.
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
## Canonical address and decorative app frames
|
|
597
|
+
|
|
598
|
+
Set `cloudflare.domain` for the canonical public address. The manifest uses that
|
|
599
|
+
address even when fetched from workers.dev; browser pages on the alias redirect,
|
|
600
|
+
while discovery and tool transports remain reachable. Listing requires an explicit
|
|
601
|
+
`homie.directory: true` or URL; existing claims remain valid.
|
|
602
|
+
|
|
603
|
+
An iframe inside `.hero-media` is decorative and lets the parent page scroll.
|
|
604
|
+
Add `data-interactive` only when people should interact with the hero. Ordinary app
|
|
605
|
+
frames permit Web Share and downloads; browser capability and a user gesture still
|
|
606
|
+
apply. App shells allow normal touch scrolling and text selection.
|
package/tools/TOOLS.md
CHANGED
|
@@ -179,3 +179,12 @@ and removes secrets directly; remote owner tools `studio_events_webhook_status`
|
|
|
179
179
|
and `studio_events_webhook_test` inspect delivery and test it. The receiver verifies
|
|
180
180
|
Standard Webhooks signatures, deduplicates events, then wakes the owner's AI to use
|
|
181
181
|
its own service connectors.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
## Signed-in connector address
|
|
185
|
+
|
|
186
|
+
Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
|
|
187
|
+
`WWW-Authenticate` resource metadata URL for `/.well-known/oauth-protected-resource/mcp`.
|
|
188
|
+
Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
|
|
189
|
+
token. An approved connection retains the account’s current app grants, not a copy
|
|
190
|
+
of its old permissions. Manage connections at `/_studio/office/connections`.
|
package/worker/arrival.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {appWords} from './app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE ARRIVAL (0.26.0): the play page and the big screen never open on a blank screen. From the page's first paint
|
|
3
4
|
* until the game says it is playable, an arrival card covers the game's frame in the game's own look: its title and
|
|
@@ -66,7 +67,7 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
|
|
|
66
67
|
const pitch = String(L.pitch ?? '').trim() || firstSentence(g?.blurb);
|
|
67
68
|
const studio = cat?.studio?.name ?? '';
|
|
68
69
|
const keys = screen
|
|
69
|
-
? [['tv', L.controls?.tv ?? 'Phones are the controllers: scan the code to play.']]
|
|
70
|
+
? [['tv', L.controls?.tv ?? (g.kind === 'app' ? appWords(g).join : 'Phones are the controllers: scan the code to play.')]]
|
|
70
71
|
: [['phone', L.controls?.phone], ['desk', L.controls?.computer]].filter(([, v]) => v);
|
|
71
72
|
const art = wide || tall
|
|
72
73
|
? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.2","sha256":"bffe9d1a55a4cbe04cb14a1c1d20fe5d32e7a1aa2d2a637d81b45c10c4087b64"});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Only an explicit choice enables a new directory claim. */
|
|
2
|
+
export function directoryUrl(value) {
|
|
3
|
+
if (value === true) return 'https://homie.rocks';
|
|
4
|
+
if (typeof value !== 'string') return null;
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) ? url.origin : null;
|
|
8
|
+
} catch { return null; }
|
|
9
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { directoryUrl } from './directory-choice.mjs';
|
|
1
2
|
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
2
3
|
import {recordsPage} from './records-page.mjs';
|
|
3
4
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
@@ -235,13 +236,7 @@ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return
|
|
|
235
236
|
const repoSaid = new Map();
|
|
236
237
|
|
|
237
238
|
export function directoryOf(cat) {
|
|
238
|
-
|
|
239
|
-
if (d === null || d === false) return null;
|
|
240
|
-
const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
|
|
241
|
-
try {
|
|
242
|
-
const u = new URL(at);
|
|
243
|
-
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
|
|
244
|
-
} catch { return null; }
|
|
239
|
+
return directoryUrl(cat?.studio?.directory);
|
|
245
240
|
}
|
|
246
241
|
|
|
247
242
|
/** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
|
|
@@ -251,6 +246,7 @@ async function claimOf(env, cat, url) {
|
|
|
251
246
|
if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
|
|
252
247
|
const kept = await storedClaim(env, origin);
|
|
253
248
|
if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
|
|
249
|
+
if (kept?.older) return kept.older;
|
|
254
250
|
const directory = directoryOf(cat);
|
|
255
251
|
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
256
252
|
const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
|
|
@@ -543,7 +539,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
543
539
|
'cache-control': 'no-store, no-transform',
|
|
544
540
|
'access-control-allow-origin': '*',
|
|
545
541
|
// The game runs in an opaque origin: it cannot read this site's storage or cookies.
|
|
546
|
-
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
|
|
542
|
+
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
|
|
547
543
|
},
|
|
548
544
|
});
|
|
549
545
|
}
|
|
@@ -678,9 +674,18 @@ async function route(request, env, ctx) {
|
|
|
678
674
|
const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
679
675
|
const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
|
|
680
676
|
|
|
677
|
+
// Keep tool transports and discovery on the alias; redirect browser pages only.
|
|
678
|
+
if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
|
|
679
|
+
&& request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
|
|
680
|
+
&& !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
|
|
681
|
+
&& !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
|
|
682
|
+
const canonical = (await getAll()).studio?.canonical;
|
|
683
|
+
if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
|
|
684
|
+
}
|
|
685
|
+
|
|
681
686
|
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
682
687
|
|
|
683
|
-
if (path === '/mcp' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
688
|
+
if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
684
689
|
const { remoteMcp } = await import('./mcp.mjs');
|
|
685
690
|
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
686
691
|
}
|
|
@@ -726,6 +731,10 @@ async function route(request, env, ctx) {
|
|
|
726
731
|
}
|
|
727
732
|
if (path === '/.well-known/homie-studio.json') {
|
|
728
733
|
const cat = await getCat();
|
|
734
|
+
const canonical = cat.studio?.canonical;
|
|
735
|
+
if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
|
|
736
|
+
const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
|
|
737
|
+
}
|
|
729
738
|
const sharing = Boolean(cat.studio?.stats?.share);
|
|
730
739
|
const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
|
|
731
740
|
// studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
|
package/worker/mcp.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
158
158
|
});
|
|
159
159
|
return s;
|
|
160
160
|
};
|
|
161
|
-
return createMcpHandler(server)(request,env,ctx);
|
|
161
|
+
return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
|
|
162
162
|
};
|
|
163
163
|
if(localIdentity)return api(request,scoped,ctx);
|
|
164
164
|
const provider=new OAuthProvider({
|
|
@@ -211,7 +211,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
211
211
|
const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
|
|
212
212
|
const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
|
|
213
213
|
const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
|
|
214
|
-
return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
214
|
+
return html(`<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
215
215
|
}
|
|
216
216
|
return json({error:'not found'},404);
|
|
217
217
|
}},
|