@looop-games/cli 0.1.2 → 0.1.4
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/bin/looop.mjs +16 -0
- package/lib/bundle-primitives.mjs +77 -0
- package/lib/bundle-primitives.test.mjs +95 -0
- package/lib/create.mjs +114 -81
- package/lib/create.test.mjs +353 -12
- package/lib/dev.mjs +27 -2
- package/lib/dev.test.mjs +52 -0
- package/lib/engine.mjs +2 -2
- package/lib/feedback.mjs +87 -0
- package/lib/feedback.test.mjs +144 -0
- package/lib/npm.mjs +38 -0
- package/lib/npm.test.mjs +61 -0
- package/lib/publish.mjs +18 -3
- package/lib/publish.test.mjs +44 -0
- package/lib/test-cmd.mjs +135 -0
- package/lib/test-cmd.test.mjs +118 -0
- package/lib/update.mjs +51 -0
- package/lib/update.test.mjs +98 -0
- package/package.json +5 -2
- package/template/.claude/skills/build/SKILL.md +240 -0
- package/template/.claude/skills/engine/SKILL.md +59 -0
- package/template/.claude/skills/feedback/SKILL.md +71 -0
- package/template/.claude/skills/qa/SKILL.md +53 -0
- package/template/.claude/skills/todo/SKILL.md +54 -0
- package/template/.claude/skills/update-handbook/SKILL.md +69 -0
- package/template/AGENTS.md +86 -0
- package/template/CLAUDE.md +4 -0
- package/template/GEMINI.md +4 -0
- package/template/boot.smoke.mjs +21 -0
- package/template/game.js +53 -0
- package/template/gitignore +2 -0
- package/template/handbook/design.md +7 -0
- package/template/handbook/feel.md +8 -0
- package/template/handbook/qa.md +9 -0
- package/template/index.html +26 -0
- package/lib/agent-files.mjs +0 -151
package/lib/create.test.mjs
CHANGED
|
@@ -8,20 +8,64 @@
|
|
|
8
8
|
// covered by packed-install.smoke.mjs.
|
|
9
9
|
import { test, after } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
|
|
12
13
|
import { tmpdir } from 'node:os';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { create } from './create.mjs';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import { create, TEMPLATE_DIR } from './create.mjs';
|
|
15
16
|
|
|
16
17
|
const base = mkdtempSync(join(tmpdir(), 'looop-create-'));
|
|
17
18
|
after(() => rmSync(base, { recursive: true, force: true }));
|
|
18
19
|
|
|
20
|
+
// Hermetic: create() now resolves the engine pin from the platform — point
|
|
21
|
+
// every test at a dead port (instant refusal → the warn-fallback path).
|
|
22
|
+
// Tests that exercise the pin itself pass an explicit mock apiBase.
|
|
23
|
+
process.env.LOOOP_API_BASE = 'http://127.0.0.1:1';
|
|
24
|
+
|
|
25
|
+
// The scaffold is an on-disk template FOLDER (cuqfzo creator-harness Slice 1),
|
|
26
|
+
// not template strings in code — Fran edits it as normal files. `create`
|
|
27
|
+
// copies it with a {{name}} substitution pass; `gitignore` is stored dot-less
|
|
28
|
+
// (npm pack silently strips .gitignore files) and renamed on copy.
|
|
29
|
+
test('the scaffold is a faithful copy of the on-disk template folder', async () => {
|
|
30
|
+
const { dir } = await create({ name: 'from-template', cwd: base, install: false, log: () => {} });
|
|
31
|
+
|
|
32
|
+
const walk = (d) =>
|
|
33
|
+
readdirSync(d, { withFileTypes: true, recursive: true })
|
|
34
|
+
.filter((e) => e.isFile())
|
|
35
|
+
.map((e) => relative(d, join(e.parentPath, e.name)));
|
|
36
|
+
|
|
37
|
+
for (const file of walk(TEMPLATE_DIR)) {
|
|
38
|
+
const dest = file === 'gitignore' ? '.gitignore' : file;
|
|
39
|
+
assert.ok(existsSync(join(dir, dest)), `template file ${file} lands in the scaffold as ${dest}`);
|
|
40
|
+
const rendered = readFileSync(join(dir, dest), 'utf8');
|
|
41
|
+
assert.ok(!rendered.includes('{{name}}'), `${dest} has no unsubstituted {{name}} placeholders`);
|
|
42
|
+
const source = readFileSync(join(TEMPLATE_DIR, file), 'utf8');
|
|
43
|
+
assert.equal(rendered, source.replaceAll('{{name}}', 'from-template'), `${dest} is the template modulo substitution`);
|
|
44
|
+
}
|
|
45
|
+
// No stray dot-less gitignore in the scaffold.
|
|
46
|
+
assert.ok(!existsSync(join(dir, 'gitignore')));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the template folder ships in the npm package (files list)', () => {
|
|
50
|
+
const pkg = JSON.parse(readFileSync(join(TEMPLATE_DIR, '..', 'package.json'), 'utf8'));
|
|
51
|
+
assert.ok(pkg.files.includes('template'), 'npm pack must carry template/ or the published CLI scaffolds nothing');
|
|
52
|
+
});
|
|
53
|
+
|
|
19
54
|
test('scaffolds a complete standalone game folder', async () => {
|
|
20
55
|
const { dir } = await create({ name: 'tower-jump', cwd: base, install: false, log: () => {} });
|
|
21
56
|
assert.equal(dir, join(base, 'tower-jump'));
|
|
22
57
|
|
|
23
58
|
const html = readFileSync(join(dir, 'index.html'), 'utf8');
|
|
24
59
|
assert.match(html, /<title>tower-jump<\/title>/);
|
|
60
|
+
|
|
61
|
+
// The mobile baseline (architecture.md "Mobile baseline (every game)") is
|
|
62
|
+
// REQUIRED boilerplate the template must carry — without it every phone
|
|
63
|
+
// build double-tap-zooms and finger-drag-selects the HUD. All four items:
|
|
64
|
+
assert.match(html, /maximum-scale=1\.0, user-scalable=no, viewport-fit=cover/, '1: locked viewport');
|
|
65
|
+
assert.match(html, /touch-action: manipulation/, '2: no double-tap zoom on the chrome');
|
|
66
|
+
assert.match(html, /gesturestart/, '3: iOS gesture-zoom blocked in JS');
|
|
67
|
+
assert.match(html, /\*\s*\{[^}]*user-select: none/, '4: text selection killed on every element');
|
|
68
|
+
assert.match(html, /input, textarea[^}]*user-select: text/, '4b: typeable fields stay selectable');
|
|
25
69
|
const js = readFileSync(join(dir, 'game.js'), 'utf8');
|
|
26
70
|
assert.match(js, /from '\/shared\/ui\/room\/client\.js'/); // real multiplayer from minute one
|
|
27
71
|
|
|
@@ -33,9 +77,28 @@ test('scaffolds a complete standalone game folder', async () => {
|
|
|
33
77
|
// the `looop.engine` pin then.
|
|
34
78
|
assert.equal(pkg.dependencies, undefined);
|
|
35
79
|
assert.ok(pkg.devDependencies['@looop-games/cli']);
|
|
80
|
+
// Smokes need a real browser: playwright ships with every scaffold and
|
|
81
|
+
// create pre-fetches Chromium — never a surprise mid-build install
|
|
82
|
+
// (caught in Fran's manual e2e, 2026-07-10).
|
|
83
|
+
assert.ok(pkg.devDependencies.playwright, 'playwright is a scaffold devDependency');
|
|
36
84
|
assert.match(pkg.scripts.dev, /looop dev/);
|
|
37
85
|
assert.match(pkg.scripts.publish, /looop publish/);
|
|
38
86
|
|
|
87
|
+
// npm is moving install scripts to opt-in (npm 11 warns, npm 12 SKIPS them
|
|
88
|
+
// by default). esbuild and workerd download their native binaries in a
|
|
89
|
+
// postinstall — skip those and `looop dev` cannot start, with no obvious
|
|
90
|
+
// cause. The scaffold ships the approval so a stranger's first install is
|
|
91
|
+
// clean on any npm. Name-only (unpinned) entries: partykit moves its
|
|
92
|
+
// esbuild/workerd pins between releases, and a version-pinned approval
|
|
93
|
+
// would silently stop covering them. Reported by a Windows creator with a
|
|
94
|
+
// strict npm, 2026-07-11.
|
|
95
|
+
assert.equal(pkg.allowScripts.esbuild, true, 'esbuild postinstall pre-approved (native binary)');
|
|
96
|
+
assert.equal(pkg.allowScripts.workerd, true, 'workerd postinstall pre-approved (native binary)');
|
|
97
|
+
// macOS-only optional dep of the watchers; unused on Windows. Verified
|
|
98
|
+
// against a real npm 11 install of the published scaffold: with these three
|
|
99
|
+
// the install is warning-free on every platform.
|
|
100
|
+
assert.equal(pkg.allowScripts.fsevents, true, 'fsevents postinstall pre-approved (mac watcher)');
|
|
101
|
+
|
|
39
102
|
// The agent-facing surface: instructions + a pointer at the practices docs
|
|
40
103
|
// that ship inside the installed engine bundle.
|
|
41
104
|
const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
|
|
@@ -58,20 +121,241 @@ test('scaffolds the agent surface for every vendor (no plugin needed — Q4 adde
|
|
|
58
121
|
assert.match(text, /read .*AGENTS\.md/i, `${pointer} has the fallback instruction`);
|
|
59
122
|
}
|
|
60
123
|
|
|
61
|
-
// The looop skill ships with the scaffold (Claude Code picks up
|
|
62
|
-
// .claude/skills/ per project) — same knowledge the plugin used to carry,
|
|
63
|
-
// now versioned with the CLI that scaffolded it.
|
|
64
|
-
const skill = readFileSync(join(dir, '.claude', 'skills', 'looop', 'SKILL.md'), 'utf8');
|
|
65
|
-
assert.match(skill, /^---\nname: looop\n/, 'skill frontmatter');
|
|
66
|
-
assert.match(skill, /npx looop publish --slug/, 'documents the CLI surface');
|
|
67
|
-
assert.match(skill, /overrides\/shared\//, 'documents the override mechanism');
|
|
68
|
-
assert.match(skill, /multiplayer/i);
|
|
69
|
-
|
|
70
124
|
// The scaffold's .gitignore must NOT swallow the agent files.
|
|
71
125
|
const ignore = readFileSync(join(dir, '.gitignore'), 'utf8');
|
|
72
126
|
assert.ok(!/\.claude/.test(ignore), '.claude/skills travels with the repo');
|
|
73
127
|
});
|
|
74
128
|
|
|
129
|
+
// The creator harness (cuqfzo creator-harness Slice 1, decisions Q2–Q7):
|
|
130
|
+
// Layer 0 AGENTS.md with managed-section markers + routing table, the SIX
|
|
131
|
+
// skills, and the handbook/ seed. This is the product surface a creator's
|
|
132
|
+
// agent runs on — the contract below is what the Slice-2 updater and the
|
|
133
|
+
// friends' cutover depend on.
|
|
134
|
+
test('Layer 0: AGENTS.md has managed-section markers and the routing table', async () => {
|
|
135
|
+
const { dir } = await create({ name: 'layer-zero', cwd: base, install: false, log: () => {} });
|
|
136
|
+
const agents = readFileSync(join(dir, 'AGENTS.md'), 'utf8');
|
|
137
|
+
|
|
138
|
+
// The exact marker tokens the Slice-2 updater rewrites between — a
|
|
139
|
+
// creator's own additions outside them must never be clobbered.
|
|
140
|
+
assert.ok(agents.includes('<!-- looop:managed:start -->'), 'managed-block start marker');
|
|
141
|
+
assert.ok(agents.includes('<!-- looop:managed:end -->'), 'managed-block end marker');
|
|
142
|
+
assert.ok(
|
|
143
|
+
agents.indexOf('<!-- looop:managed:start -->') < agents.indexOf('<!-- looop:managed:end -->'),
|
|
144
|
+
'markers in order',
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Routing lines for the demoted skills (Q6): save, update, feel/tweaks,
|
|
148
|
+
// overrides — capabilities without a skill MUST be reachable from Layer 0.
|
|
149
|
+
assert.match(agents, /save/i, 'save routing line');
|
|
150
|
+
assert.match(agents, /looop\.engine/, 'engine-update routing mentions the version pin');
|
|
151
|
+
assert.match(agents, /tweaks/, 'feel-tuning routes at the tweaks library');
|
|
152
|
+
assert.match(agents, /overrides\/shared\//, 'override mechanism reachable from Layer 0');
|
|
153
|
+
|
|
154
|
+
// Layer 0 must NOT re-list the skills' triggers — every major agent CLI has
|
|
155
|
+
// a native skill system and the frontmatter `description` IS the routing
|
|
156
|
+
// (a second copy in AGENTS.md drifts). One orientation mention of the
|
|
157
|
+
// skills directory is all that's allowed.
|
|
158
|
+
assert.ok(!agents.includes('skills/build/SKILL.md'), 'no per-skill routing rows');
|
|
159
|
+
assert.match(agents, /\.claude\/skills/, 'orientation line names the skills directory');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('.agents/skills aliases .claude/skills for non-Claude agent CLIs', async () => {
|
|
163
|
+
// Codex, Gemini/Antigravity, Cursor, and opencode discover project skills
|
|
164
|
+
// at .agents/skills/ — the scaffold aliases it to the same SKILL.md files
|
|
165
|
+
// (created at scaffold time; npm pack cannot ship symlinks).
|
|
166
|
+
const { dir } = await create({ name: 'vendor-skills', cwd: base, install: false, log: () => {} });
|
|
167
|
+
const link = join(dir, '.agents', 'skills');
|
|
168
|
+
assert.ok(lstatSync(link).isSymbolicLink(), '.agents/skills is a symlink');
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
readdirSync(link).sort(),
|
|
171
|
+
readdirSync(join(dir, '.claude', 'skills')).sort(),
|
|
172
|
+
'both paths expose the same skills',
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the six skills ship, correctly named, no more no fewer (Q6 roster)', async () => {
|
|
177
|
+
const { dir } = await create({ name: 'six-skills', cwd: base, install: false, log: () => {} });
|
|
178
|
+
const skillsDir = join(dir, '.claude', 'skills');
|
|
179
|
+
const roster = ['build', 'engine', 'feedback', 'qa', 'todo', 'update-handbook'];
|
|
180
|
+
assert.deepEqual(readdirSync(skillsDir).sort(), roster, 'exactly the six-skill roster');
|
|
181
|
+
for (const name of roster) {
|
|
182
|
+
const text = readFileSync(join(skillsDir, name, 'SKILL.md'), 'utf8');
|
|
183
|
+
assert.match(text, new RegExp(`^---\\nname: ${name}\\n`), `${name} frontmatter name matches its folder`);
|
|
184
|
+
assert.match(text, /\ndescription: .+/, `${name} has a trigger description`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('the build loop encodes the locked mechanics (Q3/Q5/Q8)', async () => {
|
|
189
|
+
const { dir } = await create({ name: 'build-loop', cwd: base, install: false, log: () => {} });
|
|
190
|
+
const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
|
|
191
|
+
|
|
192
|
+
assert.match(build, /plan/i, 'note-anchored: the plan is the spine');
|
|
193
|
+
assert.match(build, /milestone/i, 'milestone vocabulary');
|
|
194
|
+
assert.match(build, /step/i, 'step vocabulary');
|
|
195
|
+
// Save-on-accept / revert-to-last-save — and NO invented git ceremony: the
|
|
196
|
+
// pre-milestone "checkpoint" commit was struck by Fran (2026-07-10).
|
|
197
|
+
assert.match(build, /last save/i, 'reject reverts to the last save');
|
|
198
|
+
assert.ok(!/checkpoint/i.test(build), 'no checkpoint mechanic');
|
|
199
|
+
assert.match(build, /never publish/i, 'publish is offered, never automatic');
|
|
200
|
+
assert.match(build, /worktree/i, 'parallel lanes section (Q8)');
|
|
201
|
+
assert.match(build, /consequence/i, 'PM-register discussion (Q5)');
|
|
202
|
+
// The discussion is a HARD gate on plan creation, ported from the studio
|
|
203
|
+
// loop's design-then-execute split (design settles decisions one at a time
|
|
204
|
+
// with a stated lean and visible lock-in; execution runs only from a
|
|
205
|
+
// settled plan). Caught in Fran's manual e2e 2026-07-10: a whole plan +
|
|
206
|
+
// code landed with zero discussion.
|
|
207
|
+
assert.match(build, /only from a plan whose decisions are settled/i, 'building requires a settled plan');
|
|
208
|
+
assert.match(build, /one decision per turn/i, 'decisions are posed one at a time');
|
|
209
|
+
assert.match(build, /problem before the options/i, 'problem explained before options');
|
|
210
|
+
assert.match(build, /your lean/i, 'a lean is stated with the options');
|
|
211
|
+
assert.match(build, /locking in/i, 'decisions are locked in visibly');
|
|
212
|
+
assert.match(build, /never applies when there is no plan yet/i, 'ceremony collapse cannot skip plan creation');
|
|
213
|
+
// Parity-audit fixes (2026-07-10): the remaining /discuss reflexes + the
|
|
214
|
+
// scope/lifecycle/lanes gaps the independent audit found.
|
|
215
|
+
assert.match(build, /re-explain the\s+underlying problem/i, '"I don\'t understand" → back up, not louder');
|
|
216
|
+
assert.match(build, /case for the other option/i, 'the lean is easy to override');
|
|
217
|
+
assert.match(build, /## Out of scope/m, 'plan template has a durable Out-of-scope home');
|
|
218
|
+
assert.match(build, /paused \| abandoned/, 'a plan can be abandoned, distinct from done');
|
|
219
|
+
assert.match(build, /never auto-resolve/i, 'lane merge conflicts stop for the creator');
|
|
220
|
+
|
|
221
|
+
const todo = readFileSync(join(dir, '.claude', 'skills', 'todo', 'SKILL.md'), 'utf8');
|
|
222
|
+
assert.match(todo, /promoted \| abandoned/, 'todos distinguish won\'t-do from did-it');
|
|
223
|
+
assert.match(todo, /one-line why/i, 'abandoning requires the why');
|
|
224
|
+
|
|
225
|
+
// Review cadence (Fran, 2026-07-10): AUTOMATED steps — including the
|
|
226
|
+
// review sub-agents — run after EVERY step; only the playtest is
|
|
227
|
+
// per-milestone. "Milestone-sized reviews" was drift, not a decision.
|
|
228
|
+
assert.match(build, /step's diff/, 'review sub-agents run on each step\'s diff');
|
|
229
|
+
assert.match(build, /only the playtest is per-milestone/i, 'the manual gate is the only per-milestone check');
|
|
230
|
+
|
|
231
|
+
// Seamless-UX handback (Fran, 2026-07-10): the AGENT runs the dev server;
|
|
232
|
+
// the creator gets a link to click, never a command to paste.
|
|
233
|
+
assert.match(build, /you run the server,\s+never the creator/i, 'agent starts/reuses the dev stack itself');
|
|
234
|
+
assert.match(build, /link to click/i, 'the creator receives a URL, not a command');
|
|
235
|
+
const qaSkill = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
236
|
+
assert.match(qaSkill, /already\s+running/i, '/qa manual items run against a live game the agent started');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// The harness core that makes the monorepo loop effective must survive the
|
|
240
|
+
// port IN FULL — the parity gate is the whole point of the workstream. The
|
|
241
|
+
// framework itself (automated-vs-manual, the ratchet, the master steps list
|
|
242
|
+
// incl. the review rows and the generative verdict-mix row) has ONE source of
|
|
243
|
+
// truth: the engine's shared/practices/qa.md, shipped inside the artifact.
|
|
244
|
+
// The skills REFERENCE it — they never restate it (that scatter is exactly
|
|
245
|
+
// what qa.md was created to end). These rows pin the reference shape plus the
|
|
246
|
+
// point-of-use pointers that were once silently dropped.
|
|
247
|
+
test('the harness core survives the port: single source referenced, pointers intact', async () => {
|
|
248
|
+
const { dir } = await create({ name: 'harness-core', cwd: base, install: false, log: () => {} });
|
|
249
|
+
const build = readFileSync(join(dir, '.claude', 'skills', 'build', 'SKILL.md'), 'utf8');
|
|
250
|
+
const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
251
|
+
|
|
252
|
+
// Both loop skills anchor on the ONE master doc inside the engine artifact.
|
|
253
|
+
for (const [name, text] of [['build', build], ['qa', qa]]) {
|
|
254
|
+
assert.match(text, /engine\/shared\/practices\/qa\.md/, `${name} points at the master QA doc`);
|
|
255
|
+
}
|
|
256
|
+
assert.match(qa, /steps live there|never restate/i, '/qa declares the single-source rule');
|
|
257
|
+
assert.match(qa, /`npx looop test`/, '/qa names the real test gate');
|
|
258
|
+
|
|
259
|
+
// Point-of-use pointers in the loop (the rows themselves live in qa.md):
|
|
260
|
+
// independent review sub-agents + red→green findings + verdict mix.
|
|
261
|
+
assert.match(build, /sub-agent/i, 'milestone close spawns reviewers');
|
|
262
|
+
assert.match(build, /code.quality/i, 'names the code-quality review row');
|
|
263
|
+
assert.match(build, /coverage|test.opportunit|test.thoroughness/i, 'names the test-coverage review row');
|
|
264
|
+
assert.match(build, /red.*green|failing test.*(first|before)/i, 'findings fixed red→green');
|
|
265
|
+
assert.match(build, /verdict/i, 'generative verdict-mix pointer at milestone close');
|
|
266
|
+
assert.match(build, /diagnose/i, 'diagnose before second-guessing a failed fix');
|
|
267
|
+
assert.match(qa, /sub-agent/i, '/qa maps the review skills to sub-agents');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('handbook/ seeds ship and /qa + /update-handbook wire the three QA sources (Q7)', async () => {
|
|
271
|
+
const { dir } = await create({ name: 'handbook-game', cwd: base, install: false, log: () => {} });
|
|
272
|
+
for (const f of ['qa.md', 'feel.md', 'design.md']) {
|
|
273
|
+
assert.ok(existsSync(join(dir, 'handbook', f)), `handbook/${f} seeded`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const qa = readFileSync(join(dir, '.claude', 'skills', 'qa', 'SKILL.md'), 'utf8');
|
|
277
|
+
assert.match(qa, /looop test/, 'source 1: executable files via looop test');
|
|
278
|
+
assert.match(qa, /handbook\/qa\.md/, 'source 2: the game handbook');
|
|
279
|
+
assert.match(qa, /engine\/shared\/practices\/qa\.md/, 'source 3: the engine master list');
|
|
280
|
+
|
|
281
|
+
const ratchet = readFileSync(join(dir, '.claude', 'skills', 'update-handbook', 'SKILL.md'), 'utf8');
|
|
282
|
+
assert.match(ratchet, /handbook\/qa\.md/, 'ratchet writes game QA steps');
|
|
283
|
+
assert.match(ratchet, /feedback/i, 'generic holes route upstream via /feedback');
|
|
284
|
+
// The handbook is the CREATOR's truth — writes are proposed and approved,
|
|
285
|
+
// never slipped in as a side effect (Fran, 2026-07-10).
|
|
286
|
+
assert.match(ratchet, /approval first/i, 'handbook writes gated on creator approval');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// /feedback has a REAL transport (`looop feedback` → /api/creator/feedback),
|
|
290
|
+
// and the report is agent-authored: maximal detail, verbatim output, and the
|
|
291
|
+
// conversation transcript — the Looop team can't ask the session follow-ups.
|
|
292
|
+
test('the feedback skill sends via looop feedback and demands the full evidence', async () => {
|
|
293
|
+
const { dir } = await create({ name: 'feedback-game', cwd: base, install: false, log: () => {} });
|
|
294
|
+
const fb = readFileSync(join(dir, '.claude', 'skills', 'feedback', 'SKILL.md'), 'utf8');
|
|
295
|
+
|
|
296
|
+
assert.match(fb, /npx looop feedback/, 'sends through the real CLI transport');
|
|
297
|
+
assert.ok(!/however they normally reach them/.test(fb), 'the interim manual transport is gone');
|
|
298
|
+
assert.match(fb, /## Transcript/m, 'the report carries the conversation transcript');
|
|
299
|
+
assert.match(fb, /verbatim/i, 'output is pasted verbatim, never summarized');
|
|
300
|
+
assert.match(fb, /sent:/, 'documents the sent-stamp so reports never ship twice');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Every notes/ file is datetime-prefixed so the tree lists in creation order
|
|
304
|
+
// and "the latest plan" is findable at a glance (Fran, 2026-07-10 — a flat
|
|
305
|
+
// slug like iso-view.md is unsortable once notes accumulate).
|
|
306
|
+
test('note-writing skills mandate the datetime filename prefix', async () => {
|
|
307
|
+
const { dir } = await create({ name: 'ordered-notes', cwd: base, install: false, log: () => {} });
|
|
308
|
+
for (const [skill, tree] of [['build', 'plans'], ['todo', 'todos'], ['feedback', 'feedback']]) {
|
|
309
|
+
const text = readFileSync(join(dir, '.claude', 'skills', skill, 'SKILL.md'), 'utf8');
|
|
310
|
+
assert.match(
|
|
311
|
+
text,
|
|
312
|
+
new RegExp(`notes/${tree}/<YYYY-MM-DD-HHMM>-`),
|
|
313
|
+
`${skill} names its notes/${tree} files datetime-first`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// The engine pin is resolved AT CREATE TIME from the public version-list
|
|
319
|
+
// endpoint and lands INSIDE the initial commit — the milestone-1 revert
|
|
320
|
+
// baseline. Without this, first dev wrote the pin after the baseline: fresh
|
|
321
|
+
// repos started dirty, and a milestone-1 reject deleted the pin so the next
|
|
322
|
+
// dev could silently resolve a NEWER engine (caught in Fran's manual e2e,
|
|
323
|
+
// 2026-07-10).
|
|
324
|
+
test('create resolves the engine pin into the initial commit; reverts keep it', async (t) => {
|
|
325
|
+
const http = await import('node:http');
|
|
326
|
+
const server = http.createServer((req, res) => {
|
|
327
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(JSON.stringify(req.url === '/api/creator/engine' ? { versions: ['0.7.3'], latest: '0.7.3' } : {}));
|
|
329
|
+
});
|
|
330
|
+
await new Promise((r) => server.listen(0, r));
|
|
331
|
+
t.after(() => server.close());
|
|
332
|
+
|
|
333
|
+
const { dir } = await create({
|
|
334
|
+
name: 'pinned-game',
|
|
335
|
+
cwd: base,
|
|
336
|
+
install: false,
|
|
337
|
+
apiBase: `http://localhost:${server.address().port}`,
|
|
338
|
+
log: () => {},
|
|
339
|
+
});
|
|
340
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
341
|
+
assert.equal(pkg.looop.engine, '0.7.3', 'pin written at create');
|
|
342
|
+
|
|
343
|
+
// The pin is IN the baseline commit, so reset --hard can never drop it.
|
|
344
|
+
const shown = execFileSync('git', ['show', 'HEAD:package.json'], { cwd: dir, encoding: 'utf8' });
|
|
345
|
+
assert.match(shown, /"engine": "0\.7\.3"/, 'initial commit carries the pin');
|
|
346
|
+
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: dir });
|
|
347
|
+
const after = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
348
|
+
assert.equal(after.looop.engine, '0.7.3', 'revert-to-baseline preserves the pin');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('an unreachable platform degrades to the old pin-at-first-dev behavior', async () => {
|
|
352
|
+
const logs = [];
|
|
353
|
+
const { dir } = await create({ name: 'offline-game', cwd: base, install: false, log: (m) => logs.push(m) });
|
|
354
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
355
|
+
assert.equal(pkg.looop, undefined, 'no pin when the lookup fails');
|
|
356
|
+
assert.match(logs.join('\n'), /Could not resolve the engine version/, 'the fallback is loud');
|
|
357
|
+
});
|
|
358
|
+
|
|
75
359
|
test('the CLI dependency spec is overridable for tarball installs (the smoke path)', async () => {
|
|
76
360
|
const { dir } = await create({
|
|
77
361
|
name: 'spec-game',
|
|
@@ -84,6 +368,63 @@ test('the CLI dependency spec is overridable for tarball installs (the smoke pat
|
|
|
84
368
|
assert.equal(pkg.devDependencies['@looop-games/cli'], 'file:../cli.tgz');
|
|
85
369
|
});
|
|
86
370
|
|
|
371
|
+
// The initial commit is the revert baseline: reject-milestone-1 resets to it,
|
|
372
|
+
// and `git worktree add` (parallel lanes) needs a commit to exist — on a
|
|
373
|
+
// never-committed repo it silently creates an empty orphan lane.
|
|
374
|
+
test('create leaves the scaffold fully committed (the first revert baseline)', async () => {
|
|
375
|
+
const { dir } = await create({ name: 'baseline', cwd: base, install: false, log: () => {} });
|
|
376
|
+
const log = execFileSync('git', ['log', '--oneline'], { cwd: dir }).toString().trim().split('\n');
|
|
377
|
+
assert.equal(log.length, 1, 'exactly one commit');
|
|
378
|
+
const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
|
|
379
|
+
assert.equal(status, '', 'nothing left uncommitted');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// A stranger who runs `npx looop create` gets the template AND the practices
|
|
383
|
+
// docs inside the engine artifact. They have never heard of the studio, its
|
|
384
|
+
// monorepo, its CLI, its games, or its people — any mention is a leak (Fran,
|
|
385
|
+
// 2026-07-10: "if they read about the monorepo, it's an issue"). Studio-side
|
|
386
|
+
// content lives in shared/notes/guides/ (never shipped), not here.
|
|
387
|
+
test('no studio leaks in any surface a stranger receives', () => {
|
|
388
|
+
const engineRoot = join(TEMPLATE_DIR, '..', '..', '..');
|
|
389
|
+
const shipped = [];
|
|
390
|
+
const collect = (d) => {
|
|
391
|
+
for (const e of readdirSync(d, { withFileTypes: true, recursive: true })) {
|
|
392
|
+
if (e.isFile()) shipped.push(join(e.parentPath, e.name));
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
collect(TEMPLATE_DIR);
|
|
396
|
+
collect(join(engineRoot, 'shared', 'practices'));
|
|
397
|
+
shipped.push(join(engineRoot, 'shared', 'ui', 'INDEX.md'));
|
|
398
|
+
|
|
399
|
+
const FORBIDDEN = [
|
|
400
|
+
/`gamedev|gamedev </i, // the studio's internal CLI ("gamedev literature" prose is fine)
|
|
401
|
+
/\bFran\b/, // the studio's human
|
|
402
|
+
/monorepo/i, // the workspace the reader must never learn exists
|
|
403
|
+
/studio/i, // "the studio" / "studio-only" dual-world framing
|
|
404
|
+
/harness-improvement/, // studio-only skills
|
|
405
|
+
/import-game/,
|
|
406
|
+
/`\/primitives`/,
|
|
407
|
+
/looop-ugc|looop-boxeo/, // studio partykit deploy names
|
|
408
|
+
/looop-core|looop-games\/games\//, // sibling-repo paths (@looop-games/* npm scope is fine)
|
|
409
|
+
/new_game\.py|catalog_publish\.py/, // studio tool paths
|
|
410
|
+
/shared\/notes\//, // the studio notes tree (not in the artifact)
|
|
411
|
+
];
|
|
412
|
+
// Studio game names: banned in the practices docs (there they were
|
|
413
|
+
// references the reader can't open); allowed as plain DATA elsewhere —
|
|
414
|
+
// INDEX.md's used_by column, and template game.js's generic "a tiny
|
|
415
|
+
// multiplayer plaza".
|
|
416
|
+
const PRACTICES_ONLY = [/mp-lab|escaleras|piso-20|booox|wordsmith|survivors|amnesia|\bagora\b|\bplaza\b/];
|
|
417
|
+
|
|
418
|
+
for (const file of shipped) {
|
|
419
|
+
const text = readFileSync(file, 'utf8');
|
|
420
|
+
const extra = file.includes(`${join('shared', 'practices')}`) ? PRACTICES_ONLY : [];
|
|
421
|
+
for (const re of [...FORBIDDEN, ...extra]) {
|
|
422
|
+
const m = text.match(re);
|
|
423
|
+
assert.ok(!m, `${relative(engineRoot, file)} leaks "${m?.[0]}" (${re})`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
87
428
|
test('rejects slug-unsafe names and existing folders', async () => {
|
|
88
429
|
await assert.rejects(() => create({ name: 'My Game!', cwd: base, install: false, log: () => {} }), /letters/);
|
|
89
430
|
await create({ name: 'dupe', cwd: base, install: false, log: () => {} });
|
package/lib/dev.mjs
CHANGED
|
@@ -37,6 +37,29 @@ export function partykitBin() {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Per-game server parity (cuqfzo Phase 4): a game shipping its own
|
|
41
|
+
// `partykit.json` runs its OWN server in dev — partykit gets the game folder
|
|
42
|
+
// as cwd, resolving the game's `main` entry (which imports the room server
|
|
43
|
+
// from the installed engine). Without one, the bundle's shared room server
|
|
44
|
+
// runs, exactly as before. Mirrors the monorepo dev.sh detection so a
|
|
45
|
+
// vendor-flipped game behaves identically standalone. A config whose `main`
|
|
46
|
+
// doesn't exist is ignored LOUDLY — a dead file must not take dev's
|
|
47
|
+
// multiplayer down with it.
|
|
48
|
+
export function partykitCwdFor(project, engine, warn = console.warn) {
|
|
49
|
+
const configPath = join(project.dir, 'partykit.json');
|
|
50
|
+
if (existsSync(configPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
53
|
+
const main = config?.main ?? 'server.js';
|
|
54
|
+
if (existsSync(join(project.dir, main))) return project.dir;
|
|
55
|
+
warn(`partykit.json found but its main (${main}) doesn't exist — running the shared room server instead`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
warn(`partykit.json is unreadable (${err.message}) — running the shared room server instead`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return engine.roomServerDir;
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', log = console.log } = {}) {
|
|
41
64
|
const project = findProject(cwd);
|
|
42
65
|
// Q4 revision: the engine is not an npm dependency — install/refresh it from
|
|
@@ -92,10 +115,12 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
92
115
|
log(`→ multiplayer :${ports.mp} (in use — killing to take over)`);
|
|
93
116
|
await killPort(ports.mp);
|
|
94
117
|
}
|
|
118
|
+
const pkCwd = partykitCwdFor(project, engine, (m) => log(`[partykit] ${m}`));
|
|
119
|
+
const ownServer = pkCwd === project.dir;
|
|
95
120
|
const pk = spawn(
|
|
96
121
|
process.execPath,
|
|
97
122
|
[partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `looop-partykit-${ports.mp}`)],
|
|
98
|
-
{ cwd:
|
|
123
|
+
{ cwd: pkCwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true },
|
|
99
124
|
);
|
|
100
125
|
pk.stdout.on('data', () => {});
|
|
101
126
|
pk.stderr.on('data', (d) => {
|
|
@@ -103,7 +128,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
|
|
|
103
128
|
if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
|
|
104
129
|
});
|
|
105
130
|
children.push(pk);
|
|
106
|
-
log(`→ multiplayer :${ports.mp} (partykit on the bundle's room-server, pid ${pk.pid})`);
|
|
131
|
+
log(`→ multiplayer :${ports.mp} (partykit on ${ownServer ? "this game's OWN server" : "the bundle's room-server"}, pid ${pk.pid})`);
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
// ─── platform services shim ───
|
package/lib/dev.test.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Per-game server parity (cuqfzo Phase 4, dev half): a game that ships its
|
|
2
|
+
// own `partykit.json` gets its OWN server in `looop dev` — partykit runs with
|
|
3
|
+
// the game folder as cwd (resolving the game's `main` entry, which imports
|
|
4
|
+
// the room server from the installed engine). A game without one rides the
|
|
5
|
+
// bundle's shared room server, exactly as before. This mirrors the monorepo's
|
|
6
|
+
// dev.sh detection, so a vendor-flipped game behaves identically standalone.
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { partykitCwdFor } from './dev.mjs';
|
|
14
|
+
|
|
15
|
+
function tempGameDir(withPartykit) {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'looop-devtest-'));
|
|
17
|
+
writeFileSync(join(dir, 'index.html'), '<canvas></canvas>');
|
|
18
|
+
if (withPartykit) {
|
|
19
|
+
writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'server.js' }));
|
|
20
|
+
writeFileSync(join(dir, 'server.js'), 'export { default } from "@looop-games/engine/shared/ui/room/server.js";');
|
|
21
|
+
}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('game WITHOUT partykit.json → partykit runs on the bundle room server', () => {
|
|
26
|
+
const dir = tempGameDir(false);
|
|
27
|
+
try {
|
|
28
|
+
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
|
|
29
|
+
assert.equal(cwd, '/engine/room');
|
|
30
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('game WITH partykit.json → partykit runs on the GAME folder (its own server)', () => {
|
|
34
|
+
const dir = tempGameDir(true);
|
|
35
|
+
try {
|
|
36
|
+
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' });
|
|
37
|
+
assert.equal(cwd, dir);
|
|
38
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a partykit.json without a resolvable main is IGNORED (falls back to the bundle, loudly)', () => {
|
|
42
|
+
// A dead config must not silently break dev — the room server the game
|
|
43
|
+
// actually reaches should still exist.
|
|
44
|
+
const dir = tempGameDir(false);
|
|
45
|
+
writeFileSync(join(dir, 'partykit.json'), JSON.stringify({ name: 'mygame', main: 'missing.js' }));
|
|
46
|
+
try {
|
|
47
|
+
const warnings = [];
|
|
48
|
+
const cwd = partykitCwdFor({ dir }, { roomServerDir: '/engine/room' }, (m) => warnings.push(m));
|
|
49
|
+
assert.equal(cwd, '/engine/room');
|
|
50
|
+
assert.ok(warnings.some((w) => /missing\.js/.test(w)), 'warns about the dead entry');
|
|
51
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
52
|
+
});
|
package/lib/engine.mjs
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
//
|
|
17
17
|
// LOOOP_ENGINE_TARBALL=<path> short-circuits the network — the offline/smoke
|
|
18
18
|
// lane (packed-install smoke, air-gapped work against a local build).
|
|
19
|
-
import { execFileSync } from 'node:child_process';
|
|
20
19
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
21
20
|
import { join, resolve } from 'node:path';
|
|
21
|
+
import { runNpm } from './npm.mjs';
|
|
22
22
|
import { resolveEngine } from './project.mjs';
|
|
23
23
|
import { getToken, getApiBase, cacheDir } from './config.mjs';
|
|
24
24
|
import { login } from './login.mjs';
|
|
@@ -52,7 +52,7 @@ function installedEngine(projectDir) {
|
|
|
52
52
|
// node_modules/@looop-games/engine AND installs its runtime dependencies
|
|
53
53
|
// (the room server's bare imports), without touching package.json.
|
|
54
54
|
function npmInstallTarball(projectDir, tgzPath) {
|
|
55
|
-
|
|
55
|
+
runNpm(['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
|
|
56
56
|
cwd: projectDir,
|
|
57
57
|
stdio: 'pipe',
|
|
58
58
|
});
|
package/lib/feedback.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `looop feedback` — send the reports under notes/feedback/ to the Looop team
|
|
2
|
+
// (cuqfzo; the transport behind the template's /feedback skill).
|
|
3
|
+
//
|
|
4
|
+
// Target: /api/creator/feedback — the token-authenticated intake beside
|
|
5
|
+
// creator/publish. Each unsent notes/feedback/*.md ships VERBATIM as the
|
|
6
|
+
// report body (frontmatter, transcript excerpt and all — the agent authors
|
|
7
|
+
// it rich on purpose), with the repo's identity attached: slug, the
|
|
8
|
+
// looop.engine pin, and this CLI's version. A delivered file is stamped
|
|
9
|
+
// `sent:` + `id:` in its frontmatter so it never ships twice; a failed send
|
|
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';
|
|
13
|
+
import { findProject } from './project.mjs';
|
|
14
|
+
import { getToken, getApiBase } from './config.mjs';
|
|
15
|
+
import { DEFAULT_API_BASE } from './llm-shim.mjs';
|
|
16
|
+
|
|
17
|
+
const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
+
|
|
19
|
+
// A report is "sent" when its frontmatter carries a `sent:` stamp.
|
|
20
|
+
function isSent(text) {
|
|
21
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
22
|
+
return fm ? /^sent:\s*\S/m.test(fm[1]) : false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function titleOf(text, path) {
|
|
26
|
+
const h1 = /^#\s+(.+)$/m.exec(text);
|
|
27
|
+
return h1 ? h1[1].trim() : basename(path, '.md');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stamp(text, id) {
|
|
31
|
+
const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
|
|
32
|
+
if (/^---\n/.test(text)) return text.replace(/^---\n/, `---\n${lines}\n`);
|
|
33
|
+
return `---\n${lines}\n---\n${text}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function discoverUnsent(projectDir) {
|
|
37
|
+
const dir = join(projectDir, 'notes', 'feedback');
|
|
38
|
+
if (!existsSync(dir)) return [];
|
|
39
|
+
return readdirSync(dir)
|
|
40
|
+
.filter((name) => name.endsWith('.md'))
|
|
41
|
+
.map((name) => join(dir, name))
|
|
42
|
+
.filter((path) => !isSent(readFileSync(path, 'utf8')))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function sendFeedback({
|
|
47
|
+
cwd = process.cwd(),
|
|
48
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
49
|
+
log = console.log,
|
|
50
|
+
} = {}) {
|
|
51
|
+
const project = findProject(cwd);
|
|
52
|
+
const unsent = discoverUnsent(project.dir);
|
|
53
|
+
if (unsent.length === 0) {
|
|
54
|
+
log('Nothing to send — no unsent reports under notes/feedback/.');
|
|
55
|
+
return { sent: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const token = getToken();
|
|
59
|
+
if (!token) throw new Error('sending feedback requires login — run `looop login` first.');
|
|
60
|
+
|
|
61
|
+
const sent = [];
|
|
62
|
+
for (const path of unsent) {
|
|
63
|
+
const body = readFileSync(path, 'utf8');
|
|
64
|
+
const report = {
|
|
65
|
+
title: titleOf(body, path),
|
|
66
|
+
body,
|
|
67
|
+
slug: project.slug,
|
|
68
|
+
engine: project.pkg?.looop?.engine,
|
|
69
|
+
cli: CLI_VERSION,
|
|
70
|
+
};
|
|
71
|
+
const res = await fetch(`${apiBase}/api/creator/feedback`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
74
|
+
body: JSON.stringify(report),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const detail = await res.text().catch(() => '');
|
|
78
|
+
throw new Error(`feedback send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
79
|
+
}
|
|
80
|
+
const { id } = await res.json();
|
|
81
|
+
writeFileSync(path, stamp(body, id));
|
|
82
|
+
log(`✅ Sent ${basename(path)} → ${id}`);
|
|
83
|
+
sent.push({ file: path, id });
|
|
84
|
+
}
|
|
85
|
+
log(`${sent.length} report${sent.length === 1 ? '' : 's'} delivered to the Looop team.`);
|
|
86
|
+
return { sent };
|
|
87
|
+
}
|