picopilot 0.1.5 → 0.2.1
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +205 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +69 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +226 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +4 -9
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +14 -7
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +112 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +118 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +4 -9
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +52 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +4 -9
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -2
- package/src/cli.test.ts +229 -0
- package/src/cli.ts +230 -18
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +86 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +284 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +19 -15
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +192 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +68 -24
- package/src/commands/serve.ts +8 -11
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/shell.test.ts +448 -9
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
package/src/commands/run.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import {execFileSync} from 'node:child_process';
|
|
2
|
-
import {
|
|
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
|
+
});
|
package/src/commands/run.ts
CHANGED
|
@@ -12,20 +12,26 @@ 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
|
+
DISPLAY_OPTION,
|
|
20
|
+
displayUnavailableError,
|
|
21
|
+
PICO8_ENV,
|
|
22
|
+
pico8NotFoundError,
|
|
23
|
+
resolveCommandDisplay,
|
|
24
|
+
type Pico8AdapterFactory,
|
|
25
|
+
} from './pico8-env.js';
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Injects the PICO-8 adapter `run` uses (defaults to the shell adapter over
|
|
21
29
|
* 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
|
|
23
|
-
* to isolate the child `env` (its `PICO8_PATH`/`PATH`).
|
|
30
|
+
* stub to drive the sentinel/backstop/absent paths without the paid binary.
|
|
24
31
|
*/
|
|
25
|
-
export type Pico8AdapterFactory
|
|
32
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
26
33
|
|
|
27
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
28
|
-
new ShellPico8Adapter({env});
|
|
34
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
29
35
|
|
|
30
36
|
/** The default backstop: kill a cart that never signals/exits after this many ms. */
|
|
31
37
|
const DEFAULT_BACKSTOP_MS = 15_000;
|
|
@@ -91,22 +97,17 @@ export function registerRun(
|
|
|
91
97
|
.describe(
|
|
92
98
|
'One-shot scripted input passed as `-p` (the cart reads it via stat(6)) for automated playtests.',
|
|
93
99
|
),
|
|
100
|
+
display: DISPLAY_OPTION,
|
|
94
101
|
recordAudio: z
|
|
95
102
|
.boolean()
|
|
96
103
|
.default(false)
|
|
97
104
|
.describe(
|
|
98
|
-
"Also capture a WAV of the run's audio alongside the screenshots (ADR-0009). A REAL-TIME recording
|
|
105
|
+
"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
106
|
),
|
|
100
107
|
}),
|
|
101
|
-
// incur resolves these from the (test-overridable) env source
|
|
102
|
-
//
|
|
103
|
-
env:
|
|
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
|
-
}),
|
|
108
|
+
// incur resolves these from the (test-overridable) env source; the child
|
|
109
|
+
// gets the parent env with them overlaid (DISPLAY, HOME, ... pass through).
|
|
110
|
+
env: PICO8_ENV,
|
|
110
111
|
output: z.object({
|
|
111
112
|
screenshots: z
|
|
112
113
|
.array(z.string())
|
|
@@ -131,6 +132,24 @@ export function registerRun(
|
|
|
131
132
|
.describe(
|
|
132
133
|
'Whether a non-empty WAV was captured (false unless --record-audio ran and produced audio).',
|
|
133
134
|
),
|
|
135
|
+
audioDisplay: z
|
|
136
|
+
.enum(['native', 'Xvfb', 'xvfb-run'])
|
|
137
|
+
.optional()
|
|
138
|
+
.describe(
|
|
139
|
+
'With --record-audio: which display the -run pass used (native, or a private virtual display).',
|
|
140
|
+
),
|
|
141
|
+
noAudioReason: z
|
|
142
|
+
.string()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe(
|
|
145
|
+
'With --record-audio and no WAV: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
|
|
146
|
+
),
|
|
147
|
+
pico8LogTail: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe(
|
|
151
|
+
"With --record-audio and no WAV: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
|
|
152
|
+
),
|
|
134
153
|
}),
|
|
135
154
|
examples: [
|
|
136
155
|
{description: 'Run main.p8'},
|
|
@@ -141,7 +160,7 @@ export function registerRun(
|
|
|
141
160
|
options: {shotDir: './shots'},
|
|
142
161
|
},
|
|
143
162
|
],
|
|
144
|
-
async run({args, options, env, error, ok}) {
|
|
163
|
+
async run({args, options, env, error, ok, var: vars}) {
|
|
145
164
|
const cartPath = isAbsolute(args.cart)
|
|
146
165
|
? args.cart
|
|
147
166
|
: resolve(process.cwd(), args.cart);
|
|
@@ -166,7 +185,13 @@ export function registerRun(
|
|
|
166
185
|
: mkdtempSync(join(tmpdir(), 'picopilot-run-'));
|
|
167
186
|
if (!existsSync(shotDir)) mkdirSync(shotDir, {recursive: true});
|
|
168
187
|
|
|
169
|
-
|
|
188
|
+
// The display mode only matters to the --record-audio (-run) pass, so a
|
|
189
|
+
// bad PICOPILOT_DISPLAY only fails a run that records.
|
|
190
|
+
const display = resolveCommandDisplay(options.display, vars);
|
|
191
|
+
if (options.recordAudio && !display.ok) return error(display.error);
|
|
192
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
193
|
+
display: display.ok ? display.mode : 'auto',
|
|
194
|
+
});
|
|
170
195
|
const result = await adapter.run({
|
|
171
196
|
cartPath,
|
|
172
197
|
shotDir,
|
|
@@ -194,10 +219,16 @@ export function registerRun(
|
|
|
194
219
|
// uses. The WAV lands in the isolated shotDir alongside the screenshots.
|
|
195
220
|
let wav: string | undefined;
|
|
196
221
|
let audioCaptured = false;
|
|
222
|
+
let noAudioReason: string | undefined;
|
|
223
|
+
let pico8LogTail: string | undefined;
|
|
224
|
+
let audioDisplay: 'native' | 'Xvfb' | 'xvfb-run' | undefined;
|
|
197
225
|
if (options.recordAudio) {
|
|
198
226
|
try {
|
|
227
|
+
// `#include`s are inlined (resolved against the SOURCE cart's folder):
|
|
228
|
+
// the harness runs from shotDir, where `main.lua` does not exist.
|
|
199
229
|
const harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
|
|
200
230
|
sentinel: options.sentinel,
|
|
231
|
+
readInclude: makeReadInclude(cartPath),
|
|
201
232
|
});
|
|
202
233
|
const harnessCart = join(shotDir, 'record-audio-harness.p8');
|
|
203
234
|
writeFileSync(harnessCart, harness.cartText);
|
|
@@ -208,16 +239,26 @@ export function registerRun(
|
|
|
208
239
|
sentinel: options.sentinel,
|
|
209
240
|
backstopMs: options.backstopMs,
|
|
210
241
|
});
|
|
211
|
-
if (rec.ok) {
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
if (!rec.ok) {
|
|
243
|
+
// pico8-not-found is impossible here (the screenshot pass above would
|
|
244
|
+
// have returned it); display-unavailable is the real case: the -run
|
|
245
|
+
// pass needs a display, there is none, and no Xvfb to make one.
|
|
246
|
+
return error(
|
|
247
|
+
rec.reason === 'display-unavailable'
|
|
248
|
+
? displayUnavailableError('run --record-audio', rec)
|
|
249
|
+
: pico8NotFoundError(rec),
|
|
250
|
+
);
|
|
214
251
|
}
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
wav = rec.value.wavPath;
|
|
253
|
+
audioCaptured = rec.value.wavPath !== undefined;
|
|
254
|
+
noAudioReason = rec.value.noWavReason;
|
|
255
|
+
pico8LogTail = rec.value.pico8LogTail;
|
|
256
|
+
audioDisplay = rec.value.display;
|
|
217
257
|
} catch (e) {
|
|
218
258
|
if (!(e instanceof HarnessError)) throw e;
|
|
219
259
|
// A harness-build failure (unparseable cart) leaves audioCaptured false;
|
|
220
260
|
// the screenshot run's report still stands.
|
|
261
|
+
noAudioReason = `the record harness could not be built: ${e.message}`;
|
|
221
262
|
}
|
|
222
263
|
}
|
|
223
264
|
|
|
@@ -277,6 +318,9 @@ export function registerRun(
|
|
|
277
318
|
shotDir,
|
|
278
319
|
wav,
|
|
279
320
|
audioCaptured,
|
|
321
|
+
...(audioDisplay !== undefined ? {audioDisplay} : {}),
|
|
322
|
+
...(noAudioReason !== undefined ? {noAudioReason} : {}),
|
|
323
|
+
...(pico8LogTail !== undefined ? {pico8LogTail} : {}),
|
|
280
324
|
},
|
|
281
325
|
cta === undefined ? undefined : {cta},
|
|
282
326
|
);
|
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
29
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
26
30
|
|
|
27
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
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:
|
|
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
|
+
}
|
package/src/commands/tokens.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
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
|
|
28
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
26
29
|
|
|
27
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
28
|
-
|
|
30
|
+
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
31
|
+
createShellShrinkoFactory();
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Registers `picopilot tokens` (US #3), the first shrinko-backed command.
|
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
|
35
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
33
36
|
|
|
34
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
35
|
-
|
|
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 =
|
|
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
|
|
260
|
-
*
|
|
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
|
+
});
|