@homie-rocks/studio 0.5.0 → 0.7.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Markdown for a studio's posts, rendered once at build time into HTML the site serves as it is.
3
+ *
4
+ * A small, safe subset (site/SITE.md lists it): headings, paragraphs, **bold**, *italic*, ~~struck~~,
5
+ * `code`, fenced code blocks, links, images, lists, quotes and rules. Everything else is text: raw HTML in
6
+ * a post is escaped, never passed through, and a link or image goes only to an https:// address or a path
7
+ * on the studio's own site (`/…`), so a post can never run a script or point at `javascript:`.
8
+ */
9
+
10
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
11
+ export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
12
+
13
+ /** A link or image address a post may use: https://…, or a path on this site. Anything else is null. */
14
+ export function safeUrl(value, { images = false } = {}) {
15
+ const url = String(value ?? '').trim();
16
+ if (!url || /[\s<>"'`\\]/.test(url)) return null;
17
+ if (url.startsWith('/') && !url.startsWith('//')) return url;
18
+ if (!images && /^#[A-Za-z0-9_-]{1,64}$/.test(url)) return url;
19
+ try {
20
+ const u = new URL(url);
21
+ return u.protocol === 'https:' ? u.href : null;
22
+ } catch { return null; }
23
+ }
24
+
25
+ const HOLD = /\u0000(\d+)\u0000/g;
26
+ const unescapeHtml = (v) => v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
27
+
28
+ function emphasis(s) {
29
+ return s
30
+ .replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (m, a, b) => `<strong>${a ?? b}</strong>`)
31
+ .replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
32
+ .replace(/(^|[^_\w])_([^_\s][^_]*?)_(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
33
+ .replace(/~{2}([^~]+)~{2}/g, '<s>$1</s>');
34
+ }
35
+
36
+ /* Inline: code spans, images and links are held aside as finished HTML (so emphasis never reaches inside an
37
+ address), then emphasis and line breaks, then the held pieces go back. */
38
+ function inline(text) {
39
+ const kept = [];
40
+ const hold = (html) => { kept.push(html); return `\u0000${kept.length - 1}\u0000`; };
41
+ let s = String(text).replace(/\u0000/g, '').replace(/`([^`\n]+)`/g, (_, code) => hold(`<code>${escapeHtml(code)}</code>`));
42
+ s = escapeHtml(s);
43
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/g, (m, alt, src, title) => {
44
+ const url = safeUrl(unescapeHtml(src), { images: true });
45
+ return url ? hold(`<img src="${escapeHtml(url)}" alt="${alt}" loading="lazy" decoding="async"${title ? ` title="${title}"` : ''}>`) : alt;
46
+ });
47
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, href) => {
48
+ const url = safeUrl(unescapeHtml(href));
49
+ if (!url) return label;
50
+ return hold(`<a href="${escapeHtml(url)}"${/^https:/.test(url) ? ' rel="noopener"' : ''}>${emphasis(label)}</a>`);
51
+ });
52
+ s = s.replace(/&lt;(https:\/\/[^\s&]+)&gt;/g, (m, href) => {
53
+ const url = safeUrl(href);
54
+ return url ? hold(`<a href="${escapeHtml(url)}" rel="noopener">${escapeHtml(url.replace(/^https:\/\//, '').replace(/\/$/, ''))}</a>`) : m;
55
+ });
56
+ s = emphasis(s).replace(/ {2,}\n|\\\n/g, '<br>').replace(/\n/g, ' ');
57
+ for (let guard = 0; guard < 8 && HOLD.test(s); guard++) s = s.replace(HOLD, (_, i) => kept[Number(i)]);
58
+ return s;
59
+ }
60
+
61
+ /**
62
+ * Markdown to HTML. Headings shift down one level (a post's page has its title as the one h1), so `#` is h2.
63
+ * Returns the HTML and the plain text (for a feed's summary and the post record's text).
64
+ */
65
+ export function renderMarkdown(source) {
66
+ const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
67
+ const out = [];
68
+ let para = [];
69
+ const flush = () => { if (para.length) { out.push(`<p>${inline(para.join('\n'))}</p>`); para = []; } };
70
+ const LIST = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/;
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ const fence = /^(```|~~~)\s*([A-Za-z0-9_+-]{0,20})\s*$/.exec(line);
74
+ if (fence) {
75
+ flush();
76
+ const body = [];
77
+ for (i += 1; i < lines.length && !lines[i].startsWith(fence[1]); i++) body.push(lines[i]);
78
+ out.push(`<pre><code${fence[2] ? ` data-lang="${escapeHtml(fence[2])}"` : ''}>${escapeHtml(body.join('\n'))}</code></pre>`);
79
+ continue;
80
+ }
81
+ if (!line.trim()) { flush(); continue; }
82
+ const h = /^(#{1,5})\s+(.+?)\s*#*\s*$/.exec(line);
83
+ if (h) { flush(); const n = Math.min(6, h[1].length + 1); out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
84
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
85
+ const img = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$/.exec(line.trim());
86
+ if (img) {
87
+ flush();
88
+ const url = safeUrl(img[2], { images: true });
89
+ if (url) out.push(`<figure><img src="${escapeHtml(url)}" alt="${escapeHtml(img[1])}" loading="lazy" decoding="async">${img[3] ? `<figcaption>${inline(img[3])}</figcaption>` : ''}</figure>`);
90
+ continue;
91
+ }
92
+ if (/^\s{0,3}>/.test(line)) {
93
+ flush();
94
+ const quote = [];
95
+ for (; i < lines.length && /^\s{0,3}>/.test(lines[i]); i++) quote.push(lines[i].replace(/^\s{0,3}>\s?/, ''));
96
+ i -= 1;
97
+ out.push(`<blockquote>${renderMarkdown(quote.join('\n')).html}</blockquote>`);
98
+ continue;
99
+ }
100
+ const item = LIST.exec(line);
101
+ if (item) {
102
+ flush();
103
+ const ordered = /\d/.test(item[1]);
104
+ const items = [];
105
+ for (; i < lines.length; i++) {
106
+ const m = LIST.exec(lines[i]);
107
+ if (m && /\d/.test(m[1]) === ordered) items.push([m[2]]);
108
+ else if (items.length && /^\s{2,}\S/.test(lines[i])) items[items.length - 1].push(lines[i].trim());
109
+ else break;
110
+ }
111
+ i -= 1;
112
+ out.push(`<${ordered ? 'ol' : 'ul'}>${items.map((parts) => `<li>${inline(parts.join('\n'))}</li>`).join('')}</${ordered ? 'ol' : 'ul'}>`);
113
+ continue;
114
+ }
115
+ para.push(line);
116
+ }
117
+ flush();
118
+ const html = out.join('\n');
119
+ const text = unescapeHtml(html.replace(/<\/?(?:p|h\d|li|ul|ol|blockquote|pre|figure|figcaption|hr|br)\b[^>]*>/g, ' ').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
120
+ return { html, text };
121
+ }
package/lib/media.mjs CHANGED
@@ -111,7 +111,7 @@ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
111
111
  }
112
112
 
113
113
  /** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
114
- export function recordUpload(root, rel, key, bytes, siteUrl) {
114
+ export function recordUpload(root, rel, key, bytes) {
115
115
  const kind = rel.startsWith('videos/') ? 'videos' : 'music';
116
116
  const m = readManifest(root, kind);
117
117
  let hit = false;
@@ -120,7 +120,7 @@ export function recordUpload(root, rel, key, bytes, siteUrl) {
120
120
  if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
121
121
  }
122
122
  }
123
- if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: siteUrl ? `${siteUrl}/media/${key}` : null, at: new Date().toISOString() }];
123
+ if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
124
124
  writeManifest(root, kind, m);
125
125
  return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
126
126
  }
package/lib/scaffold.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  * games/<id>/ one folder per game (game.json, index.html, src/)
10
10
  * music/ videos/ manifests in the repo; the big files go to the studio's own storage
11
11
  * (R2) once it is added with `homie-studio storage add`
12
- * posts/ words the studio publishes
13
- * site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
12
+ * posts/ the studio's news and drops (markdown; the site's Posts, with feeds)
13
+ * site/ the studio's Worker (pages, rooms), its look (theme.json), its D1 migrations (no R2
14
14
  * binding until the studio adds storage: a free Cloudflare account
15
15
  * without a payment method cannot use R2, and a new studio needs none)
16
16
  * .claude/skills/ skills only this studio uses
@@ -19,10 +19,12 @@
19
19
  * lists every file it writes, so the person sees exactly what changed.
20
20
  */
21
21
  import { execFileSync } from 'node:child_process';
22
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
23
23
  import { homedir } from 'node:os';
24
24
  import { dirname, join, relative, resolve } from 'node:path';
25
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
+ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
+ import { themeFile } from './site.mjs';
26
28
 
27
29
  export const COMPAT_DATE = '2026-06-01';
28
30
  export const WRANGLER_VERSION = '4.126.0';
@@ -72,9 +74,9 @@ directory lists its games; homie.rocks does not host them.
72
74
  | --- | --- |
73
75
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
74
76
  | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
75
- | \`posts/\` | Markdown the studio publishes. |
76
- | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
77
- | \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
77
+ | \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
78
+ | \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
79
+ | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
78
80
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
79
81
 
80
82
  ## Commands (all through the pinned CLI in node_modules)
@@ -107,6 +109,31 @@ studio's pinned copy, never a registry lookup of the bare name.
107
109
  is a separate step the person agrees to; nothing else needs it.
108
110
  - \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
109
111
  (or call the Homie MCP tool \`studio_publish\`).
112
+ - \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
113
+ rooms, the most people playing at once, rounds, songs played, videos watched, and where visitors came
114
+ from (homie.rocks, other studios, search, the web). \`stats link\` gives the owner a one-time link to the
115
+ private page \`/_studio/stats\` in their own browser; \`stats key\` a short read key for the Homie MCP tool
116
+ \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
117
+ (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
118
+ nothing sent anywhere; house QA and \`check\` runs are not counted.
119
+
120
+ ## The site
121
+
122
+ \`npm run build\` makes the studio's site from what is in this folder
123
+ (\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
124
+
125
+ - **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
126
+ Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
127
+ page is not found.
128
+ - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
129
+ (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
130
+ with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
131
+ live rooms, how to play, credits, and "Make a game like this". Give it words in game.json's \`landing\` block
132
+ (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
133
+ - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
134
+ page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
135
+ \`site/theme.css\` (\`site/README.md\`).
136
+ - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
110
137
 
111
138
  ## Making games
112
139
 
@@ -125,6 +152,10 @@ studio's pinned copy, never a registry lookup of the bare name.
125
152
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
126
153
  keychain. Never write a key, token or password into this repository.
127
154
  - Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
155
+ - The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
156
+ \`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
157
+ studio.json as \`cloudflare.domain\`.
158
+ - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
128
159
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
129
160
 
130
161
  ## Beta
@@ -169,7 +200,49 @@ CREATE TABLE IF NOT EXISTS rounds (
169
200
  CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
170
201
  `;
171
202
 
203
+ const POSTS_README = `# Posts
204
+
205
+ The studio's news and drops. One markdown file each; the site shows them at \`/posts/\` (newest first), on the home
206
+ page, and in two feeds: \`/posts/feed.xml\` (Atom) and \`/posts/feed.json\` (JSON Feed).
207
+
208
+ The file's name is its address: \`posts/2026-09-30-we-are-live.md\` is \`/posts/we-are-live/\`, dated by its prefix.
209
+
210
+ \`\`\`markdown
211
+ ---
212
+ title: We are live
213
+ date: 2026-09-30
214
+ summary: One line for the cards and the feeds.
215
+ game: crown-thief
216
+ image: /games/crown-thief/cover.jpg
217
+ ---
218
+
219
+ The body, in markdown: **bold**, *italic*, [links](https://homie.rocks/), images, lists, quotes.
220
+ \`\`\`
221
+
222
+ \`game:\`, \`song:\` and \`video:\` link one of this studio's games, songs or videos (by id or slug); the post shows
223
+ it with a Play, Listen or Watch button. \`draft: true\` keeps a post off the site. Raw HTML is shown as text.
224
+ Files README.md, and names starting with \`_\` or \`.\`, are not posts.
225
+ `;
226
+
227
+ const SITE_README = `# site/
228
+
229
+ The studio's site. \`npm run build\` makes every page from the studio (its games, music, videos and posts);
230
+ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site/SITE.md\`.
231
+
232
+ | Put it in | What it does |
233
+ | --- | --- |
234
+ | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
235
+ | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
236
+ | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
237
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`. |
238
+ | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
239
+
240
+ \`src/worker.mjs\`, \`migrations/\` and \`wrangler.jsonc\` are the Worker; \`dist/\` is the build (not committed).
241
+ `;
242
+
172
243
  const GITIGNORE = `node_modules/
244
+ # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
245
+ .studio/
173
246
  site/dist/
174
247
  site/.wrangler/
175
248
  .wrangler/
@@ -178,6 +251,8 @@ site/.wrangler/
178
251
  *.log
179
252
  # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
180
253
  games/*/.port/
254
+ # Screenshots from check and look runs.
255
+ .checks/
181
256
  # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
182
257
  # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
183
258
  music/**/work/
@@ -197,7 +272,12 @@ export function studioFiles({ name, slug, homie }) {
197
272
  name, slug,
198
273
  homie: { studio: STUDIO_VERSION, directory: homie },
199
274
  // r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
200
- cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, url: null, created: [] },
275
+ // `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
276
+ // goes here: it names the Cloudflare account, so deploy keeps it in .studio/local.json (git-ignored).
277
+ cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, domain: null, created: [] },
278
+ // The site counts visits, plays, rooms, rounds and songs for the owner only (`homie-studio stats`); `share`
279
+ // also tells the homie.rocks directory "played this week" (two numbers) for the hub.
280
+ stats: { share: false },
201
281
  };
202
282
  const pkg = {
203
283
  name: `${slug}-studio`,
@@ -218,17 +298,44 @@ export function studioFiles({ name, slug, homie }) {
218
298
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
219
299
  'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
220
300
  'videos/manifest.json': '{ "v": 1, "items": [] }\n',
221
- 'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
301
+ 'posts/README.md': POSTS_README,
302
+ 'site/theme.json': themeFile(slug),
303
+ 'site/README.md': SITE_README,
222
304
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
223
305
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
224
306
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
225
307
  `,
226
308
  'site/migrations/0001_studio.sql': MIGRATION,
309
+ [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
227
310
  'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
228
311
  '.claude/skills/.gitkeep': '',
229
312
  };
230
313
  }
231
314
 
315
+ /**
316
+ * A studio made before 0.6.0 has no stats migration: add it (the template owns site/migrations/), so the next
317
+ * `d1 migrations apply` makes the counters. Returns the file it wrote, or null when it was there.
318
+ */
319
+ export function ensureStatsMigration(root) {
320
+ const file = join(root, 'site', 'migrations', STATS_MIGRATION_FILE);
321
+ if (existsSync(file)) return null;
322
+ mkdirSync(dirname(file), { recursive: true });
323
+ writeFileSync(file, STATS_MIGRATION);
324
+ return `site/migrations/${STATS_MIGRATION_FILE}`;
325
+ }
326
+
327
+ /**
328
+ * A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
329
+ * deploy writes the workers.dev address there. Returns true when it added the line.
330
+ */
331
+ export function ensureLocalIgnored(root) {
332
+ const file = join(root, '.gitignore');
333
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
334
+ if (/^\/?\.studio\/?$/m.test(text)) return false;
335
+ writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This computer's own state: the site's workers.dev address (it names the Cloudflare account).\n.studio/\n`);
336
+ return true;
337
+ }
338
+
232
339
  function insideGit(dir) {
233
340
  try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
234
341
  }