picopilot 0.1.5 → 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 (125) 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/doctor.d.ts +22 -0
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/doctor.js +222 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/export.d.ts +3 -4
  13. package/dist/commands/export.d.ts.map +1 -1
  14. package/dist/commands/export.js +4 -9
  15. package/dist/commands/export.js.map +1 -1
  16. package/dist/commands/gfx.d.ts +2 -2
  17. package/dist/commands/gfx.d.ts.map +1 -1
  18. package/dist/commands/gfx.js +3 -2
  19. package/dist/commands/gfx.js.map +1 -1
  20. package/dist/commands/lint.d.ts +2 -2
  21. package/dist/commands/lint.d.ts.map +1 -1
  22. package/dist/commands/lint.js +3 -2
  23. package/dist/commands/lint.js.map +1 -1
  24. package/dist/commands/minify.d.ts +2 -2
  25. package/dist/commands/minify.d.ts.map +1 -1
  26. package/dist/commands/minify.js +3 -2
  27. package/dist/commands/minify.js.map +1 -1
  28. package/dist/commands/pico8-env.d.ts +76 -0
  29. package/dist/commands/pico8-env.d.ts.map +1 -0
  30. package/dist/commands/pico8-env.js +71 -0
  31. package/dist/commands/pico8-env.js.map +1 -0
  32. package/dist/commands/playtest.d.ts +2 -2
  33. package/dist/commands/playtest.d.ts.map +1 -1
  34. package/dist/commands/playtest.js +4 -9
  35. package/dist/commands/playtest.js.map +1 -1
  36. package/dist/commands/run.d.ts +3 -4
  37. package/dist/commands/run.d.ts.map +1 -1
  38. package/dist/commands/run.js +46 -19
  39. package/dist/commands/run.js.map +1 -1
  40. package/dist/commands/serve.d.ts +2 -2
  41. package/dist/commands/serve.d.ts.map +1 -1
  42. package/dist/commands/serve.js +4 -9
  43. package/dist/commands/serve.js.map +1 -1
  44. package/dist/commands/shrinko-env.d.ts +22 -0
  45. package/dist/commands/shrinko-env.d.ts.map +1 -0
  46. package/dist/commands/shrinko-env.js +15 -0
  47. package/dist/commands/shrinko-env.js.map +1 -0
  48. package/dist/commands/tokens.d.ts +4 -4
  49. package/dist/commands/tokens.d.ts.map +1 -1
  50. package/dist/commands/tokens.js +3 -2
  51. package/dist/commands/tokens.js.map +1 -1
  52. package/dist/commands/verify.d.ts +2 -2
  53. package/dist/commands/verify.d.ts.map +1 -1
  54. package/dist/commands/verify.js +3 -2
  55. package/dist/commands/verify.js.map +1 -1
  56. package/dist/engine/child-env.d.ts +14 -0
  57. package/dist/engine/child-env.d.ts.map +1 -0
  58. package/dist/engine/child-env.js +21 -0
  59. package/dist/engine/child-env.js.map +1 -0
  60. package/dist/engine/pico8/adapter.d.ts +23 -3
  61. package/dist/engine/pico8/adapter.d.ts.map +1 -1
  62. package/dist/engine/pico8/adapter.js.map +1 -1
  63. package/dist/engine/pico8/cleanup.d.ts +16 -0
  64. package/dist/engine/pico8/cleanup.d.ts.map +1 -0
  65. package/dist/engine/pico8/cleanup.js +74 -0
  66. package/dist/engine/pico8/cleanup.js.map +1 -0
  67. package/dist/engine/pico8/display.d.ts +149 -0
  68. package/dist/engine/pico8/display.d.ts.map +1 -0
  69. package/dist/engine/pico8/display.js +311 -0
  70. package/dist/engine/pico8/display.js.map +1 -0
  71. package/dist/engine/pico8/drive.d.ts.map +1 -1
  72. package/dist/engine/pico8/drive.js +28 -2
  73. package/dist/engine/pico8/drive.js.map +1 -1
  74. package/dist/engine/pico8/harness.d.ts +8 -0
  75. package/dist/engine/pico8/harness.d.ts.map +1 -1
  76. package/dist/engine/pico8/harness.js +9 -1
  77. package/dist/engine/pico8/harness.js.map +1 -1
  78. package/dist/engine/pico8/index.d.ts +3 -0
  79. package/dist/engine/pico8/index.d.ts.map +1 -1
  80. package/dist/engine/pico8/index.js +3 -0
  81. package/dist/engine/pico8/index.js.map +1 -1
  82. package/dist/engine/pico8/shell.d.ts +56 -3
  83. package/dist/engine/pico8/shell.d.ts.map +1 -1
  84. package/dist/engine/pico8/shell.js +188 -24
  85. package/dist/engine/pico8/shell.js.map +1 -1
  86. package/dist/engine/shrinko/shell.d.ts +3 -3
  87. package/dist/engine/shrinko/shell.js +3 -3
  88. package/package.json +1 -1
  89. package/src/cli.test.ts +188 -0
  90. package/src/cli.ts +122 -16
  91. package/src/commands/audio.test.ts +64 -1
  92. package/src/commands/audio.ts +79 -57
  93. package/src/commands/doctor.test.ts +149 -0
  94. package/src/commands/doctor.ts +280 -0
  95. package/src/commands/export.ts +9 -13
  96. package/src/commands/gfx.ts +7 -6
  97. package/src/commands/lint.ts +7 -6
  98. package/src/commands/minify.ts +7 -6
  99. package/src/commands/pico8-env.ts +124 -0
  100. package/src/commands/playtest.ts +8 -11
  101. package/src/commands/run.test.ts +110 -2
  102. package/src/commands/run.ts +62 -24
  103. package/src/commands/serve.ts +8 -11
  104. package/src/commands/shrinko-env.test.ts +68 -0
  105. package/src/commands/shrinko-env.ts +40 -0
  106. package/src/commands/tokens.ts +9 -6
  107. package/src/commands/verify.ts +7 -4
  108. package/src/engine/child-env.ts +22 -0
  109. package/src/engine/pico8/adapter.ts +25 -3
  110. package/src/engine/pico8/cleanup.test.ts +28 -0
  111. package/src/engine/pico8/cleanup.ts +76 -0
  112. package/src/engine/pico8/display.test.ts +210 -0
  113. package/src/engine/pico8/display.ts +429 -0
  114. package/src/engine/pico8/drive.test.ts +20 -0
  115. package/src/engine/pico8/drive.ts +25 -2
  116. package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
  117. package/src/engine/pico8/harness.test.ts +26 -0
  118. package/src/engine/pico8/harness.ts +18 -2
  119. package/src/engine/pico8/index.ts +3 -0
  120. package/src/engine/pico8/shell.test.ts +448 -9
  121. package/src/engine/pico8/shell.ts +255 -26
  122. package/src/engine/pico8/virtual-display.process.test.ts +206 -0
  123. package/src/engine/shrinko/shell.ts +3 -3
  124. package/src/skills/picopilot-audio/SKILL.md +4 -8
  125. package/src/skills/picopilot-overview/SKILL.md +9 -1
@@ -22,12 +22,16 @@ import {
22
22
  SessionRegistry,
23
23
  sessionExists,
24
24
  SHOT_BASENAME,
25
- ShellPico8Adapter,
26
25
  spawnSessionDaemon,
27
26
  type SessionRequest,
28
27
  type SessionResponse,
29
28
  type SessionResponseValue,
30
29
  } from '../engine/pico8/index.js';
30
+ import {
31
+ createShellAdapterFactory,
32
+ PICO8_ENV,
33
+ type Pico8AdapterFactory,
34
+ } from './pico8-env.js';
31
35
  import {makeReadInclude} from './cart-includes.js';
32
36
 
33
37
  /**
@@ -36,10 +40,9 @@ import {makeReadInclude} from './cart-includes.js';
36
40
  * stub to drive the drive-orchestration + the absent path WITHOUT the paid
37
41
  * binary, and to isolate the child `env` (its `PICO8_PATH`/`PATH`).
38
42
  */
39
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
43
+ export type {Pico8AdapterFactory} from './pico8-env.js';
40
44
 
41
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
42
- new ShellPico8Adapter({env});
45
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
43
46
 
44
47
  /**
45
48
  * The default backstop: kill a driven cart that never signals/exits after this
@@ -219,13 +222,7 @@ function buildPlaytestGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
219
222
  }
220
223
 
221
224
  /** The env schema the whole `playtest` group shares (locates the binary, isolated). */
222
- const PLAYTEST_ENV = z.object({
223
- PICO8_PATH: z
224
- .string()
225
- .optional()
226
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
227
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
228
- });
225
+ const PLAYTEST_ENV = PICO8_ENV;
229
226
 
230
227
  /**
231
228
  * The ONE-SHOT root handler (unchanged behaviour from the one-shot task): build
@@ -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). */
@@ -268,3 +275,104 @@ describe('picopilot run: cart-not-found (picopilot-side error, distinct from pic
268
275
  expect(snap()).toEqual(before); // same entries: an absent dir stays absent, a present one unchanged
269
276
  });
270
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
  );
@@ -13,8 +13,12 @@ import {
13
13
  EXPORT_HTML_NAME,
14
14
  type Pico8Adapter,
15
15
  type Pico8ExportResult,
16
- ShellPico8Adapter,
17
16
  } from '../engine/pico8/index.js';
17
+ import {
18
+ createShellAdapterFactory,
19
+ PICO8_ENV,
20
+ type Pico8AdapterFactory,
21
+ } from './pico8-env.js';
18
22
  import {prepareLabeledCart} from './label-cart.js';
19
23
 
20
24
  /**
@@ -22,10 +26,9 @@ import {prepareLabeledCart} from './label-cart.js';
22
26
  * seam as `export`/`run`: a test passes a stub so the export step needs no paid
23
27
  * binary, and the child `env` (`PICO8_PATH`/`PATH`) stays isolated.
24
28
  */
25
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
29
+ export type {Pico8AdapterFactory} from './pico8-env.js';
26
30
 
27
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
28
- new ShellPico8Adapter({env});
31
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
29
32
 
30
33
  /**
31
34
  * Injects the HTTP-server factory `serve` binds. The default is a zero-dep
@@ -206,13 +209,7 @@ export function registerServe(
206
209
  'Safety backstop for the export step (and the --label-frame capture run).',
207
210
  ),
208
211
  }),
209
- env: z.object({
210
- PICO8_PATH: z
211
- .string()
212
- .optional()
213
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
214
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
215
- }),
212
+ env: PICO8_ENV,
216
213
  output: z.object({
217
214
  url: z.string().describe('The local URL to open in a browser.'),
218
215
  port: z.number().describe('The port the server bound to.'),
@@ -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
+ });
@@ -0,0 +1,40 @@
1
+ import {overlayEnv} from '../engine/child-env.js';
2
+ import {
3
+ type ChildRunner,
4
+ type ShrinkoAdapter,
5
+ ShellShrinkoAdapter,
6
+ } from '../engine/shrinko/index.js';
7
+
8
+ /**
9
+ * Builds the {@link ShrinkoAdapter} a shrinko-backed command uses, given the
10
+ * incur-resolved env (its `PATH`). A test passes its own factory to swap in a
11
+ * stub adapter (the seam-swap contract).
12
+ */
13
+ export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
14
+
15
+ /** Dependencies for {@link createShellShrinkoFactory}, injectable for tests. */
16
+ export interface ShellShrinkoFactoryDeps {
17
+ /** The parent environment shrinko inherits (defaults to `process.env`). */
18
+ readonly parentEnv?: NodeJS.ProcessEnv;
19
+ /** The child runner (defaults to the real `execFile`). */
20
+ readonly run?: ChildRunner;
21
+ }
22
+
23
+ /**
24
+ * The default shrinko factory: the shell adapter over a user-installed
25
+ * shrinko8, whose child env is the parent env overlaid with the command's
26
+ * resolved `PATH`. shrinko8 is a Python tool, so a `pip install --user` copy is
27
+ * only found when HOME (user site-packages) reaches the child.
28
+ */
29
+ export function createShellShrinkoFactory(
30
+ deps: ShellShrinkoFactoryDeps = {},
31
+ ): ShrinkoAdapterFactory {
32
+ return (env) =>
33
+ new ShellShrinkoAdapter({
34
+ env: overlayEnv(
35
+ env as Record<string, string | undefined>,
36
+ deps.parentEnv ?? process.env,
37
+ ),
38
+ run: deps.run,
39
+ });
40
+ }
@@ -5,16 +5,19 @@ import {type Cli, z} from 'incur';
5
5
  import {
6
6
  type ShrinkoAdapter,
7
7
  ShrinkoParseError,
8
- ShellShrinkoAdapter,
9
8
  } from '../engine/shrinko/index.js';
9
+ import {
10
+ createShellShrinkoFactory,
11
+ type ShrinkoAdapterFactory,
12
+ } from './shrinko-env.js';
10
13
 
11
14
  /** PICO-8's Lua token budget. A cart over this will not load in PICO-8 (US #3). */
12
15
  export const TOKEN_BUDGET = 8192;
13
16
 
14
17
  /**
15
18
  * Builds the {@link ShrinkoAdapter} a command uses, given the incur-resolved
16
- * env. The default is the shell-out {@link ShellShrinkoAdapter}, whose child
17
- * process inherits ONLY this `env` (its `PATH` is the test isolation lever).
19
+ * env. The default is the shell-out `ShellShrinkoAdapter`, whose child process
20
+ * gets the parent env with this resolved `PATH` overlaid (see `shrinko-env.ts`).
18
21
  *
19
22
  * A test passes its own factory to {@link registerTokens} to SWAP a stub
20
23
  * adapter (the seam-swap contract): the command's public output/exit must be
@@ -22,10 +25,10 @@ export const TOKEN_BUDGET = 8192;
22
25
  * seam, because the command only ever talks to the {@link ShrinkoAdapter}
23
26
  * interface.
24
27
  */
25
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
28
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
26
29
 
27
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
28
- new ShellShrinkoAdapter({env});
30
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
31
+ createShellShrinkoFactory();
29
32
 
30
33
  /**
31
34
  * Registers `picopilot tokens` (US #3), the first shrinko-backed command.
@@ -6,8 +6,11 @@ import {Cart, CartParseError} from '../engine/cart/index.js';
6
6
  import {
7
7
  type ShrinkoAdapter,
8
8
  ShrinkoParseError,
9
- ShellShrinkoAdapter,
10
9
  } from '../engine/shrinko/index.js';
10
+ import {
11
+ createShellShrinkoFactory,
12
+ type ShrinkoAdapterFactory,
13
+ } from './shrinko-env.js';
11
14
  import {TOKEN_BUDGET} from './tokens.js';
12
15
 
13
16
  /**
@@ -29,10 +32,10 @@ export const VERIFY_SCOPE =
29
32
  export const GATE_INCAPABLE_EXIT = 2;
30
33
 
31
34
  /** See {@link ShrinkoAdapterFactory} in `tokens.ts` — verify reuses the same seam. */
32
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
35
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
33
36
 
34
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
35
- new ShellShrinkoAdapter({env});
37
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
38
+ createShellShrinkoFactory();
36
39
 
37
40
  /**
38
41
  * The result of the integrity check: the cart parses AND round-trips
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The environment an external tool (PICO-8, shrinko8) is spawned with: the
3
+ * PARENT environment overlaid with the values a command RESOLVED (incur parses
4
+ * only the keys a command declares, e.g. PICO8_PATH / PATH). Handing the child
5
+ * just that parsed subset strips everything else it needs: DISPLAY, HOME,
6
+ * XDG_RUNTIME_DIR and SDL_* for PICO-8 (ADR-0017), HOME / PYTHONUSERBASE for a
7
+ * `pip install --user` shrinko8. An `undefined` resolved value never erases a
8
+ * parent value.
9
+ *
10
+ * `parent` is a parameter (the command layer defaults it to `process.env`; it is
11
+ * never read here) so tests inject a controlled environment.
12
+ */
13
+ export function overlayEnv(
14
+ resolved: Readonly<Record<string, string | undefined>>,
15
+ parent: NodeJS.ProcessEnv,
16
+ ): NodeJS.ProcessEnv {
17
+ const env: NodeJS.ProcessEnv = {...parent};
18
+ for (const [key, value] of Object.entries(resolved)) {
19
+ if (value !== undefined) env[key] = value;
20
+ }
21
+ return env;
22
+ }
@@ -12,6 +12,8 @@
12
12
  * a normal, well-signposted boundary (US #14, #19), mirroring shrinko-not-found.
13
13
  */
14
14
 
15
+ import type {DisplayUnavailable, VirtualDisplayBackend} from './display.js';
16
+
15
17
  /** The remedy string in a pico8-not-found result: how the user makes `run` work. */
16
18
  export const PICO8_REMEDY = 'set PICO8_PATH or install PICO-8' as const;
17
19
 
@@ -75,6 +77,23 @@ export interface RecordReport {
75
77
  readonly wavPath: string | undefined;
76
78
  readonly printh: string;
77
79
  readonly exitReason: ExitReason;
80
+ /**
81
+ * Which display the `-run` session used (ADR-0017): `native` (the caller's
82
+ * own DISPLAY/WAYLAND_DISPLAY, or none) or a private virtual display (`Xvfb`
83
+ * spawned directly, or the `xvfb-run` fallback). Omitted by fakes.
84
+ */
85
+ readonly display?: 'native' | VirtualDisplayBackend;
86
+ /**
87
+ * Set only when `wavPath` is undefined: WHY no WAV was captured (the backstop
88
+ * fired before the done-sentinel, PICO-8 exited early, or the sentinel was
89
+ * reached but the WAV was missing/empty), so a silent failure always says why.
90
+ */
91
+ readonly noWavReason?: string;
92
+ /**
93
+ * Set only when `wavPath` is undefined: the tail of PICO-8's own `log.txt`
94
+ * from the isolated `-home` (load errors, SDL/audio init failures).
95
+ */
96
+ readonly pico8LogTail?: string;
78
97
  }
79
98
 
80
99
  /** A successful audio-record call carrying the {@link RecordReport}. */
@@ -132,7 +151,8 @@ export interface DriveOptions {
132
151
  * The result {@link Pico8Adapter.record} returns: a {@link RecordReport} or the
133
152
  * structured {@link Pico8NotFound} (PICO-8 absent), mirroring {@link Pico8Result}.
134
153
  */
135
- export type Pico8RecordResult = Pico8RecordOk | Pico8NotFound;
154
+ export type Pico8RecordResult =
155
+ Pico8RecordOk | Pico8NotFound | DisplayUnavailable;
136
156
 
137
157
  /**
138
158
  * Options for a single {@link Pico8Adapter.record} (ADR-0009). The recorded cart
@@ -256,8 +276,10 @@ export interface Pico8Adapter {
256
276
  * `-x`, which mixes no audio and yields an empty WAV, ADR-0009), streams
257
277
  * stdout, ends on the sentinel (backstop as the net), and collects the WAV the
258
278
  * cooperating cart wrote via `audio_rec`/`audio_end(1)`. Returns
259
- * {@link Pico8NotFound} when PICO-8 is not installed. Live capture is a
260
- * manual/opt-in tier; CI drives a fake runner + the absent boundary.
279
+ * {@link Pico8NotFound} when PICO-8 is not installed, and
280
+ * {@link DisplayUnavailable} when the session needs a display, there is none,
281
+ * and no Xvfb is installed to provide a virtual one (ADR-0017). CI drives a
282
+ * fake runner + fake Xvfb; a live capture needs the paid binary.
261
283
  */
262
284
  record(options: RecordOptions): Promise<Pico8RecordResult>;
263
285
 
@@ -0,0 +1,28 @@
1
+ import {afterEach, describe, expect, it} from 'vitest';
2
+ import {killOnAbort} from './cleanup.js';
3
+
4
+ /** The kill-on-abort registry: one listener per event, however many runs. */
5
+ describe('killOnAbort', () => {
6
+ const disposers: (() => void)[] = [];
7
+ afterEach(() => {
8
+ for (const d of disposers.splice(0)) d();
9
+ });
10
+
11
+ it('many concurrent runs add ONE listener per signal, removed when all end', () => {
12
+ const before = process.listenerCount('SIGTERM');
13
+ for (let i = 0; i < 20; i++) disposers.push(killOnAbort(() => {}));
14
+ expect(process.listenerCount('SIGTERM')).toBe(before + 1);
15
+ expect(process.listenerCount('exit')).toBeGreaterThan(0);
16
+ for (const d of disposers.splice(0)) d();
17
+ expect(process.listenerCount('SIGTERM')).toBe(before);
18
+ });
19
+
20
+ it('kills every pending run on exit, and a disposed run is not killed', () => {
21
+ const killed: number[] = [];
22
+ const d1 = killOnAbort(() => killed.push(1));
23
+ disposers.push(killOnAbort(() => killed.push(2)));
24
+ d1();
25
+ process.emit('exit', 0);
26
+ expect(killed).toEqual([2]);
27
+ });
28
+ });