@looop-games/cli 0.1.21 → 0.1.23

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/CHANGELOG.md CHANGED
@@ -14,6 +14,32 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.23] - 2026-08-01
18
+
19
+ ### Changed
20
+
21
+ - When the platform refuses an engine download because the Engine License has
22
+ been updated, `looop dev` and `looop update` now explain why and open the
23
+ sign-in flow for you — review and accept in the browser, and the download
24
+ continues by itself. No more bare HTTP status codes, no manual
25
+ `looop login`.
26
+
27
+ ## [0.1.22] - 2026-08-01
28
+
29
+ ### Added
30
+ - `looop feedback` now brings the conversation back: after sending your unsent
31
+ reports, it pulls each report's outcome from the Looop team and stamps it into
32
+ the report file itself — `status:` / `resolved-in:` in the frontmatter, team
33
+ messages appended under `## Updates`. The file under `notes/feedback/` is the
34
+ whole thread, readable offline.
35
+ - You can reply on a report: add a `## Reply (unsent)` section to a sent report
36
+ file and run `looop feedback` — the reply joins the same thread, and a closed
37
+ report you reply to is automatically reopened on the Looop side. Reply when
38
+ something still needs doing; a new problem is a new report.
39
+ - `looop update` now tells you when a report you filed was resolved in one of
40
+ the versions you just crossed ("Feedback you filed landed in this update"),
41
+ next to the changelog it already prints.
42
+
17
43
  ## [0.1.21] - 2026-07-30
18
44
 
19
45
  ### Added
package/bin/looop.mjs CHANGED
@@ -13,6 +13,7 @@ import { lintCmd } from '../lib/lint.mjs';
13
13
  import { update } from '../lib/update.mjs';
14
14
  import { changelog } from '../lib/changelog.mjs';
15
15
  import { sendFeedback } from '../lib/feedback.mjs';
16
+ import { syncFeedback } from '../lib/feedback-sync.mjs';
16
17
  import { clearToken } from '../lib/config.mjs';
17
18
 
18
19
  const [, , cmd, ...rest] = process.argv;
@@ -34,7 +35,7 @@ Usage:
34
35
  looop update Move this game to the latest engine release (re-pins looop.engine)
35
36
  looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
36
37
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
37
- looop feedback Send the unsent reports under notes/feedback/ to the Looop team
38
+ looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
38
39
  looop login Authenticate this machine as your Looop player account
39
40
  looop logout Forget the stored token
40
41
  looop whoami Show who this machine publishes as
@@ -103,7 +104,10 @@ try {
103
104
  await publish({ slug: flag('slug') });
104
105
  break;
105
106
  case 'feedback':
107
+ // Send first, then sync: a report filed this run is already stamped with
108
+ // its id, so the same invocation can pull any outcome waiting for it.
106
109
  await sendFeedback();
110
+ await syncFeedback();
107
111
  break;
108
112
  case 'login':
109
113
  await login();
package/lib/changelog.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `looop changelog` — what changed in the engine (project note azlqm2).
1
+ // `looop changelog` — what changed in the engine.
2
2
  //
3
3
  // The gap this closes: `looop update` used to say "Engine updated: 0.1.10 →
4
4
  // 0.1.12" and then list the SKILL FILES it rewrote. It said nothing about what
package/lib/engine.mjs CHANGED
@@ -112,7 +112,26 @@ export async function ensureEngine(
112
112
  const cached = join(cache, `engine-${pin}.tgz`);
113
113
  if (!existsSync(cached)) {
114
114
  log(`Downloading engine ${pin} from ${apiBase}…`);
115
- const res = await fetchImpl(`${apiBase}/api/creator/engine/${pin}`, { headers: auth });
115
+ const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
116
+ let res = await fetchImpl(dlUrl, { headers: auth });
117
+ // 403 = the token is fine but the platform refuses with a reason the
118
+ // human must act on — today, an updated Engine License that needs
119
+ // re-accepting. The acceptance IS the browser approval step of the device
120
+ // flow, so run the flow right here instead of asking the human to type
121
+ // `looop login` themselves (the click in the browser is still theirs to
122
+ // make), then retry once with the fresh token. If the platform still says
123
+ // no, surface its words — never loop.
124
+ if (res.status === 403) {
125
+ const reason = (await res.json().catch(() => null))?.error;
126
+ log(reason || 'The platform refused the engine download — your sign-in needs a refresh.');
127
+ log('Opening the sign-in flow to sort that out…');
128
+ await loginFn({ apiBase, log });
129
+ res = await fetchImpl(dlUrl, { headers: { Authorization: `Bearer ${getToken()}` } });
130
+ if (res.status === 403) {
131
+ const still = (await res.json().catch(() => null))?.error;
132
+ throw new Error(still || reason || 'the platform refused the engine download (HTTP 403).');
133
+ }
134
+ }
116
135
  if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `looop login` again.');
117
136
  if (res.status === 404) throw new Error(`engine ${pin} is not downloadable — check the \`looop.engine\` pin in package.json.`);
118
137
  if (!res.ok) throw new Error(`engine download failed (HTTP ${res.status})`);
@@ -0,0 +1,232 @@
1
+ // `looop feedback` — the back-channel half. The send half
2
+ // (feedback.mjs) ships reports OUT; this module brings the conversation BACK:
3
+ //
4
+ // 1. Push replies. A report file can carry a `## Reply (unsent)` section —
5
+ // the agent writes one when something about the outcome still needs
6
+ // doing. It ships to /api/creator/feedback/reply and the heading is
7
+ // stamped with the returned fu_ id, so it never ships twice.
8
+ // 2. Fetch outcomes. /api/creator/feedback/status returns, for every report
9
+ // this account filed: its status (read / done / discarded), the engine
10
+ // version a fix landed in, and the update thread. Everything is stamped
11
+ // back INTO the report file — `status:` / `resolved-in:` in frontmatter,
12
+ // unseen updates appended under `## Updates` — so the file under
13
+ // notes/feedback/ IS the thread, readable offline by any agent.
14
+ //
15
+ // Idempotency is by fu_ id: every update written into the file carries its
16
+ // `fu_<16 hex>` marker (in an HTML comment, or on a sent-reply heading), and
17
+ // anything whose id already appears in the file is never appended again.
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { basename } from 'node:path';
20
+ import { findProject } from './project.mjs';
21
+ import { getToken, getApiBase } from './config.mjs';
22
+ import { DEFAULT_API_BASE } from './llm-shim.mjs';
23
+ import { discoverSent } from './feedback.mjs';
24
+ import { compareVersions } from './changelog.mjs';
25
+
26
+ const UPDATE_ID = /fu_[0-9a-f]{16}/g;
27
+ const UNSENT_REPLY = /^## Reply \(unsent\)\s*$/m;
28
+
29
+ // Every unsent reply section: the text between a `## Reply (unsent)` heading
30
+ // and the next `## ` heading (or EOF). A `## ` line inside a ``` / ~~~ fence
31
+ // is CONTENT, not a boundary — replies quote markdown evidence, and stopping
32
+ // at a fenced heading would ship half the reply and strand the rest under an
33
+ // already-stamped heading where it can never be sent.
34
+ export function extractUnsentReplies(text) {
35
+ const out = [];
36
+ const lines = text.split('\n');
37
+ for (let i = 0; i < lines.length; i++) {
38
+ if (!/^## Reply \(unsent\)\s*$/.test(lines[i])) continue;
39
+ const body = [];
40
+ let fenced = false;
41
+ for (let j = i + 1; j < lines.length; j++) {
42
+ if (/^(```|~~~)/.test(lines[j])) fenced = !fenced;
43
+ else if (!fenced && /^## /.test(lines[j])) break;
44
+ body.push(lines[j]);
45
+ }
46
+ out.push({ body: body.join('\n').trim() });
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export function knownUpdateIds(text) {
52
+ return new Set(text.match(UPDATE_ID) ?? []);
53
+ }
54
+
55
+ // Upsert `status:` / `resolved-in:` into the frontmatter block. The report was
56
+ // stamped `sent:` + `id:` when it shipped, so a synced file always has
57
+ // frontmatter; a file without one is left alone. CRLF files (a Windows clone
58
+ // with autocrlf) get their line endings preserved.
59
+ export function applyStatus(text, { status, resolved_in }) {
60
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
61
+ if (!fm) return text;
62
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
63
+ let block = fm[1];
64
+ const upsert = (key, value) => {
65
+ if (value == null) return;
66
+ const line = `${key}: ${value}`;
67
+ if (new RegExp(`^${key}:.*$`, 'm').test(block)) block = block.replace(new RegExp(`^${key}:[^\\r\\n]*`, 'm'), line);
68
+ else block = `${block}${eol}${line}`;
69
+ };
70
+ upsert('status', status);
71
+ upsert('resolved-in', resolved_in);
72
+ return text.slice(0, fm.index) + `---${eol}${block}${eol}---` + text.slice(fm.index + fm[0].length);
73
+ }
74
+
75
+ // Append unseen thread updates under `## Updates` (created at EOF if missing).
76
+ // Each entry carries its fu_ marker; `knownIds` is what makes re-runs no-ops.
77
+ //
78
+ // The body is written as a BLOCKQUOTE, not raw markdown. That is a protocol
79
+ // necessity, not styling: this file is re-parsed by the reply/section regexes
80
+ // on every sync, so a message that merely QUOTES a heading like "## Reply
81
+ // (unsent)" must not become one — raw injection would ship the team's own
82
+ // words back as a creator reply (reopening the report it arrived on).
83
+ export function appendUpdates(text, updates, knownIds) {
84
+ const fresh = updates.filter((u) => !knownIds.has(u.id));
85
+ if (fresh.length === 0) return { text, appended: 0 };
86
+
87
+ let entries = '';
88
+ for (const u of fresh) {
89
+ const who = u.author === 'creator' ? 'You' : 'Looop';
90
+ const when = new Date(u.created_at * 1000).toISOString().slice(0, 10);
91
+ const quoted = u.body.split('\n').map((l) => (l.trim() ? `> ${l}` : '>')).join('\n');
92
+ entries += `\n### ${who} — ${when} <!-- ${u.id} -->\n\n${quoted}\n`;
93
+ }
94
+
95
+ // New entries go at the END OF THE SECTION, not the end of the file: once a
96
+ // reply section exists below `## Updates`, appending at EOF would interleave
97
+ // platform messages under the creator's reply and the thread stops reading
98
+ // in order.
99
+ const lines = text.split('\n');
100
+ const head = lines.findIndex((l) => /^## Updates\s*$/.test(l));
101
+ if (head === -1) {
102
+ return { text: `${text.trimEnd()}\n\n## Updates\n${entries}`, appended: fresh.length };
103
+ }
104
+ let end = lines.length;
105
+ for (let j = head + 1; j < lines.length; j++) {
106
+ if (/^## /.test(lines[j])) { end = j; break; }
107
+ }
108
+ const section = lines.slice(0, end).join('\n').trimEnd();
109
+ const rest = lines.slice(end).join('\n');
110
+ return {
111
+ text: `${section}\n${entries}${rest ? `\n${rest}` : ''}`,
112
+ appended: fresh.length,
113
+ };
114
+ }
115
+
116
+ // Which reports landed in the versions an engine update just crossed:
117
+ // (from, to], or everything at/below `to` when no previous pin exists.
118
+ // `status` must still be done — a report reopened after its fix shipped keeps
119
+ // the resolved_in stamp as history, but announcing it as "landed" would claim
120
+ // a fix the reply just said didn't hold.
121
+ export function landedIn(reports, { from, to }) {
122
+ return reports.filter(
123
+ (r) =>
124
+ r.status === 'done' &&
125
+ r.resolved_in &&
126
+ compareVersions(r.resolved_in, to) <= 0 &&
127
+ (!from || compareVersions(r.resolved_in, from) > 0),
128
+ );
129
+ }
130
+
131
+ export async function fetchStatus({ apiBase, fetchImpl = fetch }) {
132
+ const token = getToken();
133
+ if (!token) throw new Error('reading feedback status requires login — run `looop login` first.');
134
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/status`, {
135
+ headers: { Authorization: `Bearer ${token}` },
136
+ });
137
+ if (!res.ok) throw new Error(`feedback status fetch failed (HTTP ${res.status})`);
138
+ const { reports } = await res.json();
139
+ return Array.isArray(reports) ? reports : [];
140
+ }
141
+
142
+ async function pushReplies({ path, id, apiBase, fetchImpl, log }) {
143
+ let text = readFileSync(path, 'utf8');
144
+ let sent = 0;
145
+ // One at a time: each stamp rewrites the first unsent heading, so the next
146
+ // loop iteration sees the next reply (if any) as the new first.
147
+ while (UNSENT_REPLY.test(text)) {
148
+ const [reply] = extractUnsentReplies(text);
149
+ if (!reply?.body) {
150
+ log(`⚠️ ${basename(path)} has an empty "## Reply (unsent)" section — write the reply, then rerun.`);
151
+ break;
152
+ }
153
+ const token = getToken();
154
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/reply`, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
157
+ body: JSON.stringify({ id, body: reply.body }),
158
+ });
159
+ if (!res.ok) {
160
+ const detail = await res.text().catch(() => '');
161
+ throw new Error(`reply send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
162
+ }
163
+ const { id: updateId } = await res.json();
164
+ text = text.replace(UNSENT_REPLY, `## Reply (sent ${new Date().toISOString()}, ${updateId})`);
165
+ writeFileSync(path, text);
166
+ sent += 1;
167
+ log(`↩️ Sent your reply on ${basename(path)} → ${updateId}`);
168
+ }
169
+ return sent;
170
+ }
171
+
172
+ // The whole sync: replies out, outcomes in. Fails soft on nothing-to-do (no
173
+ // sent reports → silent no-op, no login needed); fails LOUD on a send error,
174
+ // leaving the reply unsent so the next run retries it.
175
+ export async function syncFeedback({
176
+ cwd = process.cwd(),
177
+ apiBase = getApiBase(DEFAULT_API_BASE),
178
+ log = console.log,
179
+ fetchImpl = fetch,
180
+ } = {}) {
181
+ const project = findProject(cwd);
182
+ const sentFiles = discoverSent(project.dir);
183
+ if (sentFiles.length === 0) return { replies: 0, updated: [] };
184
+
185
+ if (!getToken()) throw new Error('syncing feedback requires login — run `looop login` first.');
186
+
187
+ // A failing reply must not rob the OTHER reports of their outcomes: push
188
+ // everything, stamp everything, and only THEN re-throw the first failure —
189
+ // loud (so the run is visibly not clean) but after the useful work. The
190
+ // failed reply's heading is still `(unsent)`, so the next run retries it.
191
+ let replies = 0;
192
+ let replyError = null;
193
+ for (const f of sentFiles) {
194
+ try {
195
+ replies += await pushReplies({ ...f, apiBase, fetchImpl, log });
196
+ } catch (err) {
197
+ replyError ??= err;
198
+ }
199
+ }
200
+
201
+ // Fetch-and-stamp is best-effort: the send half already succeeded, and a
202
+ // platform without the endpoint (or a transient failure) must not turn a
203
+ // delivered report into an apparent failure. The replies stay loud.
204
+ let reports;
205
+ try {
206
+ reports = await fetchStatus({ apiBase, fetchImpl });
207
+ } catch (err) {
208
+ log(`(could not check your reports' outcomes right now — ${err.message} They'll sync on the next run.)`);
209
+ if (replyError) throw replyError;
210
+ return { replies, updated: [] };
211
+ }
212
+ const byId = new Map(reports.map((r) => [r.id, r]));
213
+
214
+ const updated = [];
215
+ for (const { path, id } of sentFiles) {
216
+ const report = byId.get(id);
217
+ if (!report) continue;
218
+ const before = readFileSync(path, 'utf8');
219
+ let text = applyStatus(before, report);
220
+ const { text: withUpdates, appended } = appendUpdates(text, report.updates ?? [], knownUpdateIds(text));
221
+ text = withUpdates;
222
+ if (text === before) continue;
223
+ writeFileSync(path, text);
224
+ updated.push({ file: path, id, status: report.status, resolved_in: report.resolved_in, newUpdates: appended });
225
+
226
+ const landed = report.resolved_in ? ` — landed in ${report.resolved_in}` : '';
227
+ const news = appended ? ` (+${appended} update${appended === 1 ? '' : 's'})` : '';
228
+ log(`📬 ${basename(path)}: ${report.status}${landed}${news}`);
229
+ }
230
+ if (replyError) throw replyError;
231
+ return { replies, updated };
232
+ }
package/lib/feedback.mjs CHANGED
@@ -25,7 +25,10 @@ export const MAX_ATTACHMENTS = 20;
25
25
  export const MAX_ATTACHMENT_BYTES = 256 * 1024; // per file
26
26
  export const MAX_ATTACHMENTS_BYTES = 1024 * 1024; // all of them together
27
27
 
28
- const frontmatter = (text) => /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '';
28
+ // CRLF-tolerant: a Windows clone with autocrlf rewrites these files, and a
29
+ // frontmatter that stops matching means a sent report reads as unsent and is
30
+ // re-filed on every run.
31
+ const frontmatter = (text) => /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '';
29
32
 
30
33
  // A report is "sent" when its frontmatter carries a `sent:` stamp.
31
34
  function isSent(text) {
@@ -128,7 +131,7 @@ function titleOf(text, path) {
128
131
 
129
132
  function stamp(text, id) {
130
133
  const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
131
- if (/^---\n/.test(text)) return text.replace(/^---\n/, `---\n${lines}\n`);
134
+ if (/^---\r?\n/.test(text)) return text.replace(/^---\r?\n/, (m) => `${m}${lines}\n`);
132
135
  return `---\n${lines}\n---\n${text}`;
133
136
  }
134
137
 
@@ -142,6 +145,21 @@ export function discoverUnsent(projectDir) {
142
145
  .sort();
143
146
  }
144
147
 
148
+ // Delivered reports, with the id the platform stamped into each — what the
149
+ // back-channel (feedback-sync.mjs) replies on and fetches outcomes for.
150
+ export function discoverSent(projectDir) {
151
+ const dir = join(projectDir, 'notes', 'feedback');
152
+ if (!existsSync(dir)) return [];
153
+ return readdirSync(dir)
154
+ .filter((name) => name.endsWith('.md'))
155
+ .map((name) => join(dir, name))
156
+ .sort()
157
+ .flatMap((path) => {
158
+ const id = /^id:\s*(fb_[0-9a-f]{16})\s*$/m.exec(frontmatter(readFileSync(path, 'utf8')))?.[1];
159
+ return id ? [{ path, id }] : [];
160
+ });
161
+ }
162
+
145
163
  export async function sendFeedback({
146
164
  cwd = process.cwd(),
147
165
  apiBase = getApiBase(DEFAULT_API_BASE),
package/lib/update.mjs CHANGED
@@ -17,6 +17,7 @@ import { login } from './login.mjs';
17
17
  import { DEFAULT_API_BASE } from './llm-shim.mjs';
18
18
  import { compareVersions, renderReleases } from './changelog.mjs';
19
19
  import { syncCli, reportCli } from './self-update.mjs';
20
+ import { fetchStatus, landedIn } from './feedback-sync.mjs';
20
21
 
21
22
  // The report is the point. A creator reading this must be able to answer, with
22
23
  // no further digging: what changed, was any of it mine, and what do I do now.
@@ -78,7 +79,7 @@ export async function update({
78
79
  const { latest, releases } = await res.json();
79
80
  if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
80
81
 
81
- // What the update is about to change UNDER the game (project note azlqm2).
82
+ // What the update is about to change UNDER the game.
82
83
  // Reporting the skill files we rewrote and nothing about the engine was the
83
84
  // whole gap: an agent crossed two versions of the shared library blind.
84
85
  //
@@ -172,6 +173,19 @@ export async function update({
172
173
  reportCli(log, cli);
173
174
  }
174
175
 
176
+ // Feedback this repo filed that a crossed release resolved. Best-effort on
177
+ // purpose: the back-channel must never block or fail an engine update, so
178
+ // any error here (an older platform without the endpoint, offline after the
179
+ // download, a bad response) reduces to "no landings to report".
180
+ let landed = [];
181
+ if (updated) {
182
+ try {
183
+ landed = landedIn(await fetchStatus({ apiBase, fetchImpl }), { from, to: latest });
184
+ } catch {
185
+ landed = [];
186
+ }
187
+ }
188
+
175
189
  if (updated) {
176
190
  log('');
177
191
  if (crossed === null) {
@@ -180,8 +194,14 @@ export async function update({
180
194
  } else if (crossed.length) {
181
195
  log(renderReleases(crossed, { pinned: from, latest }));
182
196
  }
197
+ if (landed.length) {
198
+ log('');
199
+ log(' Feedback you filed landed in this update:');
200
+ for (const r of landed) log(` • "${r.title}" — resolved in ${r.resolved_in}`);
201
+ log(' Run `npx looop feedback` to pull the details into notes/feedback/.');
202
+ }
183
203
  log('');
184
204
  log(' Republish (`npx looop publish`) when you want the live game on it.');
185
205
  }
186
- return { from, to: latest, updated, surface, crossed, cli };
206
+ return { from, to: latest, updated, surface, crossed, cli, landed };
187
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@looop-games/cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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",