@homie-rocks/studio 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -2
- package/bin/homie-studio.mjs +75 -1
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +8 -3
- package/lib/cloudflare.mjs +7 -2
- package/lib/codex.mjs +664 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/progress.mjs +7 -0
- package/lib/scaffold.mjs +20 -0
- package/lib/statusline.mjs +155 -0
- package/lib/template-history.json +24 -0
- package/package.json +1 -1
- package/worker/codex-script.mjs +6 -0
- package/worker/stats-page.mjs +34 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Game Codex page's one script (lib/codex.mjs draws the page): its tabs, "Updated 5 s ago", and a reload every
|
|
3
|
+
* few seconds while a build runs in the local copy. Constant text with no data in it, so the site's private codex
|
|
4
|
+
* page (worker/stats-page.mjs) allows exactly this script by its hash and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
export const CODEX_SCRIPT = `(function(){var d=document,t=[].slice.call(d.querySelectorAll('.tab')),n=[].slice.call(d.querySelectorAll('nav.tabs a'));if(!t.length)return;d.documentElement.classList.add('js');function show(id){var f=t.filter(function(s){return s.id===id})[0]||t.filter(function(s){return s.id===d.body.getAttribute('data-start')})[0]||t[0];t.forEach(function(s){s.classList.toggle('on',s===f)});n.forEach(function(a){a.classList.toggle('on',a.getAttribute('href')==='#'+f.id)});return f}var cur=show(location.hash.slice(1));var nav=d.querySelector('nav.tabs');window.addEventListener('hashchange',function(){cur=show(location.hash.slice(1));if(nav&&window.scrollY>nav.offsetTop)window.scrollTo(0,nav.offsetTop)});function ago(){[].forEach.call(d.querySelectorAll('[data-ago]'),function(e){var w=Date.parse(e.getAttribute('data-ago'));if(!w)return;var s=Math.max(0,Math.round((Date.now()-w)/1000));e.textContent='Updated '+(s<60?s+' s':s<3600?Math.round(s/60)+' min':Math.round(s/3600)+' h')+' ago'})}ago();setInterval(ago,1000);var st=d.getElementById('build-status');if(d.body.getAttribute('data-mode')==='file'&&st&&st.getAttribute('data-running')==='1')setTimeout(function(){if(cur===st)location.reload()},5000)})();`;
|
package/worker/stats-page.mjs
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* never cached, never indexed.
|
|
9
9
|
*/
|
|
10
10
|
import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
|
|
11
|
+
import { CODEX_SCRIPT } from './codex-script.mjs';
|
|
12
|
+
|
|
13
|
+
/** Where a sign-in may send the owner next: the stats, or one game's codex. */
|
|
14
|
+
const NEXT = /^\/_studio\/(?:stats|codex\/[a-z0-9][a-z0-9-]{0,39}\/)$/;
|
|
15
|
+
const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
|
|
16
|
+
let scriptHash = null;
|
|
17
|
+
async function codexScriptHash() {
|
|
18
|
+
if (!scriptHash) {
|
|
19
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(CODEX_SCRIPT));
|
|
20
|
+
scriptHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
|
|
21
|
+
}
|
|
22
|
+
return scriptHash;
|
|
23
|
+
}
|
|
11
24
|
|
|
12
25
|
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
13
26
|
const fmt = (n) => Number(n || 0).toLocaleString('en-US');
|
|
@@ -119,12 +132,12 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
|
|
|
119
132
|
// A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
|
|
120
133
|
return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
|
|
121
134
|
<p class="dim">Open this studio's private stats in this browser. The link works once.</p>
|
|
122
|
-
<form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}"><button type="submit"
|
|
135
|
+
<form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${nextOf(url).startsWith('/_studio/codex/') ? 'Open the codex' : 'Open the stats'}</button></form>`);
|
|
123
136
|
}
|
|
124
137
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
|
|
125
138
|
const session = await spendSignin(env, key);
|
|
126
139
|
if (!session) return page('Link used or expired', `<h1>That link has been used or has expired</h1><p class="note">A sign-in link works once, for 30 minutes. Ask your AI for a new one: <code>npx --no-install homie-studio stats link</code>.</p>`, 403);
|
|
127
|
-
return new Response(null, { status: 303, headers: { location:
|
|
140
|
+
return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
|
|
128
141
|
}
|
|
129
142
|
if (path === '/_studio/signout') {
|
|
130
143
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
|
|
@@ -140,5 +153,24 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
|
|
|
140
153
|
}
|
|
141
154
|
return page(`Stats · ${cat.studio?.name ?? 'Studio'}`, statsBody(cat, stats, url));
|
|
142
155
|
}
|
|
156
|
+
// A game's Game Codex (lib/codex.mjs, built into site/dist/_studio/codex/<id>/): the owner's only, never listed.
|
|
157
|
+
const codex = /^\/_studio\/codex\/([a-z0-9][a-z0-9-]{0,39})(\/|\/index\.html)?$/.exec(path);
|
|
158
|
+
if (codex) {
|
|
159
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return page('Not allowed', '<h1>Not allowed</h1>', 405);
|
|
160
|
+
if (!codex[2]) return new Response(null, { status: 301, headers: { location: `/_studio/codex/${codex[1]}/`, 'cache-control': 'no-store' } });
|
|
161
|
+
if ((await ownerAllowed(request, env, { kinds: ['session'] })) !== 'session') {
|
|
162
|
+
return page('Codex · private', `<h1>${esc(cat.studio?.name ?? 'Studio')}: Game Codex</h1>
|
|
163
|
+
<p class="dim">A game's codex is for the studio's owner only.</p>
|
|
164
|
+
<p class="note">To open it, the owner asks their AI in the studio's folder to run <code>npx --no-install homie-studio codex link ${esc(codex[1])}</code>. That uses the studio's own Cloudflare login, and gives a one-time link for this browser.</p>`, 401);
|
|
165
|
+
}
|
|
166
|
+
const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`${url.origin}/_studio/codex/${codex[1]}/index.html`)) : null;
|
|
167
|
+
if (!res || !res.ok) return page('No codex yet', `<h1>No codex for this game yet</h1><p class="note">Its plan is <code>games/${esc(codex[1])}/CODEX.md</code> in the studio; the next <code>npm run deploy</code> puts it here.</p>`, 404);
|
|
168
|
+
return new Response(request.method === 'HEAD' ? null : await res.text(), {
|
|
169
|
+
headers: {
|
|
170
|
+
...PRIVATE_HEADERS,
|
|
171
|
+
'content-security-policy': `default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data:; script-src '${await codexScriptHash()}'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
143
175
|
return page('Not found', '<h1>Not found</h1>', 404);
|
|
144
176
|
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.11.0';
|