@looop-games/cli 0.1.7 → 0.1.8

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/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.8",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feedback
3
- description: File feedback to the Looop team when something in Looop ITSELF seems broken or missing — the engine, a shared component, the looop CLI, login, publishing, the platform. Use the moment you or the creator notice "this looks like a Looop bug, not our game" — offer to file it right away, don't wait to be asked.
3
+ description: File feedback to the Looop team when something in Looop ITSELF is broken or missing — the engine, a shared component, the looop CLI, login, publishing, the platform. Use the moment you or the creator think "this looks like a Looop bug, not our game" — AND, just as importantly, whenever you discover your own game breaking a Looop rule, because Looop's own instructions may be what put it there. Offer to file it right away; don't wait to be asked.
4
4
  ---
5
5
 
6
6
  # /feedback — tell Looop when Looop is the problem
@@ -13,6 +13,34 @@ that lied, a missing capability every game would want — **offer to file it**:
13
13
 
14
14
  This game's own bugs are NOT feedback — they're `/todo`s or just fixes.
15
15
 
16
+ ## The defect you will not notice
17
+
18
+ There is one class of Looop bug the reflex above is blind to, and it is the most
19
+ valuable class there is: **a defect Looop's own guidance caused.**
20
+
21
+ It looks like this. You discover your game violating a Looop rule — it's routing
22
+ state the wrong way, using an API it shouldn't. The symptom is in *your* code, so
23
+ the conclusion writes itself: *"our bug, not the engine's."* You fix your game and
24
+ move on, and Looop never hears about it.
25
+
26
+ That inference is locally correct and globally wrong. **Looop's instructions are
27
+ not a fixed point — they are a suspect.** They contradict each other sometimes.
28
+ The mandatory ones (this repo's `AGENTS.md`, injected into every session) can be
29
+ wrong while an opt-in practices doc is right, and you will have followed the
30
+ mandatory one without ever noticing there was a choice.
31
+
32
+ So, the rule:
33
+
34
+ > **When you find your game violating a Looop rule, work out what led you there
35
+ > BEFORE you fix it.**
36
+
37
+ Retrace it honestly. Did a Looop-authored source — `AGENTS.md`, a
38
+ `shared/practices/` doc, a library README, a skill — point you this way? If yes,
39
+ **that is a Looop defect and it gets filed**, even though your game is also wrong.
40
+ Both are true at the same time; fix your game *and* file the report. If you'd have
41
+ made the same mistake with no guidance at all, it's just your bug — fix it and
42
+ move on.
43
+
16
44
  ## What to file
17
45
 
18
46
  Write the report to `notes/feedback/<YYYY-MM-DD-HHMM>-<short-slug>.md`
@@ -26,6 +54,9 @@ detail you leave out is a detail they'll have to guess. Include:
26
54
  created: YYYY-MM-DD
27
55
  engine: <the looop.engine pin from package.json>
28
56
  cli: <@looop-games/cli version from package.json devDependencies>
57
+ attachments:
58
+ - game.js
59
+ - src/net.js
29
60
  ---
30
61
  # <One line: what's wrong>
31
62
 
@@ -40,6 +71,10 @@ custom port, offline, …).
40
71
  The smallest steps/snippet that shows it. Exact commands **with their full
41
72
  output pasted verbatim** — never summarize an error message.
42
73
 
74
+ ## What led us here (if a Looop source did)
75
+ The file and line that pointed you wrong, quoted, and the doc that contradicts
76
+ it. This is the section that gets the guidance itself fixed.
77
+
43
78
  ## Transcript
44
79
  The conversation that surfaced this, quoted verbatim — from the creator's
45
80
  request that first hit the problem through your diagnosis: what they asked,
@@ -54,18 +89,37 @@ Trim game-specific noise, never evidence: cut what's irrelevant to the
54
89
  defect, keep everything that shows it. (The intake caps a report at 256 KiB —
55
90
  if the transcript pushes past that, cut it down to the relevant exchange.)
56
91
 
92
+ ## Attach the files you're talking about
93
+
94
+ A report that cites `game.js:56` is only as good as the reader's ability to see
95
+ line 56. **List the files your diagnosis rests on in the `attachments:`
96
+ frontmatter** — they ship with the report, and the team reads them beside it.
97
+
98
+ This applies to **any** report, whatever its reason: a bug, a missing capability,
99
+ a rule that led you wrong, a module you had to fork. Whatever your evidence is,
100
+ attach it.
101
+
102
+ - **You pick the files** — you wrote the diagnosis, so you know what it rests on.
103
+ - Paths are **relative to the game folder** and must stay inside it. Anything
104
+ pointing outside is refused outright.
105
+ - **Text only** (source, logs, config) — up to 20 files, 256 KiB each, 1 MiB total.
106
+ - Attach what the report *argues from*: the file with the bug, the module you
107
+ worked around, the config that reproduces it. Not the whole game.
108
+
57
109
  ## Send it
58
110
 
59
111
  1. Run **`npx looop feedback`** — it delivers every unsent report under
60
112
  `notes/feedback/` to the Looop team (login required; it's the same account
61
- as publishing) and stamps each delivered file with `sent:` + `id:` in its
62
- frontmatter so it never ships twice.
63
- 2. If the send fails (offline, not logged in), the file stays unstamped —
64
- just run `npx looop feedback` again later; it retries everything unsent.
113
+ as publishing), attachments included, and stamps each delivered file with
114
+ `sent:` + `id:` in its frontmatter so it never ships twice.
115
+ 2. If the send fails (offline, not logged in, a bad attachment path), the file
116
+ stays unstamped — fix what it says and run `npx looop feedback` again; it
117
+ retries everything unsent.
65
118
 
66
119
  ## Why bother
67
120
 
68
121
  An override that patches an engine bug is a fork that stops receiving updates;
69
- feedback is how the fix lands upstream so the fork can be deleted. And a
70
- missing capability filed from a real game is exactly how the engine decides
71
- what to build next.
122
+ feedback is how the fix lands upstream so the fork can be deleted. A missing
123
+ capability filed from a real game is exactly how the engine decides what to build
124
+ next. And a rule that led you wrong will lead **every** creator wrong, in every
125
+ game, until somebody says so.