picopilot 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +111 -16
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +3 -3
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +61 -44
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/cart-includes.d.ts +11 -0
  9. package/dist/commands/cart-includes.d.ts.map +1 -0
  10. package/dist/commands/cart-includes.js +26 -0
  11. package/dist/commands/cart-includes.js.map +1 -0
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.d.ts.map +1 -0
  14. package/dist/commands/doctor.js +222 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/export.d.ts +3 -4
  17. package/dist/commands/export.d.ts.map +1 -1
  18. package/dist/commands/export.js +66 -86
  19. package/dist/commands/export.js.map +1 -1
  20. package/dist/commands/gfx.d.ts +2 -2
  21. package/dist/commands/gfx.d.ts.map +1 -1
  22. package/dist/commands/gfx.js +3 -2
  23. package/dist/commands/gfx.js.map +1 -1
  24. package/dist/commands/label-cart.d.ts +72 -0
  25. package/dist/commands/label-cart.d.ts.map +1 -0
  26. package/dist/commands/label-cart.js +228 -0
  27. package/dist/commands/label-cart.js.map +1 -0
  28. package/dist/commands/lint.d.ts +2 -2
  29. package/dist/commands/lint.d.ts.map +1 -1
  30. package/dist/commands/lint.js +3 -2
  31. package/dist/commands/lint.js.map +1 -1
  32. package/dist/commands/minify.d.ts +2 -2
  33. package/dist/commands/minify.d.ts.map +1 -1
  34. package/dist/commands/minify.js +3 -2
  35. package/dist/commands/minify.js.map +1 -1
  36. package/dist/commands/pico8-env.d.ts +76 -0
  37. package/dist/commands/pico8-env.d.ts.map +1 -0
  38. package/dist/commands/pico8-env.js +71 -0
  39. package/dist/commands/pico8-env.js.map +1 -0
  40. package/dist/commands/playtest.d.ts +2 -2
  41. package/dist/commands/playtest.d.ts.map +1 -1
  42. package/dist/commands/playtest.js +6 -32
  43. package/dist/commands/playtest.js.map +1 -1
  44. package/dist/commands/run.d.ts +3 -4
  45. package/dist/commands/run.d.ts.map +1 -1
  46. package/dist/commands/run.js +46 -19
  47. package/dist/commands/run.js.map +1 -1
  48. package/dist/commands/serve.d.ts +2 -2
  49. package/dist/commands/serve.d.ts.map +1 -1
  50. package/dist/commands/serve.js +65 -19
  51. package/dist/commands/serve.js.map +1 -1
  52. package/dist/commands/shrinko-env.d.ts +22 -0
  53. package/dist/commands/shrinko-env.d.ts.map +1 -0
  54. package/dist/commands/shrinko-env.js +15 -0
  55. package/dist/commands/shrinko-env.js.map +1 -0
  56. package/dist/commands/tokens.d.ts +4 -4
  57. package/dist/commands/tokens.d.ts.map +1 -1
  58. package/dist/commands/tokens.js +3 -2
  59. package/dist/commands/tokens.js.map +1 -1
  60. package/dist/commands/verify.d.ts +2 -2
  61. package/dist/commands/verify.d.ts.map +1 -1
  62. package/dist/commands/verify.js +3 -2
  63. package/dist/commands/verify.js.map +1 -1
  64. package/dist/engine/child-env.d.ts +14 -0
  65. package/dist/engine/child-env.d.ts.map +1 -0
  66. package/dist/engine/child-env.js +21 -0
  67. package/dist/engine/child-env.js.map +1 -0
  68. package/dist/engine/gfx/label.d.ts +15 -9
  69. package/dist/engine/gfx/label.d.ts.map +1 -1
  70. package/dist/engine/gfx/label.js +130 -38
  71. package/dist/engine/gfx/label.js.map +1 -1
  72. package/dist/engine/pico8/adapter.d.ts +23 -3
  73. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  74. package/dist/engine/pico8/adapter.js.map +1 -1
  75. package/dist/engine/pico8/cleanup.d.ts +16 -0
  76. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  77. package/dist/engine/pico8/cleanup.js +74 -0
  78. package/dist/engine/pico8/cleanup.js.map +1 -0
  79. package/dist/engine/pico8/display.d.ts +149 -0
  80. package/dist/engine/pico8/display.d.ts.map +1 -0
  81. package/dist/engine/pico8/display.js +311 -0
  82. package/dist/engine/pico8/display.js.map +1 -0
  83. package/dist/engine/pico8/drive.d.ts.map +1 -1
  84. package/dist/engine/pico8/drive.js +28 -2
  85. package/dist/engine/pico8/drive.js.map +1 -1
  86. package/dist/engine/pico8/harness.d.ts +8 -0
  87. package/dist/engine/pico8/harness.d.ts.map +1 -1
  88. package/dist/engine/pico8/harness.js +9 -1
  89. package/dist/engine/pico8/harness.js.map +1 -1
  90. package/dist/engine/pico8/index.d.ts +3 -0
  91. package/dist/engine/pico8/index.d.ts.map +1 -1
  92. package/dist/engine/pico8/index.js +3 -0
  93. package/dist/engine/pico8/index.js.map +1 -1
  94. package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
  95. package/dist/engine/pico8/session-daemon-entry.js +1 -1
  96. package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
  97. package/dist/engine/pico8/session-daemon-main.js +1 -1
  98. package/dist/engine/pico8/session-daemon.d.ts +1 -1
  99. package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
  100. package/dist/engine/pico8/session-daemon.js +19 -19
  101. package/dist/engine/pico8/session-daemon.js.map +1 -1
  102. package/dist/engine/pico8/session.d.ts +1 -1
  103. package/dist/engine/pico8/session.js +1 -1
  104. package/dist/engine/pico8/shell.d.ts +56 -3
  105. package/dist/engine/pico8/shell.d.ts.map +1 -1
  106. package/dist/engine/pico8/shell.js +188 -24
  107. package/dist/engine/pico8/shell.js.map +1 -1
  108. package/dist/engine/pico8/supervisor.d.ts +1 -1
  109. package/dist/engine/pico8/supervisor.js +1 -1
  110. package/dist/engine/shrinko/adapter.d.ts +1 -1
  111. package/dist/engine/shrinko/shell.d.ts +3 -3
  112. package/dist/engine/shrinko/shell.js +3 -3
  113. package/package.json +2 -1
  114. package/src/cli.test.ts +188 -0
  115. package/src/cli.ts +122 -16
  116. package/src/commands/audio.test.ts +76 -1
  117. package/src/commands/audio.ts +79 -57
  118. package/src/commands/cart-includes.ts +27 -0
  119. package/src/commands/doctor.test.ts +149 -0
  120. package/src/commands/doctor.ts +280 -0
  121. package/src/commands/export.test.ts +327 -3
  122. package/src/commands/export.ts +81 -99
  123. package/src/commands/gfx.ts +7 -6
  124. package/src/commands/label-cart.ts +304 -0
  125. package/src/commands/lint.test.ts +6 -0
  126. package/src/commands/lint.ts +7 -6
  127. package/src/commands/minify.test.ts +6 -0
  128. package/src/commands/minify.ts +7 -6
  129. package/src/commands/pico8-env.ts +124 -0
  130. package/src/commands/playtest.test.ts +3 -0
  131. package/src/commands/playtest.ts +10 -34
  132. package/src/commands/run.test.ts +116 -2
  133. package/src/commands/run.ts +62 -24
  134. package/src/commands/serve.test.ts +138 -2
  135. package/src/commands/serve.ts +80 -21
  136. package/src/commands/shrinko-env.test.ts +68 -0
  137. package/src/commands/shrinko-env.ts +40 -0
  138. package/src/commands/tokens.test.ts +19 -0
  139. package/src/commands/tokens.ts +9 -6
  140. package/src/commands/verify.test.ts +12 -0
  141. package/src/commands/verify.ts +7 -4
  142. package/src/engine/child-env.ts +22 -0
  143. package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
  144. package/src/engine/gfx/label.test.ts +467 -1
  145. package/src/engine/gfx/label.ts +147 -41
  146. package/src/engine/pico8/adapter.ts +25 -3
  147. package/src/engine/pico8/cleanup.test.ts +28 -0
  148. package/src/engine/pico8/cleanup.ts +76 -0
  149. package/src/engine/pico8/display.test.ts +210 -0
  150. package/src/engine/pico8/display.ts +429 -0
  151. package/src/engine/pico8/drive.test.ts +20 -0
  152. package/src/engine/pico8/drive.ts +25 -2
  153. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  154. package/src/engine/pico8/harness.test.ts +26 -0
  155. package/src/engine/pico8/harness.ts +18 -2
  156. package/src/engine/pico8/index.ts +3 -0
  157. package/src/engine/pico8/session-daemon-entry.ts +1 -1
  158. package/src/engine/pico8/session-daemon-main.ts +1 -1
  159. package/src/engine/pico8/session-daemon.test.ts +32 -1
  160. package/src/engine/pico8/session-daemon.ts +25 -17
  161. package/src/engine/pico8/session.test.ts +1 -1
  162. package/src/engine/pico8/session.ts +1 -1
  163. package/src/engine/pico8/shell.test.ts +449 -10
  164. package/src/engine/pico8/shell.ts +255 -26
  165. package/src/engine/pico8/supervisor.ts +1 -1
  166. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  167. package/src/engine/shrinko/adapter.ts +1 -1
  168. package/src/engine/shrinko/shell.ts +3 -3
  169. package/src/engine/skills/skills.test.ts +10 -0
  170. package/src/skills/picopilot-audio/SKILL.md +4 -8
  171. package/src/skills/picopilot-debug/SKILL.md +18 -1
  172. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -1,5 +1,11 @@
1
1
  import {execFileSync} from 'node:child_process';
2
- import {existsSync, mkdtempSync, readdirSync, writeFileSync} from 'node:fs';
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
3
9
  import {homedir, tmpdir} from 'node:os';
4
10
  import {join} from 'node:path';
5
11
  import {Cli} from 'incur';
@@ -11,7 +17,8 @@ import type {
11
17
  RecordOptions,
12
18
  RunOptions,
13
19
  } from '../engine/pico8/index.js';
14
- import {pico8NotFound} from '../engine/pico8/index.js';
20
+ import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
21
+ import {createShellAdapterFactory} from './pico8-env.js';
15
22
  import {type Pico8AdapterFactory, registerRun} from './run.js';
16
23
 
17
24
  /** A trivial valid cart the command reads (existence + a path to hand the adapter). */
@@ -47,6 +54,12 @@ function stubAdapter(
47
54
  recordResult?: Pico8RecordResult,
48
55
  ): Pico8Adapter {
49
56
  return {
57
+ async drive() {
58
+ throw new Error('drive not used');
59
+ },
60
+ async export() {
61
+ throw new Error('export not used');
62
+ },
50
63
  async run(options) {
51
64
  if (seen !== undefined) seen.options = options;
52
65
  return result;
@@ -262,3 +275,104 @@ describe('picopilot run: cart-not-found (picopilot-side error, distinct from pic
262
275
  expect(snap()).toEqual(before); // same entries: an absent dir stays absent, a present one unchanged
263
276
  });
264
277
  });
278
+
279
+ describe('picopilot run --record-audio: an #include cart produces a loadable harness', () => {
280
+ it('inlines the cart includes into the harness it hands the record pass', async () => {
281
+ writeFileSync(
282
+ cartPath,
283
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n#include main.lua\n',
284
+ );
285
+ writeFileSync(
286
+ join(dir, 'main.lua'),
287
+ 'function _update() hello_from_main=1 end\n',
288
+ );
289
+ const seen: {recordOptions?: RecordOptions} = {};
290
+ const {exitCode} = await runRun(
291
+ () => stubAdapter(ranWith('sentinel'), seen),
292
+ ['run', cartPath, '--record-audio', '--json'],
293
+ );
294
+ expect(exitCode).toBe(0);
295
+ const harness = readFileSync(seen.recordOptions!.cartPath, 'utf8');
296
+ expect(harness).not.toMatch(/#include/);
297
+ expect(harness).toContain('hello_from_main=1');
298
+ });
299
+ });
300
+
301
+ describe('picopilot run --record-audio: failures are explained, never silent', () => {
302
+ it('surfaces noAudioReason + pico8LogTail when no WAV was captured', async () => {
303
+ const {stdout} = await runRun(
304
+ () =>
305
+ stubAdapter(ranWith('sentinel'), undefined, {
306
+ ok: true,
307
+ value: {
308
+ wavPath: undefined,
309
+ printh: '',
310
+ exitReason: 'timeout',
311
+ noWavReason: 'the backstop fired before the done-sentinel',
312
+ pico8LogTail: 'syntax error',
313
+ },
314
+ }),
315
+ ['run', cartPath, '--record-audio', '--json'],
316
+ );
317
+ const out = JSON.parse(stdout);
318
+ expect(out.audioCaptured).toBe(false);
319
+ expect(out.noAudioReason).toMatch(/backstop/);
320
+ expect(out.pico8LogTail).toBe('syntax error');
321
+ });
322
+
323
+ it('display-unavailable is a structured error whose CTA names the fix', async () => {
324
+ const {stdout, exitCode} = await runRun(
325
+ () =>
326
+ stubAdapter(
327
+ ranWith('sentinel'),
328
+ undefined,
329
+ displayUnavailable('no DISPLAY and no Xvfb'),
330
+ ),
331
+ ['run', cartPath, '--record-audio', '--json', '--full-output'],
332
+ );
333
+ expect(exitCode).not.toBe(0);
334
+ const out = JSON.parse(stdout);
335
+ expect(out.error.code).toBe('display-unavailable');
336
+ expect(out.error.message).toMatch(/Xvfb/);
337
+ expect(JSON.stringify(out.meta?.cta ?? out.cta)).toMatch(/install Xvfb/);
338
+ });
339
+ });
340
+
341
+ describe('picopilot run: the PICO-8 child inherits the parent environment', () => {
342
+ it('DISPLAY, HOME and XDG_RUNTIME_DIR from the parent reach the spawn; PICO8_PATH comes from the resolved env', async () => {
343
+ let spawnedEnv: NodeJS.ProcessEnv | undefined;
344
+ const factory = createShellAdapterFactory({
345
+ parentEnv: {
346
+ DISPLAY: ':5',
347
+ HOME: '/home/agent',
348
+ XDG_RUNTIME_DIR: '/run/user/1000',
349
+ SDL_AUDIODRIVER: 'pulseaudio',
350
+ PICO8_PATH: '/parent/pico8',
351
+ },
352
+ spawn: (_file, _args, env) => {
353
+ spawnedEnv = env;
354
+ return {
355
+ onStdout(cb) {
356
+ setImmediate(() => cb('__PICOPILOT_DONE__\n'));
357
+ },
358
+ onClose() {},
359
+ onError() {},
360
+ kill() {},
361
+ };
362
+ },
363
+ });
364
+ const {exitCode} = await runRun(factory, ['run', cartPath, '--json'], {
365
+ PICO8_PATH: '/resolved/pico8',
366
+ PATH: '/usr/bin',
367
+ });
368
+ expect(exitCode).toBe(0);
369
+ expect(spawnedEnv).toMatchObject({
370
+ DISPLAY: ':5',
371
+ HOME: '/home/agent',
372
+ XDG_RUNTIME_DIR: '/run/user/1000',
373
+ SDL_AUDIODRIVER: 'pulseaudio',
374
+ PICO8_PATH: '/resolved/pico8',
375
+ PATH: '/usr/bin',
376
+ });
377
+ });
378
+ });
@@ -12,20 +12,25 @@ import {
12
12
  buildRecordHarness,
13
13
  DONE_SENTINEL,
14
14
  HarnessError,
15
- type Pico8Adapter,
16
- ShellPico8Adapter,
17
15
  } from '../engine/pico8/index.js';
16
+ import {makeReadInclude} from './cart-includes.js';
17
+ import {
18
+ createShellAdapterFactory,
19
+ displayModeOf,
20
+ displayUnavailableError,
21
+ PICO8_ENV,
22
+ pico8NotFoundError,
23
+ type Pico8AdapterFactory,
24
+ } from './pico8-env.js';
18
25
 
19
26
  /**
20
27
  * Injects the PICO-8 adapter `run` uses (defaults to the shell adapter over
21
28
  * native `pico8 -x`). Mirrors the shrinko `adapterFactory` seam: a test passes a
22
- * stub to drive the sentinel/backstop/absent paths without the paid binary, and
23
- * to isolate the child `env` (its `PICO8_PATH`/`PATH`).
29
+ * stub to drive the sentinel/backstop/absent paths without the paid binary.
24
30
  */
25
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
31
+ export type {Pico8AdapterFactory} from './pico8-env.js';
26
32
 
27
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
28
- new ShellPico8Adapter({env});
33
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
29
34
 
30
35
  /** The default backstop: kill a cart that never signals/exits after this many ms. */
31
36
  const DEFAULT_BACKSTOP_MS = 15_000;
@@ -95,18 +100,12 @@ export function registerRun(
95
100
  .boolean()
96
101
  .default(false)
97
102
  .describe(
98
- "Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording that needs a real audio+video session (a separate -run pass, NOT the headless screenshot pass), never an offline export.",
103
+ "Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording in a separate -run pass (not the headless screenshot pass), never an offline export. Works headless: with no display, picopilot runs that pass on a private virtual display (ADR-0017, --display).",
99
104
  ),
100
105
  }),
101
- // incur resolves these from the (test-overridable) env source and hands
102
- // them to the child, so PICO8_PATH/PATH locate the binary in isolation.
103
- env: z.object({
104
- PICO8_PATH: z
105
- .string()
106
- .optional()
107
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
108
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
109
- }),
106
+ // incur resolves these from the (test-overridable) env source; the child
107
+ // gets the parent env with them overlaid (DISPLAY, HOME, ... pass through).
108
+ env: PICO8_ENV,
110
109
  output: z.object({
111
110
  screenshots: z
112
111
  .array(z.string())
@@ -131,6 +130,24 @@ export function registerRun(
131
130
  .describe(
132
131
  'Whether a non-empty WAV was captured (false unless --record-audio ran and produced audio).',
133
132
  ),
133
+ audioDisplay: z
134
+ .enum(['native', 'Xvfb', 'xvfb-run'])
135
+ .optional()
136
+ .describe(
137
+ 'With --record-audio: which display the -run pass used (native, or a private virtual display).',
138
+ ),
139
+ noAudioReason: z
140
+ .string()
141
+ .optional()
142
+ .describe(
143
+ 'With --record-audio and no WAV: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
144
+ ),
145
+ pico8LogTail: z
146
+ .string()
147
+ .optional()
148
+ .describe(
149
+ "With --record-audio and no WAV: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
150
+ ),
134
151
  }),
135
152
  examples: [
136
153
  {description: 'Run main.p8'},
@@ -141,7 +158,7 @@ export function registerRun(
141
158
  options: {shotDir: './shots'},
142
159
  },
143
160
  ],
144
- async run({args, options, env, error, ok}) {
161
+ async run({args, options, env, error, ok, var: vars}) {
145
162
  const cartPath = isAbsolute(args.cart)
146
163
  ? args.cart
147
164
  : resolve(process.cwd(), args.cart);
@@ -166,7 +183,9 @@ export function registerRun(
166
183
  : mkdtempSync(join(tmpdir(), 'picopilot-run-'));
167
184
  if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
168
185
 
169
- const adapter = adapterFactory(env as NodeJS.ProcessEnv);
186
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
187
+ display: displayModeOf(vars),
188
+ });
170
189
  const result = await adapter.run({
171
190
  cartPath,
172
191
  shotDir,
@@ -194,10 +213,16 @@ export function registerRun(
194
213
  // uses. The WAV lands in the isolated shotDir alongside the screenshots.
195
214
  let wav: string | undefined;
196
215
  let audioCaptured = false;
216
+ let noAudioReason: string | undefined;
217
+ let pico8LogTail: string | undefined;
218
+ let audioDisplay: 'native' | 'Xvfb' | 'xvfb-run' | undefined;
197
219
  if (options.recordAudio) {
198
220
  try {
221
+ // `#include`s are inlined (resolved against the SOURCE cart's folder):
222
+ // the harness runs from shotDir, where `main.lua` does not exist.
199
223
  const harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
200
224
  sentinel: options.sentinel,
225
+ readInclude: makeReadInclude(cartPath),
201
226
  });
202
227
  const harnessCart = join(shotDir, 'record-audio-harness.p8');
203
228
  writeFileSync(harnessCart, harness.cartText);
@@ -208,16 +233,26 @@ export function registerRun(
208
233
  sentinel: options.sentinel,
209
234
  backstopMs: options.backstopMs,
210
235
  });
211
- if (rec.ok) {
212
- wav = rec.value.wavPath;
213
- audioCaptured = rec.value.wavPath !== undefined;
236
+ if (!rec.ok) {
237
+ // pico8-not-found is impossible here (the screenshot pass above would
238
+ // have returned it); display-unavailable is the real case: the -run
239
+ // pass needs a display, there is none, and no Xvfb to make one.
240
+ return error(
241
+ rec.reason === 'display-unavailable'
242
+ ? displayUnavailableError('run --record-audio', rec)
243
+ : pico8NotFoundError(rec),
244
+ );
214
245
  }
215
- // A record-pass pico8-not-found is impossible here: the screenshot
216
- // pass above already returned pico8-not-found if PICO-8 were absent.
246
+ wav = rec.value.wavPath;
247
+ audioCaptured = rec.value.wavPath !== undefined;
248
+ noAudioReason = rec.value.noWavReason;
249
+ pico8LogTail = rec.value.pico8LogTail;
250
+ audioDisplay = rec.value.display;
217
251
  } catch (e) {
218
252
  if (!(e instanceof HarnessError)) throw e;
219
253
  // A harness-build failure (unparseable cart) leaves audioCaptured false;
220
254
  // the screenshot run's report still stands.
255
+ noAudioReason = `the record harness could not be built: ${e.message}`;
221
256
  }
222
257
  }
223
258
 
@@ -277,6 +312,9 @@ export function registerRun(
277
312
  shotDir,
278
313
  wav,
279
314
  audioCaptured,
315
+ ...(audioDisplay !== undefined ? {audioDisplay} : {}),
316
+ ...(noAudioReason !== undefined ? {noAudioReason} : {}),
317
+ ...(pico8LogTail !== undefined ? {pico8LogTail} : {}),
280
318
  },
281
319
  cta === undefined ? undefined : {cta},
282
320
  );
@@ -1,12 +1,21 @@
1
1
  import {execFileSync} from 'node:child_process';
2
- import {existsSync, mkdtempSync, writeFileSync} from 'node:fs';
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
3
9
  import {tmpdir} from 'node:os';
4
10
  import {join} from 'node:path';
5
11
  import {Cli} from 'incur';
6
12
  import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import {encodePng} from '../engine/gfx/index.js';
7
14
  import type {
15
+ DriveOptions,
8
16
  ExportOptions,
9
17
  Pico8Adapter,
18
+ Pico8DriveResult,
10
19
  Pico8ExportResult,
11
20
  } from '../engine/pico8/index.js';
12
21
  import {pico8NotFound} from '../engine/pico8/index.js';
@@ -211,7 +220,12 @@ describe('picopilot serve: failure paths', () => {
211
220
  const lower = stdout.toLowerCase();
212
221
  expect(lower).toContain('picopilot run');
213
222
  expect(lower).toContain('playtest');
214
- expect(lower).toContain('export --label');
223
+ // The remedy must be one that WORKS: `export --label` never writes the label
224
+ // into the cart, so "export --label, then re-serve" would fail again. serve
225
+ // takes the label itself.
226
+ expect(lower).toMatch(/picopilot serve [^`]*--label/);
227
+ expect(lower).toContain('--label-frame 0');
228
+ expect(lower).not.toContain('then re-serve');
215
229
  expect(lower).toContain('do not hand-edit main.p8');
216
230
  });
217
231
 
@@ -225,3 +239,125 @@ describe('picopilot serve: failure paths', () => {
225
239
  expect(stdout).toContain('no cart at');
226
240
  });
227
241
  });
242
+
243
+ describe('picopilot serve: --label (bake a screenshot in for a labelless cart)', () => {
244
+ const shotPath = new URL(
245
+ '../engine/gfx/fixtures/pico8-screenshot-384.png',
246
+ import.meta.url,
247
+ );
248
+
249
+ it('exports a labeled COPY (the screenshot as-is), leaves the cart untouched, and cleans the copy up', async () => {
250
+ const labelFile = join(dir, 'shot.png');
251
+ writeFileSync(labelFile, readFileSync(shotPath));
252
+ let exportedCart = '';
253
+ const exportSeen: {options?: ExportOptions} = {};
254
+ const {exitCode} = await runServe(
255
+ () => {
256
+ const inner = stubAdapter('writes-bundle', exportSeen);
257
+ return {
258
+ ...inner,
259
+ export: async (o: ExportOptions) => {
260
+ exportedCart = readFileSync(o.cartPath, 'utf8');
261
+ return inner.export(o);
262
+ },
263
+ } as Pico8Adapter;
264
+ },
265
+ fakeServer(),
266
+ ['serve', cartPath, '--label', labelFile, '--json'],
267
+ );
268
+ expect(exitCode).toBe(0);
269
+ expect(exportSeen.options!.cartPath).not.toBe(cartPath);
270
+ const label = exportedCart.split('__label__\n')[1]!.split('\n');
271
+ expect(label[10]![10]).toBe('8');
272
+ expect(label[100]![100]).toBe('c');
273
+ expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
274
+ // Only the cart and the label image remain: the labeled copy is gone.
275
+ expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
276
+ });
277
+
278
+ it('removes the labeled copy even when the export step throws', async () => {
279
+ const labelFile = join(dir, 'shot.png');
280
+ writeFileSync(labelFile, readFileSync(shotPath));
281
+ await runServe(
282
+ () =>
283
+ ({
284
+ ...stubAdapter('writes-bundle'),
285
+ export: async () => {
286
+ throw new Error('boom');
287
+ },
288
+ }) as Pico8Adapter,
289
+ fakeServer(),
290
+ ['serve', cartPath, '--label', labelFile, '--json'],
291
+ ).catch(() => undefined);
292
+ expect(readdirSync(dir).sort()).toEqual(['main.p8', 'shot.png']);
293
+ });
294
+
295
+ it('surfaces a bad label image as label-wrong-size with the screenshot CTA, before exporting', async () => {
296
+ const labelFile = join(dir, 'odd.png');
297
+ writeFileSync(
298
+ labelFile,
299
+ encodePng({width: 200, height: 200, rgb: new Uint8Array(200 * 200 * 3)}),
300
+ );
301
+ const exportSeen: {options?: ExportOptions} = {};
302
+ const {stdout, exitCode} = await runServe(
303
+ () => stubAdapter('writes-bundle', exportSeen),
304
+ fakeServer(),
305
+ ['serve', cartPath, '--label', labelFile, '--json'],
306
+ );
307
+ expect(exitCode).not.toBe(0);
308
+ expect(stdout).toContain('label-wrong-size');
309
+ expect(stdout).toContain('scaled for you');
310
+ expect(exportSeen.options).toBeUndefined();
311
+ });
312
+ });
313
+
314
+ describe('picopilot serve: --label-frame (capture the label from the game)', () => {
315
+ it('captures frame N with no input and serves the export of a labeled copy', async () => {
316
+ let drive: DriveOptions | undefined;
317
+ let exportedCart = '';
318
+ const {exitCode} = await runServe(
319
+ () => ({
320
+ ...stubAdapter('writes-bundle'),
321
+ async drive(o: DriveOptions): Promise<Pico8DriveResult> {
322
+ drive = o;
323
+ const shot = join(o.shotDir, 'label0.png');
324
+ writeFileSync(
325
+ shot,
326
+ readFileSync(
327
+ new URL(
328
+ '../engine/gfx/fixtures/pico8-screenshot-384.png',
329
+ import.meta.url,
330
+ ),
331
+ ),
332
+ );
333
+ return {
334
+ ok: true,
335
+ value: {screenshots: [shot], printh: '', exitReason: 'sentinel'},
336
+ };
337
+ },
338
+ async export(o: ExportOptions) {
339
+ exportedCart = readFileSync(o.cartPath, 'utf8');
340
+ return stubAdapter('writes-bundle').export(o);
341
+ },
342
+ }),
343
+ fakeServer(),
344
+ ['serve', cartPath, '--label-frame', '2', '--json'],
345
+ );
346
+ expect(exitCode).toBe(0);
347
+ expect(drive).toBeDefined();
348
+ expect(exportedCart.split('__label__\n')[1]!.split('\n')[10]![10]).toBe(
349
+ '8',
350
+ );
351
+ expect(readdirSync(dir)).toEqual(['main.p8']);
352
+ });
353
+
354
+ it('rejects --label with --label-frame', async () => {
355
+ const {stdout, exitCode} = await runServe(
356
+ () => stubAdapter('writes-bundle'),
357
+ fakeServer(),
358
+ ['serve', cartPath, '--label', 'x.png', '--label-frame', '1', '--json'],
359
+ );
360
+ expect(exitCode).not.toBe(0);
361
+ expect(stdout).toContain('label-flags-conflict');
362
+ });
363
+ });
@@ -12,18 +12,23 @@ import {type Cli, z} from 'incur';
12
12
  import {
13
13
  EXPORT_HTML_NAME,
14
14
  type Pico8Adapter,
15
- ShellPico8Adapter,
15
+ type Pico8ExportResult,
16
16
  } from '../engine/pico8/index.js';
17
+ import {
18
+ createShellAdapterFactory,
19
+ PICO8_ENV,
20
+ type Pico8AdapterFactory,
21
+ } from './pico8-env.js';
22
+ import {prepareLabeledCart} from './label-cart.js';
17
23
 
18
24
  /**
19
25
  * Injects the PICO-8 adapter `serve` uses (defaults to the shell adapter). Same
20
26
  * seam as `export`/`run`: a test passes a stub so the export step needs no paid
21
27
  * binary, and the child `env` (`PICO8_PATH`/`PATH`) stays isolated.
22
28
  */
23
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
29
+ export type {Pico8AdapterFactory} from './pico8-env.js';
24
30
 
25
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
26
- new ShellPico8Adapter({env});
31
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
27
32
 
28
33
  /**
29
34
  * Injects the HTTP-server factory `serve` binds. The default is a zero-dep
@@ -174,6 +179,20 @@ export function registerServe(
174
179
  ),
175
180
  }),
176
181
  options: z.object({
182
+ label: z
183
+ .string()
184
+ .optional()
185
+ .describe(
186
+ 'PNG to bake in as the __label__ (the export splash) for a cart that has none: a `run`/`playtest` screenshot as-is, or any 128x128 PNG. The cart itself is not modified. Ignored if the cart already has a label.',
187
+ ),
188
+ labelFrame: z
189
+ .number()
190
+ .int()
191
+ .min(0)
192
+ .optional()
193
+ .describe(
194
+ 'Capture the label from the game itself: run the cart headlessly with NO input and use frame N (0-based: 0 = the first drawn frame, usually the title screen) as the __label__. Needs PICO-8; the cart need not cooperate. Exclusive with --label; ignored if the cart already has a label.',
195
+ ),
177
196
  port: z
178
197
  .number()
179
198
  .int()
@@ -186,15 +205,11 @@ export function registerServe(
186
205
  .int()
187
206
  .positive()
188
207
  .default(DEFAULT_BACKSTOP_MS)
189
- .describe('Safety backstop for the export step.'),
190
- }),
191
- env: z.object({
192
- PICO8_PATH: z
193
- .string()
194
- .optional()
195
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
196
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
208
+ .describe(
209
+ 'Safety backstop for the export step (and the --label-frame capture run).',
210
+ ),
197
211
  }),
212
+ env: PICO8_ENV,
198
213
  output: z.object({
199
214
  url: z.string().describe('The local URL to open in a browser.'),
200
215
  port: z.number().describe('The port the server bound to.'),
@@ -210,6 +225,18 @@ export function registerServe(
210
225
  args: {cart: 'game.p8'},
211
226
  options: {port: 8080},
212
227
  },
228
+ {
229
+ description:
230
+ 'Serve a labelless cart, using a `run` screenshot as the splash',
231
+ args: {cart: 'game.p8'},
232
+ options: {label: './shots/<name>.png'},
233
+ },
234
+ {
235
+ description:
236
+ 'Serve a labelless cart, using its title screen (frame 0) as the splash',
237
+ args: {cart: 'game.p8'},
238
+ options: {labelFrame: 0},
239
+ },
213
240
  ],
214
241
  async run({args, options, env, error, ok}) {
215
242
  const cartPath = isAbsolute(args.cart)
@@ -224,16 +251,48 @@ export function registerServe(
224
251
  });
225
252
  }
226
253
 
227
- // Always export first, into an isolated temp dir (never a user path or
228
- // ~/Desktop). The bundle is the standalone pair the static server plays.
229
- const serveDir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
254
+ // --label / --label-frame: export a labeled throwaway copy (shared with
255
+ // `export`; the cart is never mutated). A bad image or a failed capture
256
+ // fails before anything is exported.
230
257
  const adapter = adapterFactory(env as NodeJS.ProcessEnv);
231
- const result = await adapter.export({
258
+ const labeled = await prepareLabeledCart(
232
259
  cartPath,
233
- outDir: serveDir,
234
- htmlName: EXPORT_HTML_NAME,
235
- backstopMs: options.backstopMs,
236
- });
260
+ {
261
+ label: options.label,
262
+ labelFrame: options.labelFrame,
263
+ backstopMs: options.backstopMs,
264
+ },
265
+ (o) => adapter.drive(o),
266
+ );
267
+ if (!labeled.ok) {
268
+ return error({
269
+ code: labeled.code,
270
+ message: labeled.message,
271
+ exitCode: 1,
272
+ ...(labeled.cta ? {cta: labeled.cta} : {}),
273
+ });
274
+ }
275
+
276
+ // Always export first, into an isolated temp dir (never a user path or
277
+ // ~/Desktop). The bundle is the standalone pair the static server plays.
278
+ // The labeled copy (in the user's cart dir) is removed on every path; a
279
+ // throw also reaps the temp dir, since nothing will be served from it.
280
+ let result: Pico8ExportResult;
281
+ let serveDir: string | undefined;
282
+ try {
283
+ serveDir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
284
+ result = await adapter.export({
285
+ cartPath: labeled.cartPath,
286
+ outDir: serveDir,
287
+ htmlName: EXPORT_HTML_NAME,
288
+ backstopMs: options.backstopMs,
289
+ });
290
+ } catch (e) {
291
+ if (serveDir !== undefined) removeDir(serveDir);
292
+ throw e;
293
+ } finally {
294
+ labeled.cleanup();
295
+ }
237
296
 
238
297
  if (!result.ok) {
239
298
  // Nothing is being served: remove the temp dir now, not on exit.
@@ -250,7 +309,7 @@ export function registerServe(
250
309
  return error({
251
310
  code: 'export-failed',
252
311
  message: result.value.labelWarning
253
- ? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE, bake a label first with `picopilot export --label <128x128.png>` (do NOT hand-edit main.p8), then re-serve.'
312
+ ? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE, give it a splash: `picopilot serve <cart> --label-frame 0` captures the title screen for you, or `picopilot serve <cart> --label <screenshot.png>` takes any path `run`/`playtest` lists under `screenshots` as-is (do NOT hand-edit main.p8).'
254
313
  : 'PICO-8 produced no export bundle. Check the cart runs without a boot error (via `picopilot run`).',
255
314
  exitCode: 1,
256
315
  });
@@ -0,0 +1,68 @@
1
+ import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {Cli} from 'incur';
5
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
6
+ import type {ChildRunner} from '../engine/shrinko/index.js';
7
+ import {createShellShrinkoFactory} from './shrinko-env.js';
8
+ import {registerTokens} from './tokens.js';
9
+
10
+ /**
11
+ * shrinko8 is a Python tool: a `pip install --user` copy is found through HOME
12
+ * (user site-packages), so the child must inherit the parent environment, with
13
+ * the command's resolved PATH overlaid (the same rule as the PICO-8 child,
14
+ * ADR-0017).
15
+ */
16
+
17
+ let dir: string;
18
+ let cartPath: string;
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-shrinko-env-'));
21
+ cartPath = join(dir, 'main.p8');
22
+ writeFileSync(
23
+ cartPath,
24
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n',
25
+ );
26
+ });
27
+ afterEach(() => rmSync(dir, {recursive: true, force: true}));
28
+
29
+ describe('shrinko child env', () => {
30
+ it('HOME and the rest of the parent env reach shrinko; PATH comes from the resolved env', async () => {
31
+ let seen: NodeJS.ProcessEnv | undefined;
32
+ const run: ChildRunner = async (_file, _args, env) => {
33
+ seen = env;
34
+ return {
35
+ code: 0,
36
+ stdout: 'tokens: 3 0%\nchars: 11 0%\ncompressed: 20 0%\n',
37
+ stderr: '',
38
+ spawnError: undefined,
39
+ };
40
+ };
41
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
42
+ registerTokens(
43
+ cli,
44
+ createShellShrinkoFactory({
45
+ parentEnv: {
46
+ HOME: '/home/agent',
47
+ PYTHONUSERBASE: '/home/agent/.local',
48
+ PATH: '/parent/bin',
49
+ },
50
+ run,
51
+ }),
52
+ );
53
+ let stdout = '';
54
+ await cli.serve(['tokens', cartPath, '--json'], {
55
+ stdout(s) {
56
+ stdout += s;
57
+ },
58
+ exit() {},
59
+ env: {PATH: '/resolved/bin'},
60
+ });
61
+ expect(JSON.parse(stdout).tokens).toBe(3);
62
+ expect(seen).toMatchObject({
63
+ HOME: '/home/agent',
64
+ PYTHONUSERBASE: '/home/agent/.local',
65
+ PATH: '/resolved/bin',
66
+ });
67
+ });
68
+ });