@homie-rocks/studio 0.12.0 → 0.13.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 +86 -7
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +18 -5
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/office.mjs +172 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/setup.mjs +15 -23
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +49 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +197 -40
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/lib/office.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio office [--url <site>] every live room of every game and who is in it, now
|
|
5
|
+
* homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office,
|
|
6
|
+
* or --to /<game>/play: a private game on the owner's phone)
|
|
7
|
+
* homie-studio office key [--hours 1] an office key for the Homie MCP's owner tools (studio_office,
|
|
8
|
+
* room_kick, room_announce, room_close, game_launch_state)
|
|
9
|
+
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
10
|
+
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
11
|
+
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
12
|
+
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
13
|
+
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
14
|
+
* homie-studio office revoke every office key ends; every play ticket and pending ask too
|
|
15
|
+
*
|
|
16
|
+
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
|
|
17
|
+
* SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
|
|
18
|
+
* a game's launch state, remix switch or room size are only ASKED for here: the answer is a one-time sign-in link
|
|
19
|
+
* that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
|
|
20
|
+
*/
|
|
21
|
+
import { readStudio } from './studio.mjs';
|
|
22
|
+
import { runner } from './cloudflare.mjs';
|
|
23
|
+
import { drop, loopback, mint, siteOf } from './stats.mjs';
|
|
24
|
+
|
|
25
|
+
const HOUR = 3600_000;
|
|
26
|
+
const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
27
|
+
|
|
28
|
+
/** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
|
|
29
|
+
async function withKey(root, url, fn) {
|
|
30
|
+
const { studio, site } = siteOf(root, url);
|
|
31
|
+
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
32
|
+
const local = loopback(site);
|
|
33
|
+
const k = mint(root, studio, 'office', 10 * 60_000, { local });
|
|
34
|
+
if (!k.ok) return { ok: false, why: /no such table: stats_keys/.test(k.why) ? k.why : k.why };
|
|
35
|
+
try {
|
|
36
|
+
const call = async (path, body = null) => {
|
|
37
|
+
const res = await fetch(`${site}${path}`, {
|
|
38
|
+
method: body ? 'POST' : 'GET',
|
|
39
|
+
headers: { authorization: `Bearer ${k.key}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
40
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(20_000),
|
|
41
|
+
});
|
|
42
|
+
const out = await res.json().catch(() => ({ ok: false, message: `${site}${path} answered ${res.status}` }));
|
|
43
|
+
return { status: res.status, ...out };
|
|
44
|
+
};
|
|
45
|
+
return await fn(call, { studio, site, local });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { ok: false, why: `could not reach ${site}: ${error.message}` };
|
|
48
|
+
} finally { drop(root, studio, k.hash, { local }); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A one-time sign-in link for the owner's browser that lands on `to` (30 minutes). */
|
|
52
|
+
function signinTo(root, url, to) {
|
|
53
|
+
const { studio, site } = siteOf(root, url);
|
|
54
|
+
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
55
|
+
const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
|
|
56
|
+
if (!k.ok) return k;
|
|
57
|
+
return { ok: true, site, link: `${site}/_studio/signin?k=${k.key}${to && to !== '/_studio/stats' ? `&to=${encodeURIComponent(to)}` : ''}`, expiresAt: k.expiresAt };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const askedFor = (root, url, r, command) => {
|
|
61
|
+
if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
|
|
62
|
+
if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
|
|
63
|
+
const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
|
|
64
|
+
return {
|
|
65
|
+
ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
|
|
66
|
+
link: link.ok ? link.link : r.ask.confirm,
|
|
67
|
+
use: `Waiting for the owner: ${r.ask.what} Give the owner this link to open in their own browser: it signs that browser in (once, within 30 minutes) and shows the ask with one button. It lasts 15 minutes; nothing happens unless the owner taps.`,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function officeShow(root, { url } = {}) {
|
|
72
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/office'));
|
|
73
|
+
if (!r.ok) return { ok: false, command: 'office', why: r.why ?? r.message ?? 'the office did not answer' };
|
|
74
|
+
return { command: 'office', ...r, ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function officeLink(root, { url, to } = {}) {
|
|
78
|
+
const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
|
|
79
|
+
const r = signinTo(root, url, target);
|
|
80
|
+
if (!r.ok) return { ok: false, command: 'office link', why: r.why };
|
|
81
|
+
return {
|
|
82
|
+
...r, command: 'office link', to: target,
|
|
83
|
+
use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in as the owner for 30 days (the office, the stats, and the owner\'s tools inside their own games). It is theirs alone; do not post it anywhere.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function officeKey(root, { url, hours } = {}) {
|
|
88
|
+
const { studio, site } = siteOf(root, url);
|
|
89
|
+
if (!site) return { ok: false, command: 'office key', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
90
|
+
const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
|
|
91
|
+
const k = mint(root, studio, 'office', h * HOUR, { local: loopback(site) });
|
|
92
|
+
if (!k.ok) return { ok: false, command: 'office key', why: k.why };
|
|
93
|
+
return {
|
|
94
|
+
ok: true, command: 'office key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
|
|
95
|
+
use: `Pass { "site": "${site}", "key": "<this key>" } to the Homie MCP's owner tools (studio_office, room_announce, room_kick, room_close, game_launch_state). With it the AI sees the live rooms, announces and makes invites; a kick, a closed room or a launch state change is only asked for, and the owner confirms it with one tap. It also reads the stats (studio_stats). It ends at ${k.expiresAt}; \`homie-studio office revoke\` ends it sooner. Never paste it anywhere else.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function officeAnnounce(root, text, { url, game, room, seconds } = {}) {
|
|
100
|
+
if (!String(text ?? '').trim()) return { ok: false, command: 'office announce', why: 'usage: homie-studio office announce "<text>" [--game <id>] [--room <code>]' };
|
|
101
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/announce', { text, ...(game ? { game } : {}), ...(room ? { room } : {}), ...(seconds ? { seconds: Number(seconds) } : {}) }));
|
|
102
|
+
return r.ok ? { ok: true, command: 'office announce', ...r, message: `Announced to ${r.people ?? 0} people in ${r.rooms ?? 0} room(s).` } : { ok: false, command: 'office announce', why: r.message ?? r.why };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function officeInvite(root, game, { url, label, uses, count, days } = {}) {
|
|
106
|
+
if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1]' };
|
|
107
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/invites', {
|
|
108
|
+
game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}),
|
|
109
|
+
}));
|
|
110
|
+
if (!r.ok) return { ok: false, command: 'office invite', why: r.message ?? r.why };
|
|
111
|
+
return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
|
|
115
|
+
if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
|
|
116
|
+
return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]' };
|
|
117
|
+
}
|
|
118
|
+
const body = { game, ...(state ? { launch: state } : {}), ...(remixable !== undefined ? { remix: remixable === 'on' || remixable === true } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
|
|
119
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
|
|
120
|
+
return askedFor(root, url, r, 'office launch');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function officeKick(root, game, room, who, { url, minutes } = {}) {
|
|
124
|
+
if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
|
|
125
|
+
const r = await withKey(root, url, async (call) => {
|
|
126
|
+
const office = await call('/_studio/api/office');
|
|
127
|
+
const g = (office.games ?? []).find((x) => x.id === game);
|
|
128
|
+
const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
|
|
129
|
+
if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
130
|
+
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
131
|
+
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
132
|
+
if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
133
|
+
return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
|
|
134
|
+
});
|
|
135
|
+
return askedFor(root, url, r, 'office kick');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
|
|
139
|
+
if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
|
|
140
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
|
|
141
|
+
return askedFor(root, url, r, 'office close');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every office key ends, and the office secret is made anew: every play ticket and every signed control ends with it. */
|
|
145
|
+
export function officeRevoke(root, { url } = {}) {
|
|
146
|
+
const { studio, site } = siteOf(root, url);
|
|
147
|
+
const local = site ? loopback(site) : false;
|
|
148
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
149
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "DELETE FROM stats_keys WHERE kind = 'office'; DELETE FROM meta WHERE key = 'office_key'; UPDATE office_asks SET state = 'expired' WHERE state = 'pending';"]);
|
|
150
|
+
return res.code === 0
|
|
151
|
+
? { ok: true, command: 'office revoke', site, message: 'Every office key has ended, and every play ticket and pending ask with it (a browser let into a private or invite-only game gets a new ticket the next time it opens the game). Invite passes and the owner\'s signed-in browsers stay; `homie-studio stats revoke` ends those sessions.' }
|
|
152
|
+
: { ok: false, command: 'office revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One line per room for a person (the CLI's print). */
|
|
156
|
+
export function officeLines(r) {
|
|
157
|
+
const lines = [`${r.studio}: ${r.playing ? `${r.playing} playing now` : 'nobody playing right now'} (${r.site})`];
|
|
158
|
+
const now = r.now ?? Date.now();
|
|
159
|
+
const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
|
|
160
|
+
for (const g of r.games ?? []) {
|
|
161
|
+
lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, ${g.remix ? 'remixable' : 'source closed'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
|
|
162
|
+
for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
|
|
163
|
+
if (!g.rooms.length) lines.push(' no live rooms');
|
|
164
|
+
for (const rm of g.rooms) {
|
|
165
|
+
const round = rm.round ? `round ${rm.round.n} ${rm.round.phase}` : 'no round';
|
|
166
|
+
lines.push(` ${rm.label}${rm.public ? '' : ' (named)'}: ${rm.players}/${rm.max} players, ${rm.bots} bots, ${round}, up ${rm.openedAt ? mins(now - rm.openedAt) : '?'}${rm.closedUntil ? `, CLOSED for ${mins(rm.closedUntil - now)}` : ''}`);
|
|
167
|
+
for (const c of rm.clients) lines.push(` ${c.seat !== null ? `seat ${c.seat + 1}` : 'watching'}: ${c.name} · ${c.device}${c.role === 'host' ? ' · host' : ''} · ${c.as}${c.muted ? ' · muted' : ''} · here ${mins(now - c.joinedAt)}`);
|
|
168
|
+
for (const b of rm.bans ?? []) lines.push(` kicked: ${b.name} (back in ${mins(b.until - now)})`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
package/lib/progress.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
|
|
|
57
57
|
import { extname, join } from 'node:path';
|
|
58
58
|
import { refreshCodexFile } from './codex.mjs';
|
|
59
59
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
60
|
+
import { request, whyRefused } from './net.mjs';
|
|
60
61
|
|
|
61
62
|
export const PROGRESS_KIND = 'homie-studio-progress';
|
|
62
63
|
export const PROGRESS_DIR = join('.studio', 'progress');
|
|
@@ -89,19 +90,10 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
|
|
|
89
90
|
const currentPath = (root) => join(dir(root), 'current');
|
|
90
91
|
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
* A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
|
|
94
|
-
* in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
|
|
95
|
-
* `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
|
|
96
|
-
* instead of "did not answer"; the build itself goes on with its local feed.
|
|
97
|
-
*/
|
|
93
|
+
/** Kept for callers of 0.11.0: whether an answer is the proxy refusing the host (lib/net.mjs says the rest). */
|
|
98
94
|
export function networkWhy(res, at) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
try { host = new URL(at).host; } catch { /* as given */ }
|
|
102
|
-
return `this Claude Code cloud environment's network does not reach ${host}. In claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"), and start a new session; until then the build goes on with its local feed only`;
|
|
103
|
-
}
|
|
104
|
-
return null;
|
|
95
|
+
const r = whyRefused(res, null, '', at);
|
|
96
|
+
return r.needs === 'network' ? r.why : null;
|
|
105
97
|
}
|
|
106
98
|
|
|
107
99
|
/** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
|
|
@@ -154,23 +146,24 @@ async function pushDoc(root, id) {
|
|
|
154
146
|
let key = null;
|
|
155
147
|
try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
|
|
156
148
|
const { shared, ...body } = doc;
|
|
157
|
-
const
|
|
149
|
+
const sent = await request(`${shared.directory}/api/studio/progress/${shared.build}`, {
|
|
158
150
|
method: 'PUT',
|
|
159
151
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
160
152
|
body: JSON.stringify({ ...body, build: shared.build }),
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
mutateDoc(root, id, (d) => { d.shared = { ...d.shared,
|
|
153
|
+
}, { timeout: 8000 });
|
|
154
|
+
if (!sent.ok) {
|
|
155
|
+
if (sent.status === 404 || sent.status === 410 || sent.needs === 'network') {
|
|
156
|
+
// The shared copy ended (24 hours, or the directory dropped it), or the proxy refuses the directory: keep the
|
|
157
|
+
// local feed, stop sending, and say why in its log.
|
|
158
|
+
mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app's card stopped following this build: ${sent.why}`); });
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Anything else (a slow answer, a refused write): the next write tries again; the log keeps what happened once.
|
|
162
|
+
mutateDoc(root, id, (d) => { if (d.shared && d.shared.lastError !== sent.why) { d.shared = { ...d.shared, lastError: sent.why }; pushLog(d, `Sending to the card failed: ${sent.why}`); } });
|
|
171
163
|
return null;
|
|
172
164
|
}
|
|
173
|
-
const reply =
|
|
165
|
+
const reply = sent.body;
|
|
166
|
+
if (readFeed(root, id)?.shared?.lastError) mutateDoc(root, id, (d) => { delete d.shared.lastError; });
|
|
174
167
|
if (reply?.stop) {
|
|
175
168
|
mutateDoc(root, id, (d) => {
|
|
176
169
|
if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
|
|
@@ -468,36 +461,38 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
|
|
|
468
461
|
writeAtomic(currentPath(root), `${build}\n`);
|
|
469
462
|
let shared = null;
|
|
470
463
|
let why = null;
|
|
464
|
+
let failure = {};
|
|
471
465
|
if (share) {
|
|
472
466
|
const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
473
467
|
if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
|
|
474
468
|
else {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
469
|
+
// A build the chat opened is attached (once); otherwise a new one is opened here.
|
|
470
|
+
const sent = await request(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
|
|
471
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
472
|
+
body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }),
|
|
473
|
+
});
|
|
474
|
+
const body = sent.body;
|
|
475
|
+
if (sent.ok && REMOTE_ID.test(String(body?.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body?.key))) {
|
|
476
|
+
// The chat named the build: its title is the one the person saw.
|
|
477
|
+
if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
|
|
478
|
+
writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
|
|
479
|
+
try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
|
|
480
|
+
shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
|
|
481
|
+
mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
|
|
482
|
+
schedule(root, build, { soon: true });
|
|
483
|
+
heartbeat(root, build, true);
|
|
484
|
+
} else {
|
|
485
|
+
// As it is: the directory's own answer (its status and message) or the connection's error, never a guess.
|
|
486
|
+
why = sent.ok ? `${at} answered without a build and key` : sent.why;
|
|
487
|
+
failure = { ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) };
|
|
488
|
+
}
|
|
494
489
|
}
|
|
495
490
|
}
|
|
496
491
|
if (attach && !shared) {
|
|
497
492
|
// Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
|
|
498
493
|
rmSync(docPath(root, build), { force: true });
|
|
499
494
|
rmSync(currentPath(root), { force: true });
|
|
500
|
-
return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
|
|
495
|
+
return { ok: false, command: 'progress attach', build: attach, ...failure, why: `could not attach to ${attach}: ${why}` };
|
|
501
496
|
}
|
|
502
497
|
return {
|
|
503
498
|
ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
|
package/lib/repo.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH GITHUB REPOSITORY THIS STUDIO IS. The Claude app's hand-off opens Claude Code on it, so it must be the
|
|
3
|
+
* studio's own: never the engine and template repository (homie-rocks/homie) that Cloudflare's "Deploy to
|
|
4
|
+
* Cloudflare" copied the studio from, and never a guess.
|
|
5
|
+
*
|
|
6
|
+
* In order:
|
|
7
|
+
* 1. HOMIE_REPO, a build variable naming it;
|
|
8
|
+
* 2. studio.json `github` (`setup attach` writes it, and the person may);
|
|
9
|
+
* 3. the checkout's git remotes, origin first. A Workers Builds checkout, a Claude Code cloud session (its remote
|
|
10
|
+
* goes through the session's own git proxy) and a person's clone all carry one. Only owner/name is kept,
|
|
11
|
+
* never a token in the URL.
|
|
12
|
+
*
|
|
13
|
+
* A deploy hands it to the live Worker as a variable (HOMIE_REPO), never into a public file. The Worker tells its
|
|
14
|
+
* directory alongside its claim, so the setup card can open the right repository.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { readStudio } from './studio.mjs';
|
|
18
|
+
|
|
19
|
+
export const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
|
|
20
|
+
/** The engine and template repository: a studio is never this. */
|
|
21
|
+
export const ENGINE_REPO = 'homie-rocks/homie';
|
|
22
|
+
|
|
23
|
+
/** owner/name in a git remote URL (https, ssh, a proxy's /git/owner/name path), without any credentials; or null. */
|
|
24
|
+
export function repoFromUrl(url) {
|
|
25
|
+
const s = String(url ?? '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
26
|
+
if (!s) return null;
|
|
27
|
+
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(s);
|
|
28
|
+
return m ? studioRepo(`${m[1]}/${m[2]}`) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A studio repository slug, or null (not a slug, or the engine repository). */
|
|
32
|
+
export function studioRepo(value) {
|
|
33
|
+
const r = String(value ?? '').trim();
|
|
34
|
+
return REPO.test(r) && r.toLowerCase() !== ENGINE_REPO ? r : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This studio's repository: HOMIE_REPO, studio.json `github`, else its git remotes (origin first). */
|
|
38
|
+
export function repoOf(root, { env = process.env } = {}) {
|
|
39
|
+
const named = studioRepo(env.HOMIE_REPO);
|
|
40
|
+
if (named) return named;
|
|
41
|
+
try { const s = studioRepo(readStudio(root).github); if (s) return s; } catch { /* no studio.json */ }
|
|
42
|
+
const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
43
|
+
const origin = git(['remote', 'get-url', 'origin']);
|
|
44
|
+
if (origin.status === 0) { const r = repoFromUrl(origin.stdout); if (r) return r; }
|
|
45
|
+
const all = git(['config', '--get-regexp', '^remote\\..*\\.url$']);
|
|
46
|
+
for (const line of (all.status === 0 ? all.stdout : '').split('\n')) {
|
|
47
|
+
const r = repoFromUrl(line.split(/\s+/)[1]);
|
|
48
|
+
if (r) return r;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
25
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
26
|
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
27
27
|
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
28
|
+
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
28
29
|
import { themeFile } from './site.mjs';
|
|
29
30
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
30
31
|
|
|
@@ -222,6 +223,28 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
222
223
|
Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
|
|
223
224
|
block anything.
|
|
224
225
|
|
|
226
|
+
## Running live games (the back office)
|
|
227
|
+
|
|
228
|
+
- The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
|
|
229
|
+
the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
|
|
230
|
+
sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
|
|
231
|
+
the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
|
|
232
|
+
room; and per game: launch state, Remixable, players per room and invites.
|
|
233
|
+
- **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
|
|
234
|
+
in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
|
|
235
|
+
as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
|
|
236
|
+
the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
|
|
237
|
+
from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
|
|
238
|
+
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
239
|
+
Mute, Kick; Announce). Nobody else's page has it.
|
|
240
|
+
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
241
|
+
\`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
242
|
+
one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
|
|
243
|
+
owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
|
|
244
|
+
\`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
|
|
245
|
+
- A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
|
|
246
|
+
player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
|
|
247
|
+
|
|
225
248
|
## Rules
|
|
226
249
|
|
|
227
250
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
@@ -397,6 +420,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
397
420
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
398
421
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
399
422
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
423
|
+
[`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
|
|
400
424
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
401
425
|
'.claude/skills/.gitkeep': '',
|
|
402
426
|
};
|
|
@@ -466,6 +490,18 @@ export function ensureStatsMigration(root) {
|
|
|
466
490
|
return `site/migrations/${STATS_MIGRATION_FILE}`;
|
|
467
491
|
}
|
|
468
492
|
|
|
493
|
+
/**
|
|
494
|
+
* A studio made before 0.13.0 has no back-office migration (worker/office.mjs): add it, so the next
|
|
495
|
+
* `d1 migrations apply` makes its tables. Returns the file it wrote, or null when it was there.
|
|
496
|
+
*/
|
|
497
|
+
export function ensureOfficeMigration(root) {
|
|
498
|
+
const file = join(root, 'site', 'migrations', OFFICE_MIGRATION_FILE);
|
|
499
|
+
if (existsSync(file)) return null;
|
|
500
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
501
|
+
writeFileSync(file, OFFICE_MIGRATION);
|
|
502
|
+
return `site/migrations/${OFFICE_MIGRATION_FILE}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
469
505
|
/**
|
|
470
506
|
* A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
|
|
471
507
|
* so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
|
|
@@ -480,7 +516,12 @@ export function ensurePlayersMigration(root) {
|
|
|
480
516
|
|
|
481
517
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
482
518
|
export function ensureMigrations(root) {
|
|
483
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
|
|
519
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
523
|
+
export function migrationWord(file) {
|
|
524
|
+
return /players/.test(file) ? 'player accounts and cloud saves' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
484
525
|
}
|
|
485
526
|
|
|
486
527
|
/**
|
package/lib/setup.mjs
CHANGED
|
@@ -13,43 +13,32 @@
|
|
|
13
13
|
* A setup id is single use per repository: a second attach from the same repository answers the same; another
|
|
14
14
|
* repository is refused.
|
|
15
15
|
*/
|
|
16
|
-
import { spawnSync } from 'node:child_process';
|
|
17
16
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
17
|
import { join } from 'node:path';
|
|
19
18
|
import { configNames } from './cloudflare.mjs';
|
|
20
|
-
import {
|
|
19
|
+
import { request } from './net.mjs';
|
|
20
|
+
import { ENGINE_REPO, repoOf } from './repo.mjs';
|
|
21
21
|
import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
|
|
22
22
|
import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
|
|
23
23
|
import { STUDIO_VERSION } from './version.mjs';
|
|
24
24
|
|
|
25
25
|
const SETUP_ID = /^hs_[a-f0-9]{32}$/;
|
|
26
26
|
|
|
27
|
-
/** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
|
|
28
|
-
export function repoOf(root) {
|
|
29
|
-
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
30
|
-
if (r.status !== 0) return null;
|
|
31
|
-
const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
32
|
-
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
|
|
33
|
-
return m ? `${m[1]}/${m[2]}` : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
export async function setupAttach(root, id, { homie } = {}) {
|
|
37
28
|
if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
|
|
38
29
|
const studio = readStudio(root);
|
|
39
30
|
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
40
31
|
const repo = repoOf(root);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
|
|
52
|
-
if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
|
|
32
|
+
if (!repo) {
|
|
33
|
+
return { ok: false, command: 'setup attach', needs: 'repository', why: `this checkout names no GitHub repository of its own (its git remote is missing, or is ${ENGINE_REPO}, Homie's engine and template, which a studio never is). Run this in the studio's own repository, the one Cloudflare's Deploy to Cloudflare made, or name it in studio.json as "github": "<owner>/<name>"` };
|
|
34
|
+
}
|
|
35
|
+
const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
|
|
36
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
|
|
37
|
+
body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
|
|
38
|
+
});
|
|
39
|
+
// What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
|
|
40
|
+
if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
|
|
41
|
+
const body = sent.body;
|
|
53
42
|
|
|
54
43
|
const renamed = [];
|
|
55
44
|
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
|
|
@@ -78,6 +67,9 @@ export async function setupAttach(root, id, { homie } = {}) {
|
|
|
78
67
|
const band = join(root, 'site', 'partials', 'home.html');
|
|
79
68
|
if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
|
|
80
69
|
}
|
|
70
|
+
// The repository goes into studio.json, so the next deploy (and Workers Builds) tells the live Worker which it is.
|
|
71
|
+
const now = readStudio(root);
|
|
72
|
+
if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
|
|
81
73
|
if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
|
|
82
74
|
return {
|
|
83
75
|
ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
|
package/lib/stats.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export function mint(root, studio, kind, ttlMs, { local }) {
|
|
|
48
48
|
return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function drop(root, studio, hash, { local }) {
|
|
51
|
+
export function drop(root, studio, hash, { local }) {
|
|
52
52
|
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
53
53
|
w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
|
|
54
54
|
}
|
|
@@ -223,6 +223,55 @@
|
|
|
223
223
|
"## Beta": "9d02638207a26e73"
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
},
|
|
227
|
+
"0.12.1": {
|
|
228
|
+
"files": {
|
|
229
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
230
|
+
"README.md": "684b3cca8001673c",
|
|
231
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
232
|
+
"music/README.md": "b8a828de9311bd28",
|
|
233
|
+
"videos/README.md": "70614388cbb203c7",
|
|
234
|
+
"posts/README.md": "37841aae476bd540",
|
|
235
|
+
"site/README.md": "ca131cd0472464fb",
|
|
236
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
237
|
+
},
|
|
238
|
+
"sections": {
|
|
239
|
+
"AGENTS.md": {
|
|
240
|
+
"(intro)": "214aa46eb3a895d5",
|
|
241
|
+
"## Layout": "f14d641159480e6c",
|
|
242
|
+
"## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
|
|
243
|
+
"## The site": "7d3525ba39b7f262",
|
|
244
|
+
"## Making games": "63069b31c87a2aa2",
|
|
245
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
246
|
+
"## Rules": "969318425c99f5a5",
|
|
247
|
+
"## Beta": "9d02638207a26e73"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
"0.13.0": {
|
|
252
|
+
"files": {
|
|
253
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
254
|
+
"README.md": "684b3cca8001673c",
|
|
255
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
256
|
+
"music/README.md": "b8a828de9311bd28",
|
|
257
|
+
"videos/README.md": "70614388cbb203c7",
|
|
258
|
+
"posts/README.md": "37841aae476bd540",
|
|
259
|
+
"site/README.md": "ca131cd0472464fb",
|
|
260
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
261
|
+
},
|
|
262
|
+
"sections": {
|
|
263
|
+
"AGENTS.md": {
|
|
264
|
+
"(intro)": "214aa46eb3a895d5",
|
|
265
|
+
"## Layout": "f14d641159480e6c",
|
|
266
|
+
"## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
|
|
267
|
+
"## The site": "7d3525ba39b7f262",
|
|
268
|
+
"## Making games": "63069b31c87a2aa2",
|
|
269
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
270
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
271
|
+
"## Rules": "969318425c99f5a5",
|
|
272
|
+
"## Beta": "9d02638207a26e73"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
226
275
|
}
|
|
227
276
|
}
|
|
228
277
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -170,11 +170,14 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
const have = sections(mine);
|
|
172
172
|
const tsecs = sections(want);
|
|
173
|
+
const adding = new Set();
|
|
173
174
|
for (const [k, t] of tsecs.entries()) {
|
|
174
175
|
const at = have.find((s) => s.heading === t.heading);
|
|
175
176
|
if (!at) {
|
|
176
|
-
// After the nearest template section before it that the studio has
|
|
177
|
-
|
|
177
|
+
// After the nearest template section before it that the studio has (or that this plan adds just before it, so
|
|
178
|
+
// two new sections in a row keep the template's order); else before the first one after it.
|
|
179
|
+
const before = tsecs.slice(0, k).reverse().find((p) => have.some((s) => s.heading === p.heading) || adding.has(p.heading));
|
|
180
|
+
adding.add(t.heading);
|
|
178
181
|
const after = before ? null : tsecs.slice(k + 1).find((p) => have.some((s) => s.heading === p.heading));
|
|
179
182
|
changes.push({ file: rel, kind: 'add-section', section: t.heading, what: `new section ${t.heading === INTRO ? 'intro' : `"${t.heading}"`}`, after: before?.heading ?? null, before: after?.heading ?? null, text: t.text });
|
|
180
183
|
continue;
|