@looop-games/cli 0.1.7 → 0.1.9

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.
@@ -1,15 +1,20 @@
1
1
  // The agent surface — skills + the Layer-0 managed block — and how an update
2
2
  // reaches a repo that already exists (creator-harness Slice 2, decision Q2).
3
3
  //
4
- // The surface ships INSIDE the versioned engine artifact, not the npm CLI: the
5
- // CLI's template is only a create-time SEED (the engine is login-gated, so a
6
- // fresh folder can't download one before its first `dev`). From then on the
7
- // artifact is the source of truth. Without this, a creator's skills are frozen
8
- // at the version they created the game with the exact freeze Q2 exists to end.
4
+ // The surface ships INSIDE the versioned engine artifact, and ONLY there the
5
+ // npm CLI carries no copy of it. That is what makes the ownership record below
6
+ // trustworthy: `create` places the surface by calling straight into this module
7
+ // (against the artifact it just installed), so the bytes on disk and the hashes
8
+ // we record come from the same place, and a fresh repo's first `update` is a
9
+ // genuine no-op. The CLI used to seed the surface from its own bundled
10
+ // `template/`, stamped with the ENGINE's version — a record that was false the
11
+ // moment npm and the engine registry shipped out of step, and that made the
12
+ // first `update` overwrite (or DELETE, via the orphan prune below) skills on a
13
+ // repo minutes old.
9
14
  //
10
- // **This runs ONLY under `looop update`** (2026-07-11) — never on `dev`
11
- // or `publish`. It COULD run there safely (it can only ever converge a repo to
12
- // the engine version already pinned in its package.json, never a newer one),
15
+ // **This runs under `looop update` — and under `looop create`** — never on
16
+ // `dev` or `publish`. It COULD run there safely (it can only ever converge a
17
+ // repo to the engine version already pinned in its package.json, never a newer one),
13
18
  // but a creator who ran `dev` and then found files they never wrote sitting in
14
19
  // `git status` would be right to feel we'd gone through their pockets. Updates
15
20
  // are something you ASK for. So this module writes nothing on its own schedule,
@@ -36,7 +41,7 @@
36
41
  // everything outside them (their title, their instructions below the end
37
42
  // marker) is theirs and is spliced back untouched.
38
43
  import { createHash } from 'node:crypto';
39
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
40
45
  import { dirname, join } from 'node:path';
41
46
 
42
47
  export const SURFACE_DIR = 'agent-surface';
@@ -269,26 +274,3 @@ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log
269
274
 
270
275
  return result;
271
276
  }
272
-
273
- // Used by `looop create`: the template seed IS the surface for a brand-new
274
- // repo, so record it as ours immediately. Without this the first `dev` would
275
- // treat a two-minute-old scaffold as an unknown pre-existing repo and adopt it.
276
- export function recordSeededSurface(projectDir, engineVersion) {
277
- const placed = {};
278
- const add = (rel) => {
279
- const abs = join(projectDir, rel);
280
- if (existsSync(abs)) placed[rel] = sha(readFileSync(abs));
281
- };
282
- const walk = (rel) => {
283
- const abs = join(projectDir, rel);
284
- if (!existsSync(abs)) return;
285
- for (const entry of readdirSync(abs)) {
286
- const child = join(rel, entry);
287
- if (statSync(join(projectDir, child)).isDirectory()) walk(child);
288
- else add(child);
289
- }
290
- };
291
- walk(join('.claude', 'skills'));
292
- add('AGENTS.md');
293
- writeLocal(projectDir, engineVersion ?? null, placed);
294
- }
package/lib/create.mjs CHANGED
@@ -2,26 +2,45 @@
2
2
  //
3
3
  // The create-next-app gesture: one command → a complete folder that is its
4
4
  // own repo, where `looop dev` immediately serves a WORKING multiplayer game
5
- // (the platform is not optional — the template joins a room on line one, no
5
+ // (the platform is not optional — the scaffold joins a room on line one, no
6
6
  // "add multiplayer later" tier). The folder name is the slug.
7
7
  //
8
- // The scaffold lives as REAL FILES in ../template/ (creator-harness Slice 1)
9
- // — edit the template there, not string literals here. `create` copies it
10
- // with a {{name}} substitution pass. One rename on copy, an npm-pack
11
- // artifact: `gitignore` `.gitignore` (npm silently strips .gitignore
12
- // files from packages). package.json is generated below instead of shipped
13
- // in the template (nested package.json files confuse npm's packer, and it
14
- // carries the dynamic cliSpec anyway).
8
+ // ── Where the creator's files come from ─────────────────────────────────────
9
+ //
10
+ // EVERYTHING a creator receives comes from the versioned engine artifact: the
11
+ // starter game (`scaffold/`) and the agent surface (`agent-surface/` skills
12
+ // plus the Layer-0 managed block). This npm package ships CODE ONLY.
13
+ //
14
+ // It used to ship a `template/` folder, and that was a bug with a long fuse.
15
+ // npm and the engine registry are two independent, immutable release lanes, so
16
+ // the two copies of the surface drifted the moment one shipped without the
17
+ // other — and `create` seeded from the CLI while stamping the ownership record
18
+ // with the ENGINE's version, so that record was a lie the day it was written.
19
+ // The creator paid for it on their first `looop update`: skills they had never
20
+ // touched changed under them, and a skill the CLI shipped but the engine didn't
21
+ // was silently DELETED (agent-surface.mjs prunes files it owns that the
22
+ // artifact no longer lists). On a repo minutes old.
23
+ //
24
+ // One authored copy, one release lane. A skill or scaffold change now ships
25
+ // with an engine release alone; the CLI is released only when the CLI's own
26
+ // code changes.
27
+ //
28
+ // The consequence, and it is deliberate: `create` needs the engine, and the
29
+ // engine download is login-gated — so CREATE SIGNS YOU IN. That gate existed
30
+ // anyway, one command later, at first `dev`. This moves it to where the creator
31
+ // is already waiting and leaves `dev` instant.
15
32
  import { execFileSync } from 'node:child_process';
16
- import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, symlinkSync } from 'node:fs';
33
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
17
34
  import { createRequire } from 'node:module';
18
35
  import { join, relative, dirname } from 'node:path';
19
36
  import { getApiBase } from './config.mjs';
20
37
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
21
38
  import { runNpm } from './npm.mjs';
22
- import { recordSeededSurface } from './agent-surface.mjs';
39
+ import { ensureEngine } from './engine.mjs';
40
+ import { reconcileAgentSurface } from './agent-surface.mjs';
23
41
 
24
- export const TEMPLATE_DIR = join(import.meta.dirname, '..', 'template');
42
+ // The starter game, inside the installed engine artifact.
43
+ export const SCAFFOLD_DIR = 'scaffold';
25
44
 
26
45
  const NAME_OK = /^[a-z0-9][a-z0-9-]{0,40}$/;
27
46
 
@@ -48,69 +67,37 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
48
67
  // what create-time provisioning exists to prevent.
49
68
  const PLAYWRIGHT_SPEC = '^1.61.1';
50
69
 
51
- // Resolve the engine pin AT CREATE TIME so the initial commit — the
52
- // milestone-1 revert baseline already carries `looop.engine`. Without
53
- // this, first `looop dev` writes the pin AFTER the baseline: the fresh repo
54
- // starts dirty, and a milestone-1 reject (`git reset --hard`) deletes the
55
- // pin, so the next dev re-resolves "latest" and can silently change engine
56
- // versions under a revert. The version LIST endpoint is public (a version
57
- // number is not a secret); the engine BYTES stay login-gated at first dev.
58
- async function resolveEnginePin(apiBase, log) {
59
- try {
60
- const res = await fetch(`${apiBase}/api/creator/engine`);
61
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
62
- const { latest } = await res.json();
63
- if (!latest) throw new Error('no downloadable releases');
64
- return latest;
65
- } catch (err) {
66
- log(`⚠️ Could not resolve the engine version (${err.message}) — the first \`looop dev\` will pin it instead.`);
67
- return null;
70
+ // Copy the starter game out of the artifact. One rename: `gitignore`
71
+ // `.gitignore` (npm pack silently strips .gitignore files from a package, so
72
+ // the artifact stores it dot-less).
73
+ function copyScaffold(engineDir, dir, name, version) {
74
+ const root = join(engineDir, SCAFFOLD_DIR);
75
+ if (!existsSync(root)) {
76
+ // RELEASE ORDER: the engine must ship the scaffold BEFORE a CLI that expects
77
+ // it. If this CLI ever reaches a creator ahead of that engine, `create`
78
+ // resolves the latest engine, finds no starter game, and every create on
79
+ // earth fails so say exactly what is wrong, and never blame the creator's
80
+ // setup for our release ordering.
81
+ throw new Error(
82
+ `Looop engine ${version} ships no starter game — it predates engine-owned scaffolding. ` +
83
+ 'This is a platform-side issue, not something you did: please report it (`npx looop feedback`) or retry shortly.',
84
+ );
68
85
  }
69
- }
70
-
71
- function copyTemplate(dir, name) {
72
- const entries = readdirSync(TEMPLATE_DIR, { withFileTypes: true, recursive: true }).filter((e) => e.isFile());
73
- for (const entry of entries) {
74
- const rel = relative(TEMPLATE_DIR, join(entry.parentPath, entry.name));
86
+ for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) {
87
+ if (!entry.isFile()) continue;
88
+ const rel = relative(root, join(entry.parentPath, entry.name));
75
89
  const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel);
76
90
  mkdirSync(dirname(dest), { recursive: true });
77
- writeFileSync(dest, readFileSync(join(TEMPLATE_DIR, rel), 'utf8').replaceAll('{{name}}', name));
91
+ writeFileSync(dest, readFileSync(join(root, rel), 'utf8').replaceAll('{{name}}', name));
78
92
  }
79
93
  }
80
94
 
81
- export async function create({
82
- name,
83
- cwd = process.cwd(),
84
- install = true,
85
- cliSpec = process.env.LOOOP_CREATE_CLI_SPEC || '^0.1.0',
86
- apiBase = getApiBase(DEFAULT_API_BASE),
87
- log = console.log,
88
- } = {}) {
89
- if (!name || !NAME_OK.test(name)) {
90
- throw new Error(
91
- `"${name ?? ''}" won't work as a game name — use lowercase letters, digits, and dashes (it becomes the URL: play.looop.games/g/<name>).`,
92
- );
93
- }
94
- const dir = join(cwd, name);
95
- if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
96
-
97
- mkdirSync(dir, { recursive: true });
98
- copyTemplate(dir, name);
99
- // Non-Claude agent CLIs (Codex, Gemini/Antigravity, Cursor, opencode)
100
- // discover project skills at .agents/skills/ — alias it to the same files.
101
- // Created here, not shipped in the template: npm pack can't carry symlinks.
102
- try {
103
- mkdirSync(join(dir, '.agents'), { recursive: true });
104
- symlinkSync(join('..', '.claude', 'skills'), join(dir, '.agents', 'skills'), 'dir');
105
- } catch {
106
- // e.g. Windows without symlink rights — Claude Code (and any tool that
107
- // reads .claude/skills/ directly) still works.
108
- }
109
- // The engine is deliberately NOT a dependency (Q4 revision): it installs
110
- // from the platform's login-gated registry on first `looop dev`. The
111
- // VERSION is pinned here at create time (public lookup, warn-fallback) so
112
- // the initial commit already carries it — see resolveEnginePin above.
113
- const enginePin = await resolveEnginePin(apiBase, log);
95
+ async function scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, reconcile }) {
96
+ // Written FIRST, and without the engine pin: npm needs a package.json to
97
+ // install into, and `ensureEngine` writes the pin itself once it knows which
98
+ // version it actually installed. ONE writer for the pin — the old code had
99
+ // `create` resolve it and `ensureEngine` resolve it again, which is precisely
100
+ // how they came to disagree.
114
101
  writeFileSync(
115
102
  join(dir, 'package.json'),
116
103
  JSON.stringify(
@@ -121,28 +108,50 @@ export async function create({
121
108
  scripts: { dev: 'looop dev', publish: 'looop publish', test: 'looop test' },
122
109
  devDependencies: { '@looop-games/cli': cliSpec, playwright: PLAYWRIGHT_SPEC },
123
110
  allowScripts: ALLOW_SCRIPTS,
124
- ...(enginePin ? { looop: { engine: enginePin } } : {}),
125
111
  },
126
112
  null,
127
113
  2,
128
114
  ) + '\n',
129
115
  );
130
116
 
131
- // The template we just copied IS this repo's agent surface, so record it as
132
- // ours now (Slice 2 / Q2). Without this, the first `looop dev` would meet a
133
- // two-minute-old scaffold with no ownership record, treat it as a legacy
134
- // pre-Slice-2 repo, and noisily "adopt" it. From here the artifact drives it.
135
- recordSeededSurface(dir, enginePin);
136
-
137
117
  if (install) {
138
118
  log(`Installing the CLI (npm install in ${name}/)…`);
139
119
  runNpm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'pipe' });
120
+ }
121
+
122
+ // The engine — and with it the starter game and the agent skills. Signs the
123
+ // creator in via the device flow if this machine has no token yet, and writes
124
+ // the `looop.engine` pin.
125
+ const engine = await ensure(dir, { apiBase, log });
126
+
127
+ copyScaffold(engine.dir, dir, name, engine.version);
128
+
129
+ // The agent surface is PLATFORM-owned and reconciled on every `looop update`,
130
+ // so it is placed here by the very code that will later update it — reading
131
+ // the artifact, recording the real hash of what landed. That identity is the
132
+ // whole point: a fresh repo's first `update` is now a genuine no-op, because
133
+ // the bytes on disk and the bytes in the ownership record came from the same
134
+ // artifact.
135
+ reconcile(dir, engine.dir, { log });
136
+
137
+ // Non-Claude agent CLIs (Codex, Gemini/Antigravity, Cursor, opencode)
138
+ // discover project skills at .agents/skills/ — alias it to the same files.
139
+ // After reconcile, so the link is never dangling.
140
+ try {
141
+ mkdirSync(join(dir, '.agents'), { recursive: true });
142
+ symlinkSync(join('..', '.claude', 'skills'), join(dir, '.agents', 'skills'), 'dir');
143
+ } catch {
144
+ // e.g. Windows without symlink rights — Claude Code (and any tool that
145
+ // reads .claude/skills/ directly) still works.
146
+ }
147
+
148
+ if (install) {
140
149
  // Fetch the smoke-test browser NOW (cached machine-wide per version) so
141
150
  // the first `looop test` never stalls on a surprise download mid-build.
142
151
  // Resolved + run via node directly — NEVER a nested `npx`: when create
143
- // itself runs under `npm exec --package=<tarball>` (the stranger
144
- // gesture), the inherited npm_config_* env makes an inner npx try to
145
- // re-install the tarball and cancel (caught live, 2026-07-10).
152
+ // itself runs under `npm exec --package=<tarball>` (the stranger gesture),
153
+ // the inherited npm_config_* env makes an inner npx try to re-install the
154
+ // tarball and cancel (caught live, 2026-07-10).
146
155
  try {
147
156
  log('Fetching the test browser (Chromium — one-time download, cached for every game)…');
148
157
  const req = createRequire(join(dir, 'package.json'));
@@ -152,12 +161,14 @@ export async function create({
152
161
  log('⚠️ Could not fetch Chromium (offline?) — `looop test` will fetch it when first needed.');
153
162
  }
154
163
  }
164
+
155
165
  try {
156
166
  execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'pipe' });
157
- // Commit the scaffold: the initial commit is the first revert baseline
158
- // (rejecting milestone 1 resets to it) and what lets `git worktree add`
159
- // work — on a never-committed repo it silently creates an empty orphan
160
- // lane instead.
167
+ // The initial commit is the first revert baseline (rejecting milestone 1
168
+ // resets to it) and what lets `git worktree add` work — on a never-committed
169
+ // repo it silently creates an empty orphan lane instead. It carries the
170
+ // engine pin AND the true agent surface, so a reset to baseline cannot
171
+ // silently change either.
161
172
  execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
162
173
  const msg = ['commit', '-q', '-m', 'initial scaffold (looop create)'];
163
174
  try {
@@ -175,8 +186,43 @@ export async function create({
175
186
  }
176
187
 
177
188
  log('');
178
- log(`✅ ${name} is ready.`);
179
- log(` cd ${dir} && npx looop dev`);
180
- log(' (first run downloads the Looop engine — it will ask you to sign in)');
181
- return { dir };
189
+ log(`✅ ${name} is ready (Looop engine ${engine.version}).`);
190
+ log(` cd ${name} && npx looop dev`);
191
+ return { dir, engineVersion: engine.version };
192
+ }
193
+
194
+ export async function create({
195
+ name,
196
+ cwd = process.cwd(),
197
+ install = true,
198
+ cliSpec = process.env.LOOOP_CREATE_CLI_SPEC || '^0.1.0',
199
+ apiBase = getApiBase(DEFAULT_API_BASE),
200
+ log = console.log,
201
+ // Seams, mirroring `looop update` — the tests drive a real artifact on disk
202
+ // through the real reconciler, with no network and no account.
203
+ ensure = ensureEngine,
204
+ reconcile = reconcileAgentSurface,
205
+ } = {}) {
206
+ if (!name || !NAME_OK.test(name)) {
207
+ throw new Error(
208
+ `"${name ?? ''}" won't work as a game name — use lowercase letters, digits, and dashes (it becomes the URL: play.looop.games/g/<name>).`,
209
+ );
210
+ }
211
+ const dir = join(cwd, name);
212
+ if (existsSync(dir)) throw new Error(`${dir} already exists — pick another name or remove it first.`);
213
+
214
+ mkdirSync(dir, { recursive: true });
215
+ try {
216
+ return await scaffold({ dir, name, install, cliSpec, apiBase, log, ensure, reconcile });
217
+ } catch (err) {
218
+ // ATOMIC. Everything a creator receives now arrives over the network behind
219
+ // a login, so a failure anywhere leaves a folder with no game, no skills and
220
+ // no initial commit — one that `looop dev` cannot run, and that `create`
221
+ // would refuse to retry into ("already exists"). A folder that looks like a
222
+ // game and isn't one is worse than no folder at all: it fails later,
223
+ // somewhere more confusing. Take it back, and say so.
224
+ rmSync(dir, { recursive: true, force: true });
225
+ log(`\n⚠️ Could not finish creating ${name} — removed the incomplete folder so you can retry cleanly.`);
226
+ throw err;
227
+ }
182
228
  }
package/lib/feedback.mjs CHANGED
@@ -8,18 +8,117 @@
8
8
  // looop.engine pin, and this CLI's version. A delivered file is stamped
9
9
  // `sent:` + `id:` in its frontmatter so it never ships twice; a failed send
10
10
  // leaves the file unstamped and is retried on the next run.
11
- import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
12
- import { basename, join } from 'node:path';
11
+ import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, realpathSync } from 'node:fs';
12
+ import { basename, join, resolve, relative, isAbsolute, sep } from 'node:path';
13
13
  import { findProject } from './project.mjs';
14
14
  import { getToken, getApiBase } from './config.mjs';
15
15
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
16
16
 
17
17
  const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
18
 
19
+ // Attachment caps. The riven report cited game.js:56 and riven-render.js:302 —
20
+ // source files, not screenshots — so attachments are TEXT. Kept well under the
21
+ // intake's own ceiling so an oversized set is caught HERE, with a readable
22
+ // message: Cloudflare replaces a Pages Function's 5xx body with its own HTML
23
+ // page, which would reach the creator as an unreadable wall.
24
+ export const MAX_ATTACHMENTS = 20;
25
+ export const MAX_ATTACHMENT_BYTES = 256 * 1024; // per file
26
+ export const MAX_ATTACHMENTS_BYTES = 1024 * 1024; // all of them together
27
+
28
+ const frontmatter = (text) => /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '';
29
+
19
30
  // A report is "sent" when its frontmatter carries a `sent:` stamp.
20
31
  function isSent(text) {
21
- const fm = /^---\n([\s\S]*?)\n---/.exec(text);
22
- return fm ? /^sent:\s*\S/m.test(fm[1]) : false;
32
+ return /^sent:\s*\S/m.test(frontmatter(text));
33
+ }
34
+
35
+ // `attachments:` in the report's frontmatter, in either YAML shape agents write:
36
+ //
37
+ // attachments: attachments: [game.js, src/render.js]
38
+ // - game.js
39
+ // - src/render.js
40
+ export function parseAttachments(text) {
41
+ const fm = frontmatter(text);
42
+ const inline = /^attachments:\s*\[(.*)\]\s*$/m.exec(fm);
43
+ if (inline) {
44
+ return inline[1]
45
+ .split(',')
46
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
47
+ .filter(Boolean);
48
+ }
49
+ const block = /^attachments:\s*$/m.exec(fm);
50
+ if (!block) return [];
51
+ const out = [];
52
+ for (const line of fm.slice(block.index).split('\n').slice(1)) {
53
+ const item = /^\s*-\s*(.+?)\s*$/.exec(line);
54
+ if (!item) break; // the list ends at the first non-item line
55
+ out.push(item[1].replace(/^['"]|['"]$/g, ''));
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // The agent chooses this list, so it is arbitrary filesystem read access over a
61
+ // network. Every path must land inside the game folder — resolved, not merely
62
+ // inspected, because a symlink's string looks innocent. An escape is a hard
63
+ // error, never a skipped file.
64
+ function readAttachments(projectDir, paths, reportName) {
65
+ if (paths.length > MAX_ATTACHMENTS) {
66
+ throw new Error(
67
+ `${reportName} lists ${paths.length} attachments (max ${MAX_ATTACHMENTS}) — attach only the files the diagnosis rests on.`,
68
+ );
69
+ }
70
+
71
+ const root = realpathSync(projectDir);
72
+ const inside = (candidate) => candidate === root || candidate.startsWith(root + sep);
73
+ const escaped = (p, resolved) => {
74
+ throw new Error(
75
+ `${reportName} attaches "${p}", which resolves outside the game folder (${resolved}). ` +
76
+ `Feedback may only attach files from this game.`,
77
+ );
78
+ };
79
+
80
+ const files = [];
81
+ let total = 0;
82
+
83
+ for (const p of paths) {
84
+ // Containment is checked BEFORE existence, and on the lexical path first:
85
+ // "../../.ssh/id_rsa" is refused for what it asks for, not for whether it
86
+ // happens to be there. Then again after realpath — a symlink's string looks
87
+ // perfectly innocent.
88
+ const full = resolve(root, p);
89
+ if (isAbsolute(p) || !inside(full)) escaped(p, full);
90
+
91
+ let real;
92
+ try {
93
+ real = realpathSync(full);
94
+ } catch {
95
+ throw new Error(`${reportName} attaches "${p}", which does not exist in the game folder.`);
96
+ }
97
+ if (!inside(real)) escaped(p, real);
98
+ if (!statSync(real).isFile()) throw new Error(`${reportName} attaches "${p}", which is not a file.`);
99
+
100
+ const bytes = statSync(real).size;
101
+ if (bytes > MAX_ATTACHMENT_BYTES) {
102
+ throw new Error(
103
+ `${reportName} attaches "${p}" (${Math.round(bytes / 1024)} KiB) — too large, the per-file cap is ${MAX_ATTACHMENT_BYTES / 1024} KiB.`,
104
+ );
105
+ }
106
+ total += bytes;
107
+ if (total > MAX_ATTACHMENTS_BYTES) {
108
+ throw new Error(
109
+ `${reportName}'s attachments are too large together (over ${MAX_ATTACHMENTS_BYTES / 1024} KiB) — attach fewer files.`,
110
+ );
111
+ }
112
+
113
+ const content = readFileSync(real, 'utf8');
114
+ if (content.includes('\0')) {
115
+ throw new Error(`${reportName} attaches "${p}", which is not a text file. Attach source, logs, or config.`);
116
+ }
117
+ // Report the path the creator wrote (project-relative), not the real one —
118
+ // it is what the report's prose cites.
119
+ files.push({ path: relative(root, real).split(sep).join('/'), content });
120
+ }
121
+ return files;
23
122
  }
24
123
 
25
124
  function titleOf(text, path) {
@@ -61,12 +160,16 @@ export async function sendFeedback({
61
160
  const sent = [];
62
161
  for (const path of unsent) {
63
162
  const body = readFileSync(path, 'utf8');
163
+ // Read + guard the attachments BEFORE the request: a bad path must fail the
164
+ // send outright, leaving the report unstamped and retryable.
165
+ const files = readAttachments(project.dir, parseAttachments(body), basename(path));
64
166
  const report = {
65
167
  title: titleOf(body, path),
66
168
  body,
67
169
  slug: project.slug,
68
170
  engine: project.pkg?.looop?.engine,
69
171
  cli: CLI_VERSION,
172
+ ...(files.length ? { files } : {}),
70
173
  };
71
174
  const res = await fetch(`${apiBase}/api/creator/feedback`, {
72
175
  method: 'POST',
@@ -79,8 +182,9 @@ export async function sendFeedback({
79
182
  }
80
183
  const { id } = await res.json();
81
184
  writeFileSync(path, stamp(body, id));
82
- log(`✅ Sent ${basename(path)} ${id}`);
83
- sent.push({ file: path, id });
185
+ const withFiles = files.length ? ` (+${files.length} file${files.length === 1 ? '' : 's'})` : '';
186
+ log(`✅ Sent ${basename(path)}${withFiles} → ${id}`);
187
+ sent.push({ file: path, id, files: files.map((f) => f.path) });
84
188
  }
85
189
  log(`${sent.length} report${sent.length === 1 ? '' : 's'} delivered to the Looop team.`);
86
190
  return { sent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Looop game development CLI — dev server, login, and publishing for standalone Looop games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -10,7 +10,6 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
- "template",
14
13
  "!lib/**/*.test.mjs"
15
14
  ],
16
15
  "engines": {