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
@@ -0,0 +1,149 @@
1
+ import {Cli} from 'incur';
2
+ import {describe, expect, it} from 'vitest';
3
+ import type {Pico8Adapter, RunOptions} from '../engine/pico8/index.js';
4
+ import {pico8NotFound} from '../engine/pico8/index.js';
5
+ import {shrinkoNotFound} from '../engine/shrinko/index.js';
6
+ import type {ShrinkoAdapter} from '../engine/shrinko/index.js';
7
+ import {type DoctorDeps, registerDoctor} from './doctor.js';
8
+
9
+ /**
10
+ * `doctor` reports what this machine can do, from injected probes: a fake
11
+ * PICO-8 adapter (prints a version id, or is absent), a fake shrinko, a fake
12
+ * executable lookup and a controlled parent env (DISPLAY or not).
13
+ */
14
+
15
+ function pico8Present(versionId = 43): Pico8Adapter {
16
+ return {
17
+ async run(_o: RunOptions) {
18
+ return {
19
+ ok: true,
20
+ value: {
21
+ screenshots: [],
22
+ printh: `__PICOPILOT_PICO8_VERSION__${versionId}\n`,
23
+ exitReason: 'sentinel',
24
+ },
25
+ };
26
+ },
27
+ async record() {
28
+ throw new Error('unused');
29
+ },
30
+ async drive() {
31
+ throw new Error('unused');
32
+ },
33
+ async export() {
34
+ throw new Error('unused');
35
+ },
36
+ };
37
+ }
38
+
39
+ const pico8Absent: Pico8Adapter = {
40
+ ...pico8Present(),
41
+ async run() {
42
+ return pico8NotFound();
43
+ },
44
+ };
45
+
46
+ function shrinko(present: boolean): ShrinkoAdapter {
47
+ return {
48
+ async count() {
49
+ return present
50
+ ? {ok: true, value: {tokens: 1, chars: 1, compressed: 1}}
51
+ : shrinkoNotFound();
52
+ },
53
+ } as unknown as ShrinkoAdapter;
54
+ }
55
+
56
+ async function doctor(deps: DoctorDeps, argv: string[] = []) {
57
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
58
+ registerDoctor(cli, deps);
59
+ let stdout = '';
60
+ let exitCode = 0;
61
+ await cli.serve(['doctor', ...argv, '--json'], {
62
+ stdout(s) {
63
+ stdout += s;
64
+ },
65
+ exit(code) {
66
+ exitCode = code;
67
+ },
68
+ env: {PICO8_PATH: '/opt/pico-8-0.2.7/pico8'},
69
+ });
70
+ return {out: JSON.parse(stdout), exitCode};
71
+ }
72
+
73
+ const finds =
74
+ (...names: string[]) =>
75
+ (n: string) =>
76
+ names.some((x) => n === x || n.endsWith(`/${x}`)) ? n : undefined;
77
+
78
+ describe('picopilot doctor', () => {
79
+ it('headless server with Xvfb: everything is available, -run sessions go virtual', async () => {
80
+ const {out, exitCode} = await doctor({
81
+ pico8: () => pico8Present(43),
82
+ shrinko: () => shrinko(true),
83
+ parentEnv: {},
84
+ find: finds('pico8', 'Xvfb'),
85
+ });
86
+ expect(exitCode).toBe(0);
87
+ expect(out.pico8).toMatchObject({
88
+ found: true,
89
+ runs: true,
90
+ versionId: 43,
91
+ version: '0.2.7',
92
+ });
93
+ expect(out.shrinko8.found).toBe(true);
94
+ expect(out.display).toEqual({
95
+ mode: 'auto',
96
+ native: false,
97
+ virtual: 'Xvfb',
98
+ runSessions: 'xvfb',
99
+ });
100
+ expect(out.unavailable).toEqual([]);
101
+ expect(out.available).toContain('audio record');
102
+ });
103
+
104
+ it('no display and no Xvfb: only the -run commands are unavailable, missing "display"', async () => {
105
+ const {out} = await doctor({
106
+ pico8: () => pico8Present(),
107
+ shrinko: () => shrinko(true),
108
+ parentEnv: {},
109
+ find: finds('pico8'),
110
+ });
111
+ expect(out.display.runSessions).toBe('none');
112
+ expect(
113
+ out.unavailable.map((u: {command: string}) => u.command).sort(),
114
+ ).toEqual(['audio record', 'audio render', 'run --record-audio'].sort());
115
+ expect(out.unavailable[0].missing).toEqual(['display']);
116
+ expect(out.available).toContain('run');
117
+ expect(out.available).toContain('playtest');
118
+ expect(out.available).toContain('export');
119
+ });
120
+
121
+ it('a desktop (DISPLAY set): -run sessions are native', async () => {
122
+ const {out} = await doctor({
123
+ pico8: () => pico8Present(),
124
+ shrinko: () => shrinko(true),
125
+ parentEnv: {DISPLAY: ':0'},
126
+ find: finds('pico8'),
127
+ });
128
+ expect(out.display.native).toBe(true);
129
+ expect(out.display.runSessions).toBe('native');
130
+ });
131
+
132
+ it('no PICO-8 and no shrinko8: reports both as missing, still exits 0 (the report is the result)', async () => {
133
+ const {out, exitCode} = await doctor({
134
+ pico8: () => pico8Absent,
135
+ shrinko: () => shrinko(false),
136
+ parentEnv: {},
137
+ find: () => undefined,
138
+ });
139
+ expect(exitCode).toBe(0);
140
+ expect(out.pico8).toEqual({found: false, runs: false});
141
+ expect(out.shrinko8.found).toBe(false);
142
+ expect(out.available).toEqual([
143
+ 'init',
144
+ 'gfx show / set / render',
145
+ 'sfx',
146
+ 'music',
147
+ ]);
148
+ });
149
+ });
@@ -0,0 +1,280 @@
1
+ import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {type Cli, z} from 'incur';
5
+ import {
6
+ DISPLAY_MODES,
7
+ type DisplayMode,
8
+ detectVirtualDisplayBackend,
9
+ findExecutable,
10
+ hasNativeDisplay,
11
+ needsVirtualDisplay,
12
+ pico8Candidates,
13
+ pico8ChildEnv,
14
+ type VirtualDisplayBackend,
15
+ } from '../engine/pico8/index.js';
16
+ import {
17
+ createShellAdapterFactory,
18
+ displayModeOf,
19
+ PICO8_ENV,
20
+ type Pico8AdapterFactory,
21
+ } from './pico8-env.js';
22
+ import {
23
+ createShellShrinkoFactory,
24
+ type ShrinkoAdapterFactory,
25
+ } from './shrinko-env.js';
26
+
27
+ /** The marker the version-probe cart prints in front of `stat(5)`. */
28
+ const VERSION_MARKER = '__PICOPILOT_PICO8_VERSION__';
29
+
30
+ /** The probe cart: print PICO-8's version id, then the done-sentinel. */
31
+ const PROBE_SENTINEL = '__PICOPILOT_DOCTOR_DONE__';
32
+ const PROBE_CART = `pico-8 cartridge // http://www.pico-8.com
33
+ version 42
34
+ __lua__
35
+ printh("${VERSION_MARKER}"..stat(5))
36
+ printh("${PROBE_SENTINEL}")
37
+ `;
38
+
39
+ /** A tiny cart for the shrinko probe (a real `--count` run). */
40
+ const SHRINKO_PROBE_CART = `pico-8 cartridge // http://www.pico-8.com
41
+ version 42
42
+ __lua__
43
+ print("hi")
44
+ `;
45
+
46
+ /** The doctor's backstop for the headless PICO-8 probe. */
47
+ const PROBE_BACKSTOP_MS = 15_000;
48
+
49
+ /** What a command needs to run (beyond picopilot itself). */
50
+ type Need = 'pico8' | 'shrinko8' | 'display';
51
+
52
+ /**
53
+ * The capability table: every command (or command mode) and what it needs.
54
+ * `display` means a PICO-8 `-run` session: a native display or a virtual one.
55
+ * The `-x` paths (run screenshots, playtest, export, serve) are headless and
56
+ * need PICO-8 only.
57
+ */
58
+ const COMMAND_NEEDS: readonly {command: string; needs: readonly Need[]}[] = [
59
+ {command: 'init', needs: []},
60
+ {command: 'gfx show / set / render', needs: []},
61
+ {command: 'sfx', needs: []},
62
+ {command: 'music', needs: []},
63
+ {command: 'gfx import / export', needs: ['shrinko8']},
64
+ {command: 'tokens', needs: ['shrinko8']},
65
+ {command: 'lint', needs: ['shrinko8']},
66
+ {command: 'minify', needs: ['shrinko8']},
67
+ {command: 'verify', needs: ['shrinko8']},
68
+ {command: 'run', needs: ['pico8']},
69
+ {command: 'playtest', needs: ['pico8']},
70
+ {command: 'export', needs: ['pico8']},
71
+ {command: 'serve', needs: ['pico8']},
72
+ {command: 'run --record-audio', needs: ['pico8', 'display']},
73
+ {command: 'audio record', needs: ['pico8', 'display']},
74
+ {command: 'audio render', needs: ['pico8', 'display']},
75
+ ];
76
+
77
+ /** The PICO-8 version from its install path (e.g. `.../pico-8-0.2.7/...`), if it names one. */
78
+ function versionFromPath(path: string): string | undefined {
79
+ const m = /pico-?8[-_ ]v?(\d+\.\d+\.\d+[a-z]?)/i.exec(path);
80
+ return m?.[1];
81
+ }
82
+
83
+ /** How a `-run` session would get its display here. */
84
+ type RunSessionDisplay = 'native' | 'xvfb' | 'none';
85
+
86
+ /** Dependencies for {@link registerDoctor}, injectable for tests. */
87
+ export interface DoctorDeps {
88
+ readonly pico8?: Pico8AdapterFactory;
89
+ readonly shrinko?: ShrinkoAdapterFactory;
90
+ /** The parent environment (defaults to `process.env`): DISPLAY etc. are read from it. */
91
+ readonly parentEnv?: NodeJS.ProcessEnv;
92
+ /** Executable lookup (defaults to a PATH search). */
93
+ readonly find?: (name: string, env: NodeJS.ProcessEnv) => string | undefined;
94
+ }
95
+
96
+ /**
97
+ * Registers `picopilot doctor`: what can THIS machine do? It reports whether
98
+ * PICO-8 is found (and runs headless, with its version), whether shrinko8 is
99
+ * found, how a PICO-8 `-run` session would get a display (native, a private
100
+ * virtual one via Xvfb, or none), and therefore which commands are available.
101
+ * An agent calls it first instead of discovering limits by failing. It never
102
+ * fails itself: a missing dependency is data in the report.
103
+ */
104
+ export function registerDoctor(cli: Cli.Cli, deps: DoctorDeps = {}): void {
105
+ const pico8Factory =
106
+ deps.pico8 ?? createShellAdapterFactory({parentEnv: deps.parentEnv});
107
+ const shrinkoFactory: ShrinkoAdapterFactory =
108
+ deps.shrinko ?? createShellShrinkoFactory({parentEnv: deps.parentEnv});
109
+ const find = deps.find ?? findExecutable;
110
+
111
+ cli.command('doctor', {
112
+ description:
113
+ 'Report what this machine can do: PICO-8 (found, runs headless, version), shrinko8, the display a PICO-8 -run session would use (native / virtual Xvfb / none), and which picopilot commands are therefore available. Call this first on a new machine.',
114
+ env: PICO8_ENV,
115
+ output: z.object({
116
+ pico8: z.object({
117
+ found: z.boolean().describe('Whether the PICO-8 binary was found.'),
118
+ path: z.string().optional().describe('The resolved binary path.'),
119
+ runs: z
120
+ .boolean()
121
+ .describe('Whether a headless -x probe run actually worked.'),
122
+ version: z
123
+ .string()
124
+ .optional()
125
+ .describe('The PICO-8 version, when its install path names it.'),
126
+ versionId: z
127
+ .number()
128
+ .optional()
129
+ .describe("PICO-8's internal version number (stat(5))."),
130
+ }),
131
+ shrinko8: z.object({
132
+ found: z
133
+ .boolean()
134
+ .describe('Whether shrinko8 ran (token count on a probe cart).'),
135
+ }),
136
+ display: z.object({
137
+ mode: z
138
+ .enum(DISPLAY_MODES)
139
+ .describe('The resolved display mode (--display / env / config).'),
140
+ native: z
141
+ .boolean()
142
+ .describe('Whether DISPLAY or WAYLAND_DISPLAY is set.'),
143
+ virtual: z
144
+ .enum(['Xvfb', 'xvfb-run'])
145
+ .optional()
146
+ .describe('The virtual-display backend available, if any.'),
147
+ runSessions: z
148
+ .enum(['native', 'xvfb', 'none'])
149
+ .describe(
150
+ 'How a PICO-8 -run session (audio record/render, run --record-audio) gets a display here.',
151
+ ),
152
+ }),
153
+ available: z
154
+ .array(z.string())
155
+ .describe('Commands that work on this machine.'),
156
+ unavailable: z
157
+ .array(
158
+ z.object({
159
+ command: z.string(),
160
+ missing: z.array(z.string()),
161
+ }),
162
+ )
163
+ .describe('Commands that do not, and what they are missing.'),
164
+ }),
165
+ async run({env, ok, var: vars}) {
166
+ const display: DisplayMode = displayModeOf(vars);
167
+ const childEnv = pico8ChildEnv(
168
+ env as Record<string, string | undefined>,
169
+ deps.parentEnv ?? process.env,
170
+ );
171
+
172
+ // --- PICO-8: locate, then a real headless probe for the version.
173
+ const [pico8File] = pico8Candidates(childEnv) as [string, ...string[]];
174
+ const pico8Path = find(pico8File, childEnv);
175
+ let runs = false;
176
+ let versionId: number | undefined;
177
+ let version: string | undefined;
178
+ const probeDir = mkdtempSync(join(tmpdir(), 'picopilot-doctor-'));
179
+ try {
180
+ const cartPath = join(probeDir, 'probe.p8');
181
+ writeFileSync(cartPath, PROBE_CART);
182
+ const result = await pico8Factory(env as NodeJS.ProcessEnv, {
183
+ display,
184
+ }).run({
185
+ cartPath,
186
+ shotDir: probeDir,
187
+ sentinel: PROBE_SENTINEL,
188
+ backstopMs: PROBE_BACKSTOP_MS,
189
+ });
190
+ if (result.ok) {
191
+ const m = new RegExp(`${VERSION_MARKER}(\\d+)`).exec(
192
+ result.value.printh,
193
+ );
194
+ if (m !== null) {
195
+ runs = true;
196
+ versionId = Number(m[1]);
197
+ }
198
+ }
199
+ } finally {
200
+ rmSync(probeDir, {recursive: true, force: true});
201
+ }
202
+ if (pico8Path !== undefined) {
203
+ let real = pico8Path;
204
+ try {
205
+ real = realpathSync(pico8Path);
206
+ } catch {
207
+ // keep the unresolved path
208
+ }
209
+ version = versionFromPath(real) ?? versionFromPath(pico8Path);
210
+ }
211
+ const pico8Found = pico8Path !== undefined || runs;
212
+
213
+ // --- shrinko8: a real --count on a tiny cart.
214
+ let shrinkoFound = false;
215
+ const shrinkoDir = mkdtempSync(join(tmpdir(), 'picopilot-doctor-'));
216
+ try {
217
+ const cartPath = join(shrinkoDir, 'probe.p8');
218
+ writeFileSync(cartPath, SHRINKO_PROBE_CART);
219
+ try {
220
+ const count = await shrinkoFactory(env as NodeJS.ProcessEnv).count(
221
+ cartPath,
222
+ );
223
+ shrinkoFound = count.ok;
224
+ } catch {
225
+ // ran but did not parse: present, yet not usable for us
226
+ shrinkoFound = false;
227
+ }
228
+ } finally {
229
+ rmSync(shrinkoDir, {recursive: true, force: true});
230
+ }
231
+
232
+ // --- display: how a -run session would get one here.
233
+ const native = hasNativeDisplay(childEnv);
234
+ const virtual: VirtualDisplayBackend | undefined =
235
+ detectVirtualDisplayBackend(childEnv, find);
236
+ const runSessions: RunSessionDisplay = needsVirtualDisplay(
237
+ display,
238
+ childEnv,
239
+ )
240
+ ? virtual !== undefined
241
+ ? 'xvfb'
242
+ : 'none'
243
+ : native
244
+ ? 'native'
245
+ : 'none';
246
+
247
+ const have: Record<Need, boolean> = {
248
+ pico8: runs,
249
+ shrinko8: shrinkoFound,
250
+ display: runSessions !== 'none',
251
+ };
252
+ const available: string[] = [];
253
+ const unavailable: {command: string; missing: string[]}[] = [];
254
+ for (const {command, needs} of COMMAND_NEEDS) {
255
+ const missing = needs.filter((n) => !have[n]);
256
+ if (missing.length === 0) available.push(command);
257
+ else unavailable.push({command, missing});
258
+ }
259
+
260
+ return ok({
261
+ pico8: {
262
+ found: pico8Found,
263
+ ...(pico8Path !== undefined ? {path: pico8Path} : {}),
264
+ runs,
265
+ ...(version !== undefined ? {version} : {}),
266
+ ...(versionId !== undefined ? {versionId} : {}),
267
+ },
268
+ shrinko8: {found: shrinkoFound},
269
+ display: {
270
+ mode: display,
271
+ native,
272
+ ...(virtual !== undefined ? {virtual} : {}),
273
+ runSessions,
274
+ },
275
+ available,
276
+ unavailable,
277
+ });
278
+ },
279
+ });
280
+ }
@@ -6,20 +6,22 @@ import {
6
6
  EXPORT_HTML_NAME,
7
7
  type Pico8Adapter,
8
8
  type Pico8ExportResult,
9
- ShellPico8Adapter,
10
9
  } from '../engine/pico8/index.js';
10
+ import {
11
+ createShellAdapterFactory,
12
+ PICO8_ENV,
13
+ type Pico8AdapterFactory,
14
+ } from './pico8-env.js';
11
15
  import {prepareLabeledCart} from './label-cart.js';
12
16
 
13
17
  /**
14
18
  * Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
15
19
  * native `pico8 <cart> -export <name>.html -x`). Mirrors the `run` seam: a test
16
- * passes a stub to drive the present/absent paths without the paid binary, and
17
- * to isolate the child `env` (its `PICO8_PATH`/`PATH`).
20
+ * passes a stub to drive the present/absent paths without the paid binary, (the child env is the parent env + PICO8_PATH/PATH).
18
21
  */
19
- export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
22
+ export type {Pico8AdapterFactory} from './pico8-env.js';
20
23
 
21
- const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
22
- new ShellPico8Adapter({env});
24
+ const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
23
25
 
24
26
  /** The default backstop: kill an export that never completes/exits after this many ms. */
25
27
  const DEFAULT_BACKSTOP_MS = 30_000;
@@ -102,13 +104,7 @@ export function registerExport(
102
104
  }),
103
105
  // incur resolves these from the (test-overridable) env source and hands
104
106
  // them to the child, so PICO8_PATH/PATH locate the binary in isolation.
105
- env: z.object({
106
- PICO8_PATH: z
107
- .string()
108
- .optional()
109
- .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
110
- PATH: z.string().optional().describe('Used to locate `pico8`.'),
111
- }),
107
+ env: PICO8_ENV,
112
108
  output: z.object({
113
109
  outDir: z
114
110
  .string()
@@ -20,16 +20,17 @@ import {
20
20
  SHEET_RENDER_SCALE,
21
21
  SPRITE_RENDER_SCALE,
22
22
  } from '../engine/gfx/index.js';
23
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
23
24
  import {
24
- type ShrinkoAdapter,
25
- ShellShrinkoAdapter,
26
- } from '../engine/shrinko/index.js';
25
+ createShellShrinkoFactory,
26
+ type ShrinkoAdapterFactory,
27
+ } from './shrinko-env.js';
27
28
 
28
29
  /** Injects the shrinko adapter `gfx import`/`gfx export` use (the spritesheet round-trip). */
29
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
30
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
30
31
 
31
- const defaultShrinkoFactory: ShrinkoAdapterFactory = (env) =>
32
- new ShellShrinkoAdapter({env});
32
+ const defaultShrinkoFactory: ShrinkoAdapterFactory =
33
+ createShellShrinkoFactory();
33
34
 
34
35
  /**
35
36
  * The exit code for the `map-overlap` refusal. Chosen as 1 (a plain failure),
@@ -1,16 +1,17 @@
1
1
  import {existsSync} from 'node:fs';
2
2
  import {isAbsolute, resolve} from 'node:path';
3
3
  import {type Cli, z} from 'incur';
4
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
4
5
  import {
5
- type ShrinkoAdapter,
6
- ShellShrinkoAdapter,
7
- } from '../engine/shrinko/index.js';
6
+ createShellShrinkoFactory,
7
+ type ShrinkoAdapterFactory,
8
+ } from './shrinko-env.js';
8
9
 
9
10
  /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: lint reuses the same seam. */
10
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
11
12
 
12
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
- new ShellShrinkoAdapter({env});
13
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
14
+ createShellShrinkoFactory();
14
15
 
15
16
  /**
16
17
  * Registers `picopilot lint` (US #4): surface PICO-8-specific issues (undefined
@@ -1,16 +1,17 @@
1
1
  import {existsSync} from 'node:fs';
2
2
  import {dirname, isAbsolute, join, resolve} from 'node:path';
3
3
  import {type Cli, z} from 'incur';
4
+ import {type ShrinkoAdapter} from '../engine/shrinko/index.js';
4
5
  import {
5
- type ShrinkoAdapter,
6
- ShellShrinkoAdapter,
7
- } from '../engine/shrinko/index.js';
6
+ createShellShrinkoFactory,
7
+ type ShrinkoAdapterFactory,
8
+ } from './shrinko-env.js';
8
9
 
9
10
  /** See {@link import('./tokens.js').ShrinkoAdapterFactory}: minify reuses the same seam. */
10
- export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
11
+ export type {ShrinkoAdapterFactory} from './shrinko-env.js';
11
12
 
12
- const defaultAdapterFactory: ShrinkoAdapterFactory = (env) =>
13
- new ShellShrinkoAdapter({env});
13
+ const defaultAdapterFactory: ShrinkoAdapterFactory =
14
+ createShellShrinkoFactory();
14
15
 
15
16
  /**
16
17
  * Derives the default output path `<name>.min.p8` next to the source cart:
@@ -0,0 +1,124 @@
1
+ import {z} from 'incur';
2
+ import {
3
+ DEFAULT_DISPLAY_MODE,
4
+ type DisplayMode,
5
+ isDisplayMode,
6
+ type Pico8Adapter,
7
+ pico8ChildEnv,
8
+ ShellPico8Adapter,
9
+ type SpawnRunner,
10
+ type VirtualDisplayDeps,
11
+ } from '../engine/pico8/index.js';
12
+
13
+ /**
14
+ * The env schema every PICO-8-gated command declares. These are the values
15
+ * picopilot itself RESOLVES (incur reads them from its env source, which tests
16
+ * override); they are NOT the whole child environment. The child gets the
17
+ * parent environment with these overlaid ({@link pico8ChildEnv}), so PICO-8
18
+ * still sees DISPLAY, HOME, XDG_RUNTIME_DIR, SDL_* and the rest.
19
+ */
20
+ export const PICO8_ENV = z.object({
21
+ PICO8_PATH: z
22
+ .string()
23
+ .optional()
24
+ .describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
25
+ PATH: z.string().optional().describe('Used to locate `pico8`.'),
26
+ });
27
+
28
+ /** What a command knows beyond the env when it builds an adapter. */
29
+ export interface Pico8AdapterContext {
30
+ /** The resolved display mode for `-run` sessions (ADR-0017). */
31
+ readonly display: DisplayMode;
32
+ }
33
+
34
+ /**
35
+ * Injects the PICO-8 adapter a command uses. A test passes a stub to drive the
36
+ * sentinel/backstop/absent paths without the paid binary; the default is
37
+ * {@link createShellAdapterFactory}.
38
+ */
39
+ export type Pico8AdapterFactory = (
40
+ env: NodeJS.ProcessEnv,
41
+ context?: Pico8AdapterContext,
42
+ ) => Pico8Adapter;
43
+
44
+ /** Dependencies for {@link createShellAdapterFactory}, injectable for tests. */
45
+ export interface ShellAdapterFactoryDeps {
46
+ /** The parent environment PICO-8 inherits (defaults to `process.env`). */
47
+ readonly parentEnv?: NodeJS.ProcessEnv;
48
+ /** The PICO-8 spawn seam (defaults to the real spawn). */
49
+ readonly spawn?: SpawnRunner;
50
+ /** The virtual-display seam (Xvfb lookup + spawn). */
51
+ readonly virtualDisplay?: VirtualDisplayDeps;
52
+ }
53
+
54
+ /**
55
+ * The default adapter factory: the shell adapter over the user's PICO-8, with
56
+ * the child env = parent env + the command's resolved values, and the display
57
+ * mode from the context.
58
+ */
59
+ export function createShellAdapterFactory(
60
+ deps: ShellAdapterFactoryDeps = {},
61
+ ): Pico8AdapterFactory {
62
+ return (env, context) =>
63
+ new ShellPico8Adapter({
64
+ env: pico8ChildEnv(
65
+ env as Record<string, string | undefined>,
66
+ deps.parentEnv ?? process.env,
67
+ ),
68
+ spawn: deps.spawn,
69
+ display: context?.display ?? DEFAULT_DISPLAY_MODE,
70
+ virtualDisplay: deps.virtualDisplay,
71
+ });
72
+ }
73
+
74
+ /**
75
+ * The display mode a command handler sees. The root middleware in `createCli`
76
+ * resolves it (flag > env > config > default) into the `display` var; a command
77
+ * mounted on a bare CLI (some tests) has no var and gets the default.
78
+ */
79
+ export function displayModeOf(vars: unknown): DisplayMode {
80
+ const value =
81
+ vars !== null && typeof vars === 'object'
82
+ ? (vars as {display?: unknown}).display
83
+ : undefined;
84
+ return isDisplayMode(value) ? value : DEFAULT_DISPLAY_MODE;
85
+ }
86
+
87
+ /**
88
+ * The `error(...)` payload for a `display-unavailable` result (ADR-0017): the
89
+ * session needs a display, there is none, and no Xvfb is installed to provide a
90
+ * virtual one. The message and CTA name the fix, so the agent never has to
91
+ * discover it by a silent timeout.
92
+ */
93
+ export function displayUnavailableError(
94
+ what: string,
95
+ result: {detail: string; remedy: string},
96
+ ) {
97
+ return {
98
+ code: 'display-unavailable' as const,
99
+ message: `${what} needs a display (a real audio+video -run session): ${result.detail}. Remedy: ${result.remedy}.`,
100
+ exitCode: 1,
101
+ cta: {
102
+ description: `No display and no Xvfb on this machine. Fix: ${result.remedy}. Then re-run; picopilot starts a private virtual display by itself (--display auto). Check what this machine can do:`,
103
+ commands: [
104
+ {
105
+ command: 'doctor',
106
+ description:
107
+ 'Report PICO-8, shrinko8 and display capabilities, and which commands are available here.',
108
+ },
109
+ ],
110
+ },
111
+ };
112
+ }
113
+
114
+ /** The `error(...)` payload for a `pico8-not-found` result (the exact remedy + needs). */
115
+ export function pico8NotFoundError(result: {
116
+ remedy: string;
117
+ needs: readonly string[];
118
+ }) {
119
+ return {
120
+ code: 'pico8-not-found' as const,
121
+ message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
122
+ exitCode: 1,
123
+ };
124
+ }