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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -16
- 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 +61 -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 +222 -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 +3 -2
- 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 +76 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +71 -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 +46 -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 +1 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +64 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.ts +9 -13
- package/src/commands/gfx.ts +7 -6
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.ts +8 -11
- package/src/commands/run.test.ts +110 -2
- package/src/commands/run.ts +62 -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/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-overview/SKILL.md +9 -1
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {z} from 'incur';
|
|
5
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
6
|
+
import {createCli} from './cli.js';
|
|
7
|
+
import {displayModeOf} from './commands/pico8-env.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The display mode (ADR-0017) is resolved ONCE, globally, by the root CLI:
|
|
11
|
+
* `--display` > `PICOPILOT_DISPLAY` > picopilot.json `display` > `auto`. A probe
|
|
12
|
+
* command mounted on the real CLI reports what a command handler sees.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let dir: string;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-cli-'));
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => rmSync(dir, {recursive: true, force: true}));
|
|
20
|
+
|
|
21
|
+
async function probe(
|
|
22
|
+
argv: string[],
|
|
23
|
+
env: Record<string, string | undefined> = {},
|
|
24
|
+
config?: unknown,
|
|
25
|
+
) {
|
|
26
|
+
const cli = createCli();
|
|
27
|
+
cli.command('probe-display', {
|
|
28
|
+
run: (c) => ({display: displayModeOf(c.var)}),
|
|
29
|
+
});
|
|
30
|
+
const args = [...argv];
|
|
31
|
+
if (config !== undefined) {
|
|
32
|
+
const path = join(dir, 'picopilot.json');
|
|
33
|
+
writeFileSync(path, JSON.stringify(config));
|
|
34
|
+
args.push('--config', path);
|
|
35
|
+
}
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let exitCode = 0;
|
|
38
|
+
await cli.serve([...args, '--json'], {
|
|
39
|
+
stdout(s) {
|
|
40
|
+
stdout += s;
|
|
41
|
+
},
|
|
42
|
+
exit(code) {
|
|
43
|
+
exitCode = code;
|
|
44
|
+
},
|
|
45
|
+
env,
|
|
46
|
+
});
|
|
47
|
+
return {out: JSON.parse(stdout), exitCode};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('display mode resolution through the CLI', () => {
|
|
51
|
+
it('defaults to auto', async () => {
|
|
52
|
+
expect((await probe(['probe-display'])).out.display).toBe('auto');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reads picopilot.json "display"', async () => {
|
|
56
|
+
const {out} = await probe(['probe-display'], {}, {display: 'native'});
|
|
57
|
+
expect(out.display).toBe('native');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('PICOPILOT_DISPLAY beats picopilot.json', async () => {
|
|
61
|
+
const {out} = await probe(
|
|
62
|
+
['probe-display'],
|
|
63
|
+
{PICOPILOT_DISPLAY: 'xvfb'},
|
|
64
|
+
{display: 'native'},
|
|
65
|
+
);
|
|
66
|
+
expect(out.display).toBe('xvfb');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('--display beats both, before or after the command', async () => {
|
|
70
|
+
for (const argv of [
|
|
71
|
+
['--display', 'native', 'probe-display'],
|
|
72
|
+
['probe-display', '--display', 'native'],
|
|
73
|
+
]) {
|
|
74
|
+
const {out} = await probe(
|
|
75
|
+
argv,
|
|
76
|
+
{PICOPILOT_DISPLAY: 'xvfb'},
|
|
77
|
+
{display: 'xvfb'},
|
|
78
|
+
);
|
|
79
|
+
expect(out.display).toBe('native');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('an invalid PICOPILOT_DISPLAY is an error for a display command, not a silent auto', async () => {
|
|
84
|
+
const cli = createCli();
|
|
85
|
+
let stdout = '';
|
|
86
|
+
let exitCode = 0;
|
|
87
|
+
await cli.serve(['run', join(dir, 'none.p8'), '--json'], {
|
|
88
|
+
stdout(s) {
|
|
89
|
+
stdout += s;
|
|
90
|
+
},
|
|
91
|
+
exit(code) {
|
|
92
|
+
exitCode = code;
|
|
93
|
+
},
|
|
94
|
+
env: {PICOPILOT_DISPLAY: 'xfvb'},
|
|
95
|
+
});
|
|
96
|
+
expect(exitCode).not.toBe(0);
|
|
97
|
+
const out = JSON.parse(stdout);
|
|
98
|
+
expect(out.code).toBe('invalid-display-mode');
|
|
99
|
+
expect(out.message).toContain('PICOPILOT_DISPLAY');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('an invalid value does not break commands that never use the display', async () => {
|
|
103
|
+
const cli = createCli();
|
|
104
|
+
let stdout = '';
|
|
105
|
+
let exitCode = 0;
|
|
106
|
+
await cli.serve(['version', '--json'], {
|
|
107
|
+
stdout(s) {
|
|
108
|
+
stdout += s;
|
|
109
|
+
},
|
|
110
|
+
exit(code) {
|
|
111
|
+
exitCode = code;
|
|
112
|
+
},
|
|
113
|
+
env: {PICOPILOT_DISPLAY: 'xfvb'},
|
|
114
|
+
});
|
|
115
|
+
expect(exitCode).toBe(0);
|
|
116
|
+
expect(JSON.parse(stdout).name).toBe('picopilot');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('--no-config ignores the picopilot.json display key', async () => {
|
|
120
|
+
const path = join(dir, 'picopilot.json');
|
|
121
|
+
writeFileSync(path, JSON.stringify({display: 'native'}));
|
|
122
|
+
// First load it (so a stale value could leak), then disable config.
|
|
123
|
+
const cli = createCli();
|
|
124
|
+
cli.command('probe-display', {
|
|
125
|
+
run: (c) => ({display: displayModeOf(c.var)}),
|
|
126
|
+
});
|
|
127
|
+
const outs: string[] = [];
|
|
128
|
+
for (const argv of [
|
|
129
|
+
['probe-display', '--config', path, '--json'],
|
|
130
|
+
['probe-display', '--no-config', '--json'],
|
|
131
|
+
]) {
|
|
132
|
+
let stdout = '';
|
|
133
|
+
await cli.serve(argv, {
|
|
134
|
+
stdout(s) {
|
|
135
|
+
stdout += s;
|
|
136
|
+
},
|
|
137
|
+
exit() {},
|
|
138
|
+
env: {},
|
|
139
|
+
});
|
|
140
|
+
outs.push(JSON.parse(stdout).display);
|
|
141
|
+
}
|
|
142
|
+
expect(outs).toEqual(['native', 'auto']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('--config naming a missing file is an error', async () => {
|
|
146
|
+
const cli = createCli();
|
|
147
|
+
let stdout = '';
|
|
148
|
+
let exitCode = 0;
|
|
149
|
+
await cli.serve(['version', '--config', join(dir, 'nope.json'), '--json'], {
|
|
150
|
+
stdout(s) {
|
|
151
|
+
stdout += s;
|
|
152
|
+
},
|
|
153
|
+
exit(code) {
|
|
154
|
+
exitCode = code;
|
|
155
|
+
},
|
|
156
|
+
env: {},
|
|
157
|
+
});
|
|
158
|
+
expect(exitCode).not.toBe(0);
|
|
159
|
+
expect(stdout).toContain('not found');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('a per-command config option still loads next to the display key', async () => {
|
|
163
|
+
// The custom config loader must keep incur's per-command option defaults.
|
|
164
|
+
const cli = createCli();
|
|
165
|
+
let seen: unknown;
|
|
166
|
+
cli.command('probe-opt', {
|
|
167
|
+
options: z.object({flag: z.boolean().default(false)}),
|
|
168
|
+
run: (c) => {
|
|
169
|
+
seen = c.options.flag;
|
|
170
|
+
return {};
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const path = join(dir, 'picopilot.json');
|
|
174
|
+
writeFileSync(
|
|
175
|
+
path,
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
display: 'native',
|
|
178
|
+
commands: {'probe-opt': {options: {flag: true}}},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
await cli.serve(['probe-opt', '--config', path, '--json'], {
|
|
182
|
+
stdout() {},
|
|
183
|
+
exit() {},
|
|
184
|
+
env: {},
|
|
185
|
+
});
|
|
186
|
+
expect(seen).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {Cli, z} from 'incur';
|
|
2
3
|
import {registerAudio} from './commands/audio.js';
|
|
4
|
+
import {registerDoctor} from './commands/doctor.js';
|
|
3
5
|
import {registerExport} from './commands/export.js';
|
|
4
6
|
import {registerGfx} from './commands/gfx.js';
|
|
5
7
|
import {registerInit} from './commands/init.js';
|
|
@@ -13,8 +15,58 @@ import {registerSfx} from './commands/sfx.js';
|
|
|
13
15
|
import {registerTokens} from './commands/tokens.js';
|
|
14
16
|
import {registerVerify} from './commands/verify.js';
|
|
15
17
|
import {registerVersion} from './commands/version.js';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_DISPLAY_MODE,
|
|
20
|
+
DISPLAY_CONFIG_KEY,
|
|
21
|
+
DISPLAY_ENV_VAR,
|
|
22
|
+
DISPLAY_MODES,
|
|
23
|
+
resolveDisplayMode,
|
|
24
|
+
} from './engine/pico8/display.js';
|
|
16
25
|
import {VERSION} from './version.js';
|
|
17
26
|
|
|
27
|
+
/** The commands that use the display mode (they start a `-run` session, or report on it). */
|
|
28
|
+
const DISPLAY_COMMANDS: ReadonlySet<string> = new Set([
|
|
29
|
+
'run',
|
|
30
|
+
'audio record',
|
|
31
|
+
'audio render',
|
|
32
|
+
'doctor',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads a picopilot.json for incur's config layer AND captures its top-level
|
|
37
|
+
* {@link DISPLAY_CONFIG_KEY}. incur only maps per-command `options` from the
|
|
38
|
+
* config file; the display mode is a GLOBAL setting, so the loader hands the
|
|
39
|
+
* value to the display middleware through `sink`.
|
|
40
|
+
*/
|
|
41
|
+
function loadConfig(
|
|
42
|
+
path: string | undefined,
|
|
43
|
+
sink: {display?: unknown},
|
|
44
|
+
): Record<string, unknown> | undefined {
|
|
45
|
+
sink.display = undefined;
|
|
46
|
+
if (path === undefined) return undefined;
|
|
47
|
+
let raw: string;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFileSync(path, 'utf8');
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// incur only hands the loader an existing file unless --config named one.
|
|
52
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
53
|
+
throw new Error(`Config file not found: ${path}`);
|
|
54
|
+
}
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`Invalid JSON config file: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
64
|
+
throw new Error(`Config file must hold a JSON object: ${path}`);
|
|
65
|
+
}
|
|
66
|
+
sink.display = (parsed as Record<string, unknown>)[DISPLAY_CONFIG_KEY];
|
|
67
|
+
return parsed as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
/**
|
|
19
71
|
* Builds the root picopilot CLI.
|
|
20
72
|
*
|
|
@@ -28,6 +80,7 @@ import {VERSION} from './version.js';
|
|
|
28
80
|
* CLI and drive it with `cli.serve(argv, { stdout, exit, env })` DI overrides.
|
|
29
81
|
*/
|
|
30
82
|
export function createCli(): Cli.Cli {
|
|
83
|
+
const configSink: {display?: unknown} = {};
|
|
31
84
|
const cli = Cli.create('picopilot', {
|
|
32
85
|
version: VERSION,
|
|
33
86
|
description: 'An agent-first toolchain for PICO-8 game development.',
|
|
@@ -39,23 +92,76 @@ export function createCli(): Cli.Cli {
|
|
|
39
92
|
config: {
|
|
40
93
|
flag: 'config',
|
|
41
94
|
files: ['picopilot.json'],
|
|
95
|
+
loader: async (path) => loadConfig(path, configSink),
|
|
42
96
|
},
|
|
97
|
+
// The display mode (ADR-0017) is global: it applies to every command that
|
|
98
|
+
// starts a PICO-8 `-run` session. Precedence: --display > PICOPILOT_DISPLAY
|
|
99
|
+
// > picopilot.json `display` > auto.
|
|
100
|
+
globals: z.object({
|
|
101
|
+
display: z
|
|
102
|
+
.enum(DISPLAY_MODES)
|
|
103
|
+
.optional()
|
|
104
|
+
.describe(
|
|
105
|
+
'How PICO-8 -run sessions (audio record/render, run --record-audio) get a display: auto (default: a private virtual display only when there is none), xvfb (always virtual), native (as-is). Also PICOPILOT_DISPLAY or picopilot.json "display" (under --mcp, only PICOPILOT_DISPLAY applies).',
|
|
106
|
+
),
|
|
107
|
+
}),
|
|
108
|
+
env: z.object({
|
|
109
|
+
[DISPLAY_ENV_VAR]: z
|
|
110
|
+
.string()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('Display mode: auto | xvfb | native (below --display).'),
|
|
113
|
+
}),
|
|
114
|
+
vars: z.object({
|
|
115
|
+
display: z.enum(DISPLAY_MODES).default(DEFAULT_DISPLAY_MODE),
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
cli.use(async (c, next) => {
|
|
120
|
+
const configured = configSink.display;
|
|
121
|
+
configSink.display = undefined; // never leak into a later invocation
|
|
122
|
+
const resolved = resolveDisplayMode({
|
|
123
|
+
// incur's MCP transport passes no globals (and loads no config file), so
|
|
124
|
+
// under `--mcp` only PICOPILOT_DISPLAY applies.
|
|
125
|
+
flag: c.globals?.display,
|
|
126
|
+
env: (c.env as Record<string, string | undefined>)[DISPLAY_ENV_VAR],
|
|
127
|
+
config: configured,
|
|
128
|
+
});
|
|
129
|
+
if (!resolved.ok) {
|
|
130
|
+
// A bad value only fails the commands that use it; the rest run as usual.
|
|
131
|
+
if (!DISPLAY_COMMANDS.has(c.command)) return next();
|
|
132
|
+
// (An invalid --display never gets here: incur's enum rejects it first.)
|
|
133
|
+
const where =
|
|
134
|
+
resolved.source === 'env'
|
|
135
|
+
? DISPLAY_ENV_VAR
|
|
136
|
+
: `picopilot.json "${DISPLAY_CONFIG_KEY}"`;
|
|
137
|
+
return c.error({
|
|
138
|
+
code: 'invalid-display-mode',
|
|
139
|
+
message: `${where} is "${resolved.value}"; expected one of: ${DISPLAY_MODES.join(', ')}.`,
|
|
140
|
+
exitCode: 1,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
c.set('display', resolved.mode);
|
|
144
|
+
await next();
|
|
43
145
|
});
|
|
44
146
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
147
|
+
// The register* helpers take the untyped Cli: the display var is read through
|
|
148
|
+
// `displayModeOf`, so they do not depend on this CLI's generics.
|
|
149
|
+
const root = cli as unknown as Cli.Cli;
|
|
150
|
+
registerVersion(root);
|
|
151
|
+
registerDoctor(root);
|
|
152
|
+
registerInit(root);
|
|
153
|
+
registerGfx(root);
|
|
154
|
+
registerTokens(root);
|
|
155
|
+
registerLint(root);
|
|
156
|
+
registerMinify(root);
|
|
157
|
+
registerVerify(root);
|
|
158
|
+
registerRun(root);
|
|
159
|
+
registerExport(root);
|
|
160
|
+
registerServe(root);
|
|
161
|
+
registerPlaytest(root);
|
|
162
|
+
registerSfx(root);
|
|
163
|
+
registerMusic(root);
|
|
164
|
+
registerAudio(root);
|
|
59
165
|
|
|
60
|
-
return
|
|
166
|
+
return root;
|
|
61
167
|
}
|
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
RecordOptions,
|
|
20
20
|
RunOptions,
|
|
21
21
|
} from '../engine/pico8/index.js';
|
|
22
|
-
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
22
|
+
import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
|
|
23
23
|
import {type Pico8AdapterFactory, registerAudio} from './audio.js';
|
|
24
24
|
|
|
25
25
|
const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
|
|
@@ -290,3 +290,66 @@ describe('audio: honest help text (record-based, NOT an offline export)', () =>
|
|
|
290
290
|
expect(lower).toContain('offline export');
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
describe('audio record/render: headless (ADR-0017)', () => {
|
|
295
|
+
it('record inlines `#include main.lua` so the harness loads from the temp wavDir', async () => {
|
|
296
|
+
writeFileSync(cartPath, `${HEADER}__lua__\n#include main.lua\n`);
|
|
297
|
+
writeFileSync(
|
|
298
|
+
join(dir, 'main.lua'),
|
|
299
|
+
'function _update() from_main=1 end\n',
|
|
300
|
+
);
|
|
301
|
+
const seen: {record?: RecordOptions} = {};
|
|
302
|
+
await runAudio(
|
|
303
|
+
() => stubAdapter(recorded(undefined), seen),
|
|
304
|
+
['audio', 'record', cartPath, '--json'],
|
|
305
|
+
);
|
|
306
|
+
const harness = readFileSync(seen.record!.cartPath, 'utf8');
|
|
307
|
+
expect(harness).not.toMatch(/#include/);
|
|
308
|
+
expect(harness).toContain('from_main=1');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
for (const sub of [
|
|
312
|
+
['audio', 'record'],
|
|
313
|
+
['audio', 'render', '--sfx', '0'],
|
|
314
|
+
]) {
|
|
315
|
+
it(`${sub.join(' ')}: display-unavailable is a structured error naming the fix`, async () => {
|
|
316
|
+
const {stdout, exitCode} = await runAudio(
|
|
317
|
+
() => stubAdapter(displayUnavailable('no display, no Xvfb')),
|
|
318
|
+
[
|
|
319
|
+
...sub.slice(0, 2),
|
|
320
|
+
cartPath,
|
|
321
|
+
...sub.slice(2),
|
|
322
|
+
'--json',
|
|
323
|
+
'--full-output',
|
|
324
|
+
],
|
|
325
|
+
);
|
|
326
|
+
expect(exitCode).not.toBe(0);
|
|
327
|
+
const out = JSON.parse(stdout);
|
|
328
|
+
expect(out.error.code).toBe('display-unavailable');
|
|
329
|
+
expect(out.meta.cta.description).toMatch(/install Xvfb/);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
it('a run with no WAV reports noAudioReason, pico8LogTail and the display used', async () => {
|
|
334
|
+
const {stdout} = await runAudio(
|
|
335
|
+
() =>
|
|
336
|
+
stubAdapter({
|
|
337
|
+
ok: true,
|
|
338
|
+
value: {
|
|
339
|
+
wavPath: undefined,
|
|
340
|
+
printh: '',
|
|
341
|
+
exitReason: 'timeout',
|
|
342
|
+
display: 'Xvfb',
|
|
343
|
+
noWavReason: 'the backstop fired',
|
|
344
|
+
pico8LogTail: 'could not include main.lua',
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
['audio', 'record', cartPath, '--json'],
|
|
348
|
+
);
|
|
349
|
+
const out = JSON.parse(stdout);
|
|
350
|
+
expect(out.captured).toBe(false);
|
|
351
|
+
expect(out.display).toBe('Xvfb');
|
|
352
|
+
expect(out.noAudioReason).toBe('the backstop fired');
|
|
353
|
+
expect(out.pico8LogTail).toContain('main.lua');
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/commands/audio.ts
CHANGED
|
@@ -14,21 +14,28 @@ import {
|
|
|
14
14
|
buildRenderHarness,
|
|
15
15
|
DONE_SENTINEL,
|
|
16
16
|
HarnessError,
|
|
17
|
-
type
|
|
17
|
+
type Pico8RecordResult,
|
|
18
18
|
type RenderTarget,
|
|
19
|
-
ShellPico8Adapter,
|
|
20
19
|
} from '../engine/pico8/index.js';
|
|
20
|
+
import {makeReadInclude} from './cart-includes.js';
|
|
21
|
+
import {
|
|
22
|
+
createShellAdapterFactory,
|
|
23
|
+
displayModeOf,
|
|
24
|
+
displayUnavailableError,
|
|
25
|
+
PICO8_ENV,
|
|
26
|
+
type Pico8AdapterFactory,
|
|
27
|
+
pico8NotFoundError,
|
|
28
|
+
} from './pico8-env.js';
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* Injects the PICO-8 adapter the audio commands use (defaults to the shell
|
|
24
32
|
* adapter over native `pico8 -run`). Mirrors `run`'s `Pico8AdapterFactory` seam:
|
|
25
33
|
* a test passes a fake to drive the record orchestration + the absent path
|
|
26
|
-
* WITHOUT the paid binary
|
|
34
|
+
* WITHOUT the paid binary.
|
|
27
35
|
*/
|
|
28
|
-
export type Pico8AdapterFactory
|
|
36
|
+
export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
29
37
|
|
|
30
|
-
const defaultAdapterFactory: Pico8AdapterFactory = (
|
|
31
|
-
new ShellPico8Adapter({env});
|
|
38
|
+
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* The default record backstop: kill a capture that never signals after this many
|
|
@@ -39,7 +46,7 @@ const DEFAULT_BACKSTOP_MS = 30_000;
|
|
|
39
46
|
|
|
40
47
|
/** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
|
|
41
48
|
const HONEST_CAVEAT =
|
|
42
|
-
'This is a REAL-TIME recording of playback
|
|
49
|
+
'This is a REAL-TIME recording of playback in a -run session, NOT a deterministic offline export. Works headless: with no display, picopilot runs the session on a private virtual display (ADR-0017, --display).';
|
|
43
50
|
|
|
44
51
|
/** Resolves a path argument to an absolute path (relative to cwd). */
|
|
45
52
|
function resolvePath(p: string): string {
|
|
@@ -64,14 +71,8 @@ function chooseWavDir(given: string | undefined): string {
|
|
|
64
71
|
return dir;
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
/** The env schema both audio subcommands share (locates the binary
|
|
68
|
-
const AUDIO_ENV =
|
|
69
|
-
PICO8_PATH: z
|
|
70
|
-
.string()
|
|
71
|
-
.optional()
|
|
72
|
-
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
73
|
-
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
74
|
-
});
|
|
74
|
+
/** The env schema both audio subcommands share (locates the binary). */
|
|
75
|
+
const AUDIO_ENV = PICO8_ENV;
|
|
75
76
|
|
|
76
77
|
/** The structured output shape shared by `record` and `render`. */
|
|
77
78
|
const AUDIO_OUTPUT = z.object({
|
|
@@ -79,7 +80,7 @@ const AUDIO_OUTPUT = z.object({
|
|
|
79
80
|
.string()
|
|
80
81
|
.optional()
|
|
81
82
|
.describe(
|
|
82
|
-
'Absolute path of the recorded WAV, or omitted if no audio was captured (
|
|
83
|
+
'Absolute path of the recorded WAV, or omitted if no audio was captured (see noAudioReason).',
|
|
83
84
|
),
|
|
84
85
|
captured: z
|
|
85
86
|
.boolean()
|
|
@@ -98,41 +99,60 @@ const AUDIO_OUTPUT = z.object({
|
|
|
98
99
|
.number()
|
|
99
100
|
.describe('The record window in seconds the capture ran for.'),
|
|
100
101
|
printh: z.string().describe('Captured stdout from the session.'),
|
|
102
|
+
display: z
|
|
103
|
+
.enum(['native', 'Xvfb', 'xvfb-run'])
|
|
104
|
+
.optional()
|
|
105
|
+
.describe(
|
|
106
|
+
'Which display the -run session used: native, or a private virtual display (Xvfb / xvfb-run).',
|
|
107
|
+
),
|
|
108
|
+
noAudioReason: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
'When no WAV was captured: why (backstop fired / PICO-8 exited before the done-sentinel / empty capture).',
|
|
113
|
+
),
|
|
114
|
+
pico8LogTail: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(
|
|
118
|
+
"When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
|
|
119
|
+
),
|
|
101
120
|
});
|
|
102
121
|
|
|
103
|
-
/** The message a `pico8-not-found` error carries (mirrors `run`): exact remedy + needs. */
|
|
104
|
-
function pico8NotFoundMessage(notFound: {
|
|
105
|
-
remedy: string;
|
|
106
|
-
needs: readonly string[];
|
|
107
|
-
}): string {
|
|
108
|
-
return `PICO-8 is not installed. ${notFound.remedy} (needs: ${notFound.needs.join(', ')})`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
122
|
/**
|
|
112
|
-
* The advisory CTA when a record/render run captured NO audio
|
|
113
|
-
*
|
|
114
|
-
* hear (ADR-0009's live-tier caveat), so it is a nudge, not a hard error.
|
|
123
|
+
* The advisory CTA when a record/render run captured NO audio. The report's
|
|
124
|
+
* `noAudioReason` + `pico8LogTail` say why; this points at the next step.
|
|
115
125
|
*/
|
|
116
|
-
function noAudioCta(
|
|
126
|
+
function noAudioCta(reason: string | undefined) {
|
|
117
127
|
return {
|
|
118
|
-
description: `No audio was captured (${
|
|
128
|
+
description: `No audio was captured${reason !== undefined ? ` (${reason})` : ''}. Next:`,
|
|
119
129
|
commands: [
|
|
120
130
|
{
|
|
121
|
-
command: '
|
|
131
|
+
command: 'verify',
|
|
122
132
|
description:
|
|
123
|
-
'Run
|
|
133
|
+
'Run the static gate: a cart that fails to load or errors never reaches audio_end.',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
command: 'doctor',
|
|
137
|
+
description: 'Check PICO-8 and display capabilities on this machine.',
|
|
124
138
|
},
|
|
125
139
|
],
|
|
126
140
|
};
|
|
127
141
|
}
|
|
128
142
|
|
|
143
|
+
/** Maps a failed record result onto the structured `error(...)` payload. */
|
|
144
|
+
function recordFailure(
|
|
145
|
+
what: string,
|
|
146
|
+
result: Exclude<Pico8RecordResult, {ok: true}>,
|
|
147
|
+
) {
|
|
148
|
+
return result.reason === 'display-unavailable'
|
|
149
|
+
? displayUnavailableError(what, result)
|
|
150
|
+
: pico8NotFoundError(result);
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
/** Shapes an adapter record report into the structured output object. */
|
|
130
154
|
function recordOutput(
|
|
131
|
-
report: {
|
|
132
|
-
wavPath: string | undefined;
|
|
133
|
-
printh: string;
|
|
134
|
-
exitReason: 'sentinel' | 'timeout' | 'exit';
|
|
135
|
-
},
|
|
155
|
+
report: Extract<Pico8RecordResult, {ok: true}>['value'],
|
|
136
156
|
harness: {seconds: number; description: string},
|
|
137
157
|
) {
|
|
138
158
|
return {
|
|
@@ -142,6 +162,13 @@ function recordOutput(
|
|
|
142
162
|
target: harness.description,
|
|
143
163
|
seconds: harness.seconds,
|
|
144
164
|
printh: report.printh,
|
|
165
|
+
...(report.display !== undefined ? {display: report.display} : {}),
|
|
166
|
+
...(report.noWavReason !== undefined
|
|
167
|
+
? {noAudioReason: report.noWavReason}
|
|
168
|
+
: {}),
|
|
169
|
+
...(report.pico8LogTail !== undefined
|
|
170
|
+
? {pico8LogTail: report.pico8LogTail}
|
|
171
|
+
: {}),
|
|
145
172
|
};
|
|
146
173
|
}
|
|
147
174
|
|
|
@@ -154,7 +181,7 @@ function recordOutput(
|
|
|
154
181
|
function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
155
182
|
const audio = Cli.create('audio', {
|
|
156
183
|
description:
|
|
157
|
-
'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings
|
|
184
|
+
'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so both commands are REAL-TIME recordings of a -run session, NOT deterministic offline exports. They work headless: with no display, picopilot runs the session on a private virtual display (ADR-0017). Requires PICO-8.',
|
|
158
185
|
});
|
|
159
186
|
|
|
160
187
|
// --- audio record <cart> ---------------------------------------------------
|
|
@@ -203,7 +230,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
203
230
|
options: {seconds: 4},
|
|
204
231
|
},
|
|
205
232
|
],
|
|
206
|
-
async run({args, options, env, error, ok}) {
|
|
233
|
+
async run({args, options, env, error, ok, var: vars}) {
|
|
207
234
|
const cartPath = resolvePath(args.cart);
|
|
208
235
|
if (!existsSync(cartPath)) {
|
|
209
236
|
return error({
|
|
@@ -215,9 +242,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
215
242
|
|
|
216
243
|
let harness: ReturnType<typeof buildRecordHarness>;
|
|
217
244
|
try {
|
|
245
|
+
// `#include`s are inlined against the SOURCE cart's folder: the harness
|
|
246
|
+
// runs from the isolated wavDir, where `main.lua` does not exist.
|
|
218
247
|
harness = buildRecordHarness(readFileSync(cartPath, 'utf8'), {
|
|
219
248
|
seconds: options.seconds,
|
|
220
249
|
sentinel: options.sentinel,
|
|
250
|
+
readInclude: makeReadInclude(cartPath),
|
|
221
251
|
});
|
|
222
252
|
} catch (e) {
|
|
223
253
|
if (e instanceof HarnessError) {
|
|
@@ -232,7 +262,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
232
262
|
const harnessCart = join(wavDir, 'record-harness.p8');
|
|
233
263
|
writeFileSync(harnessCart, harness.cartText);
|
|
234
264
|
|
|
235
|
-
const adapter = adapterFactory(env as NodeJS.ProcessEnv
|
|
265
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
266
|
+
display: displayModeOf(vars),
|
|
267
|
+
});
|
|
236
268
|
const result = await adapter.record({
|
|
237
269
|
cartPath: harnessCart,
|
|
238
270
|
wavDir,
|
|
@@ -241,18 +273,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
241
273
|
backstopMs: options.backstopMs,
|
|
242
274
|
});
|
|
243
275
|
|
|
244
|
-
if (!result.ok)
|
|
245
|
-
return error({
|
|
246
|
-
code: 'pico8-not-found',
|
|
247
|
-
message: pico8NotFoundMessage(result),
|
|
248
|
-
exitCode: 1,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
276
|
+
if (!result.ok) return error(recordFailure('audio record', result));
|
|
251
277
|
|
|
252
278
|
const out = recordOutput(result.value, harness);
|
|
253
279
|
return ok(
|
|
254
280
|
out,
|
|
255
|
-
out.captured ? undefined : {cta: noAudioCta(
|
|
281
|
+
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
256
282
|
);
|
|
257
283
|
},
|
|
258
284
|
});
|
|
@@ -324,7 +350,7 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
324
350
|
options: {pattern: 2, seconds: 6},
|
|
325
351
|
},
|
|
326
352
|
],
|
|
327
|
-
async run({args, options, env, error, ok}) {
|
|
353
|
+
async run({args, options, env, error, ok, var: vars}) {
|
|
328
354
|
if (options.sfx !== undefined && options.pattern !== undefined) {
|
|
329
355
|
return error({
|
|
330
356
|
code: 'ambiguous-target',
|
|
@@ -368,7 +394,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
368
394
|
const harnessCart = join(wavDir, 'render-harness.p8');
|
|
369
395
|
writeFileSync(harnessCart, harness.cartText);
|
|
370
396
|
|
|
371
|
-
const adapter = adapterFactory(env as NodeJS.ProcessEnv
|
|
397
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
398
|
+
display: displayModeOf(vars),
|
|
399
|
+
});
|
|
372
400
|
const result = await adapter.record({
|
|
373
401
|
cartPath: harnessCart,
|
|
374
402
|
wavDir,
|
|
@@ -377,18 +405,12 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
377
405
|
backstopMs: options.backstopMs,
|
|
378
406
|
});
|
|
379
407
|
|
|
380
|
-
if (!result.ok)
|
|
381
|
-
return error({
|
|
382
|
-
code: 'pico8-not-found',
|
|
383
|
-
message: pico8NotFoundMessage(result),
|
|
384
|
-
exitCode: 1,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
408
|
+
if (!result.ok) return error(recordFailure('audio render', result));
|
|
387
409
|
|
|
388
410
|
const out = recordOutput(result.value, harness);
|
|
389
411
|
return ok(
|
|
390
412
|
out,
|
|
391
|
-
out.captured ? undefined : {cta: noAudioCta(
|
|
413
|
+
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
392
414
|
);
|
|
393
415
|
},
|
|
394
416
|
});
|