picopilot 0.0.0 → 0.1.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.
Files changed (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +224 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +259 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +482 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +264 -0
  243. package/src/commands/run.ts +285 -0
  244. package/src/commands/serve.test.ts +227 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +278 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,412 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ statSync,
8
+ } from 'node:fs';
9
+ import {tmpdir} from 'node:os';
10
+ import {join} from 'node:path';
11
+ import {
12
+ type DriveOptions,
13
+ type ExitReason,
14
+ EXPORT_HTML_NAME,
15
+ type ExportOptions,
16
+ type Pico8Adapter,
17
+ pico8NotFound,
18
+ type Pico8DriveResult,
19
+ type Pico8ExportResult,
20
+ type Pico8RecordResult,
21
+ type Pico8Result,
22
+ RECORD_WAV_BASENAME,
23
+ type RecordOptions,
24
+ type RunOptions,
25
+ } from './adapter.js';
26
+ import {SentinelWatcher} from './sentinel.js';
27
+
28
+ /**
29
+ * A spawned PICO-8 process, abstracted so tests inject a fake. `onStdout`
30
+ * delivers stdout chunks (both `stdout` and `stderr` folded in, since PICO-8's
31
+ * `printh` and its own lines can land on either); `onClose` fires when the
32
+ * process exits on its own; `onError` fires on a spawn failure (ENOENT ⇒ PICO-8
33
+ * absent). `kill` terminates the process tree (the backstop / sentinel kill).
34
+ */
35
+ export interface Pico8Process {
36
+ onStdout(cb: (chunk: string) => void): void;
37
+ onClose(cb: (code: number | null) => void): void;
38
+ onError(cb: (err: NodeJS.ErrnoException) => void): void;
39
+ kill(): void;
40
+ /**
41
+ * Writes raw bytes to the process's stdin, for the `drive` transport (the
42
+ * fixed-size command blocks). Absent/undefined when stdin was detached
43
+ * (`run`/`record` ignore stdin so the cart never blocks on console input).
44
+ */
45
+ writeStdin?(bytes: Uint8Array): void;
46
+ }
47
+
48
+ /**
49
+ * The process-spawn seam (mirrors shrinko's `ChildRunner`). The real impl spawns
50
+ * `pico8 -x`; a test injects a fake that emits synthetic stdout and a chosen
51
+ * outcome, so the sentinel-kill / backstop / pico8-absent paths are all testable
52
+ * WITHOUT the paid binary. `env` is handed in (its `PICO8_PATH`/`PATH` are the
53
+ * isolation levers), never read ambiently.
54
+ */
55
+ export type SpawnRunner = (
56
+ file: string,
57
+ args: string[],
58
+ env: NodeJS.ProcessEnv,
59
+ options?: {stdin?: boolean},
60
+ ) => Pico8Process;
61
+
62
+ /**
63
+ * The default {@link SpawnRunner}: `spawn` with stdin detached (`ignore`) so the
64
+ * cart never blocks on console input, stdout/stderr piped for the sentinel
65
+ * watch. A spawn failure surfaces via `onError` (ENOENT ⇒ absent). `kill` sends
66
+ * SIGKILL to the whole tree (`detached` + negative pid) so a hung PICO-8 dies.
67
+ */
68
+ /**
69
+ * An isolated directory to hand PICO-8 as its `-home`, so it writes its config /
70
+ * data tree (`config.txt`, `log.txt`, `sdl_controllers.txt`, `backup/`, `carts/`,
71
+ * `bbs/`, `cdata/`, `cstore/`, `plates/`) THERE and never litters the caller's
72
+ * CWD. Without `-home`, PICO-8 creates that tree in whatever directory it was
73
+ * spawned from, e.g. a user's repo root (see
74
+ * work/notes/observations/pico8-appimage-writes-config-into-cwd.md). Verified:
75
+ * `-home <dir>` fully redirects every one of those files/dirs.
76
+ *
77
+ * One dir per process, created lazily under the OS temp dir and reused by every
78
+ * launch (run/drive/export/record/session). It is throwaway config, so OS temp
79
+ * cleanup reclaims it; picopilot never needs its contents.
80
+ */
81
+ let cachedHome: string | undefined;
82
+ export function pico8HomeDir(): string {
83
+ if (cachedHome !== undefined && existsSync(cachedHome)) return cachedHome;
84
+ cachedHome = mkdtempSync(join(tmpdir(), 'picopilot-pico8-home-'));
85
+ return cachedHome;
86
+ }
87
+
88
+ /**
89
+ * Prepend `-home <isolatedDir>` to a PICO-8 arg list so its config/data never
90
+ * land in the CWD. Applied at every spawn site.
91
+ */
92
+ export function withPico8Home(args: string[]): string[] {
93
+ const home = pico8HomeDir();
94
+ mkdirSync(home, {recursive: true});
95
+ return ['-home', home, ...args];
96
+ }
97
+
98
+ export const spawnRunner: SpawnRunner = (file, args, env, options) => {
99
+ // `drive` pipes fixed-size command blocks to stdin; `run`/`record` detach it so
100
+ // the cart never blocks on console input.
101
+ const stdin = options?.stdin === true ? 'pipe' : 'ignore';
102
+ const child = spawn(file, args, {
103
+ env,
104
+ stdio: [stdin, 'pipe', 'pipe'],
105
+ detached: true,
106
+ });
107
+ child.stdout?.setEncoding('utf8');
108
+ child.stderr?.setEncoding('utf8');
109
+ return {
110
+ onStdout(cb) {
111
+ child.stdout?.on('data', cb);
112
+ child.stderr?.on('data', cb);
113
+ },
114
+ onClose(cb) {
115
+ child.on('close', cb);
116
+ },
117
+ onError(cb) {
118
+ child.on('error', cb as (e: Error) => void);
119
+ },
120
+ writeStdin(bytes) {
121
+ try {
122
+ child.stdin?.write(Buffer.from(bytes));
123
+ } catch {
124
+ // A closed stdin (the cart exited) is not fatal to the run.
125
+ }
126
+ },
127
+ kill() {
128
+ try {
129
+ // Negative pid kills the process GROUP (detached), so any child dies too.
130
+ if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL');
131
+ } catch {
132
+ child.kill('SIGKILL'); // fall back to killing just the process
133
+ }
134
+ },
135
+ };
136
+ };
137
+
138
+ /** The candidate ways to launch PICO-8, tried in order: `PICO8_PATH`, then `pico8` on `PATH`. */
139
+ export function pico8Candidates(env: NodeJS.ProcessEnv): string[] {
140
+ const fromEnv = env.PICO8_PATH?.trim();
141
+ return fromEnv !== undefined && fromEnv.length > 0
142
+ ? [fromEnv, 'pico8']
143
+ : ['pico8'];
144
+ }
145
+
146
+ /**
147
+ * The files an export produced in `outDir`, absolute + sorted. PICO-8 writes the
148
+ * `<name>.html` shell and the `<name>.js` runtime payload; we return every file
149
+ * present so the report is faithful even if a future PICO-8 adds siblings.
150
+ */
151
+ function collectExportFiles(outDir: string): string[] {
152
+ if (!existsSync(outDir)) return [];
153
+ return readdirSync(outDir)
154
+ .sort()
155
+ .map((f) => join(outDir, f))
156
+ .filter((p) => {
157
+ try {
158
+ return statSync(p).isFile();
159
+ } catch {
160
+ return false;
161
+ }
162
+ });
163
+ }
164
+
165
+ /** PNG files a run produced in its `-desktop` dir, sorted for deterministic order. */
166
+ function collectScreenshots(shotDir: string): string[] {
167
+ if (!existsSync(shotDir)) return [];
168
+ return readdirSync(shotDir)
169
+ .filter((f) => f.toLowerCase().endsWith('.png'))
170
+ .sort()
171
+ .map((f) => join(shotDir, f));
172
+ }
173
+
174
+ /**
175
+ * The WAV a record run produced in its `wavDir`, or `undefined`. Prefers the
176
+ * expected `<basename>.wav` (what the harness named via `set_filename`); falls
177
+ * back to any single `.wav` present. A well-formed but EMPTY WAV (a headless
178
+ * `-x` capture yields a 44-byte 0-frame RIFF header, ADR-0009) is treated as
179
+ * "no usable WAV" so a silent capture is not reported as a real recording.
180
+ */
181
+ function collectWav(wavDir: string, basename: string): string | undefined {
182
+ if (!existsSync(wavDir)) return undefined;
183
+ const wavs = readdirSync(wavDir).filter((f) =>
184
+ f.toLowerCase().endsWith('.wav'),
185
+ );
186
+ if (wavs.length === 0) return undefined;
187
+ const expected = `${basename}.wav`;
188
+ const chosen = wavs.includes(expected) ? expected : wavs.sort()[0]!;
189
+ const path = join(wavDir, chosen);
190
+ // A 44-byte RIFF header with no PCM data is the empty/silent (headless) WAV.
191
+ try {
192
+ if (statSync(path).size <= 44) return undefined;
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ return path;
197
+ }
198
+
199
+ /** Options for {@link ShellPico8Adapter}. */
200
+ export interface ShellPico8Options {
201
+ /** The environment for the child (its `PICO8_PATH`/`PATH` locate the binary). */
202
+ readonly env: NodeJS.ProcessEnv;
203
+ /** The spawn runner (defaults to {@link spawnRunner}); tests inject a fake. */
204
+ readonly spawn?: SpawnRunner;
205
+ }
206
+
207
+ /**
208
+ * The v1 {@link Pico8Adapter}: shells out to the user's native `pico8 -x`
209
+ * (ADR-0006, native path first). It owns the ORCHESTRATION the command should
210
+ * not hand-roll: launch, stream stdout, kill on the done-sentinel, arm the
211
+ * backstop, and collect screenshots + printh + exit reason into one result.
212
+ *
213
+ * PICO-8 absent (the first candidate ENOENTs and there is no fallback) returns
214
+ * the structured {@link pico8NotFound} value, never a throw or a hang. That
215
+ * absent path is the CI-testable one; live runs are a manual/opt-in tier.
216
+ */
217
+ export class ShellPico8Adapter implements Pico8Adapter {
218
+ private readonly env: NodeJS.ProcessEnv;
219
+ private readonly spawnRun: SpawnRunner;
220
+
221
+ constructor(options: ShellPico8Options) {
222
+ this.env = options.env;
223
+ this.spawnRun = options.spawn ?? spawnRunner;
224
+ }
225
+
226
+ run(options: RunOptions): Promise<Pico8Result> {
227
+ const [file] = pico8Candidates(this.env) as [string, ...string[]];
228
+ const args = [
229
+ '-desktop',
230
+ options.shotDir,
231
+ '-x',
232
+ options.cartPath,
233
+ // One-shot scripted input (the cart reads it via stat(6)); omitted if absent.
234
+ ...(options.input !== undefined ? ['-p', options.input] : []),
235
+ ];
236
+ return this.orchestrate(file, args, options, (exitReason, printh) => ({
237
+ ok: true,
238
+ value: {
239
+ screenshots: collectScreenshots(options.shotDir),
240
+ printh,
241
+ exitReason,
242
+ },
243
+ }));
244
+ }
245
+
246
+ /**
247
+ * Records a running cart's audio to a WAV (ADR-0009). Launches PICO-8 in a
248
+ * REAL A/V session (`-run`, NOT the headless `-x` that mixes no audio) and
249
+ * controls the WAV location with `-root_path <wavDir>`, because
250
+ * `extcmd("audio_end", 1)` saves to PICO-8's CURRENT folder, not `-desktop`
251
+ * (the `audio_end(1)` trap). The cooperating cart (a harness for `record`, or
252
+ * the whole render harness) does the `audio_rec`/`audio_end`/sentinel dance; we
253
+ * watch the sentinel, arm the backstop, and collect `<basename>.wav` from the
254
+ * isolated `wavDir`. Live capture is a manual/opt-in tier; the absent path is
255
+ * the CI-testable one.
256
+ */
257
+ /**
258
+ * Drives a throwaway cart through scripted input and captures live gameplay
259
+ * (ADR-0011). Launches `-desktop <shotDir> -x <cart>` with a LIVE stdin, writes
260
+ * the whole encoded FIXED-SIZE command block stream up front (the one-shot; the
261
+ * cart drains one block per frame and screenshots at the SHOT points), watches
262
+ * the sentinel, arms the backstop, and collects the SHOT PNGs + printh + exit
263
+ * reason. Returns {@link pico8NotFound} when PICO-8 is absent. Live capture is a
264
+ * manual/opt-in tier; the absent path is the CI-testable one.
265
+ */
266
+ drive(options: DriveOptions): Promise<Pico8DriveResult> {
267
+ const [file] = pico8Candidates(this.env) as [string, ...string[]];
268
+ const args = ['-desktop', options.shotDir, '-x', options.cartPath];
269
+ return this.orchestrate<Pico8DriveResult>(
270
+ file,
271
+ args,
272
+ {sentinel: options.sentinel, backstopMs: options.backstopMs},
273
+ (exitReason, printh) => ({
274
+ ok: true,
275
+ value: {
276
+ screenshots: collectScreenshots(options.shotDir),
277
+ printh,
278
+ exitReason,
279
+ },
280
+ }),
281
+ {stdin: true, onSpawn: (proc) => proc.writeStdin?.(options.blocks)},
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Exports the cart as a PICO-8 HTML bundle over the HEADLESS `-x` path (no
287
+ * display needed: `pico8 <cart> -export <outDir>/<name> -x` runs the cart, writes
288
+ * `<name>.html` + `<name>.js`, and exits). PICO-8 derives both file names from
289
+ * the `.html` name, so passing `index.html` yields a directly-serveable pair.
290
+ * A labelless cart prints "please capture a label first" and still exports (a
291
+ * blank splash), surfaced as `labelWarning`. PICO-8 absent -> {@link pico8NotFound}.
292
+ */
293
+ export(options: ExportOptions): Promise<Pico8ExportResult> {
294
+ const [file] = pico8Candidates(this.env) as [string, ...string[]];
295
+ const htmlName = options.htmlName ?? EXPORT_HTML_NAME;
296
+ const outPath = join(options.outDir, htmlName);
297
+ // `-export <name>.html` is a load-and-export command; `-x` makes it headless
298
+ // (run the cart, perform the export, exit) so no window/display is needed.
299
+ const args = ['-export', outPath, '-x', options.cartPath];
300
+ return this.orchestrate<Pico8ExportResult>(
301
+ file,
302
+ args,
303
+ // The export has no cooperative done-sentinel; `-x` exits on its own when
304
+ // finished, and the backstop is the net for a cart that hangs at boot.
305
+ {backstopMs: options.backstopMs},
306
+ (_exitReason, printh) => {
307
+ const files = collectExportFiles(options.outDir);
308
+ const htmlPath = files.find((p) => p.endsWith('.html'));
309
+ const jsPath = files.find((p) => p.endsWith('.js'));
310
+ return {
311
+ ok: true,
312
+ value: {
313
+ htmlPath,
314
+ jsPath,
315
+ files,
316
+ // PICO-8 warns when the cart has no `__label__` to embed as the splash.
317
+ labelWarning: /please capture a label first/i.test(printh),
318
+ },
319
+ };
320
+ },
321
+ );
322
+ }
323
+
324
+ record(options: RecordOptions): Promise<Pico8RecordResult> {
325
+ const [file] = pico8Candidates(this.env) as [string, ...string[]];
326
+ const basename = options.wavBasename ?? RECORD_WAV_BASENAME;
327
+ // `-root_path <wavDir>` steers the `audio_end(1)`-to-current-folder WAV into
328
+ // the isolated temp dir (never ~/Desktop or the carts root). `-run` is a real
329
+ // A/V session (recording needs audio to capture; `-x` yields an empty WAV).
330
+ const args = ['-root_path', options.wavDir, '-run', options.cartPath];
331
+ return this.orchestrate<Pico8RecordResult>(
332
+ file,
333
+ args,
334
+ {sentinel: options.sentinel, backstopMs: options.backstopMs},
335
+ (exitReason, printh) => ({
336
+ ok: true,
337
+ value: {
338
+ wavPath: collectWav(options.wavDir, basename),
339
+ printh,
340
+ exitReason,
341
+ },
342
+ }),
343
+ );
344
+ }
345
+
346
+ /**
347
+ * The shared launch/sentinel-watch/backstop/collect loop behind both
348
+ * {@link run} and {@link record}. `buildOk` turns the exit reason + captured
349
+ * stdout into the command-specific success value; a spawn ENOENT with no
350
+ * fallback resolves the structured {@link pico8NotFound}. `printh` for `run` is
351
+ * filled by the caller from the run report path (kept out of `buildOk` there so
352
+ * the two report shapes stay distinct).
353
+ */
354
+ private orchestrate<R extends {ok: boolean}>(
355
+ file: string,
356
+ args: string[],
357
+ opts: {sentinel?: string; backstopMs: number},
358
+ buildOk: (exitReason: ExitReason, printh: string) => R,
359
+ spawnOpts?: {stdin?: boolean; onSpawn?: (proc: Pico8Process) => void},
360
+ ): Promise<R | ReturnType<typeof pico8NotFound>> {
361
+ const watcher = new SentinelWatcher(opts.sentinel);
362
+
363
+ return new Promise<R | ReturnType<typeof pico8NotFound>>((resolve) => {
364
+ let settled = false;
365
+ let backstop: NodeJS.Timeout | undefined;
366
+ const proc = this.spawnRun(file, withPico8Home(args), this.env, {
367
+ stdin: spawnOpts?.stdin,
368
+ });
369
+
370
+ const finish = (exitReason: ExitReason): void => {
371
+ if (settled) return;
372
+ settled = true;
373
+ if (backstop !== undefined) clearTimeout(backstop);
374
+ resolve(buildOk(exitReason, watcher.text));
375
+ };
376
+
377
+ proc.onError((err) => {
378
+ // A spawn ENOENT ⇒ PICO-8 is absent (a bad PICO8_PATH surfaces as absence
379
+ // with the same remedy, not a silent fall-through). Any other spawn error
380
+ // is a failed run, not a crash.
381
+ if (settled) return;
382
+ if (err.code === 'ENOENT') {
383
+ settled = true;
384
+ if (backstop !== undefined) clearTimeout(backstop);
385
+ resolve(pico8NotFound());
386
+ } else {
387
+ finish('exit');
388
+ }
389
+ });
390
+
391
+ proc.onStdout((chunk) => {
392
+ if (watcher.push(chunk) && !settled) {
393
+ proc.kill(); // sentinel matched: end the run promptly
394
+ finish('sentinel');
395
+ }
396
+ });
397
+
398
+ proc.onClose(() => finish('exit')); // PICO-8 exited on its own
399
+
400
+ // For `drive`: write the command blocks to the live stdin now that it is
401
+ // spawned. A spawn ENOENT resolves via onError before this matters.
402
+ spawnOpts?.onSpawn?.(proc);
403
+
404
+ // Backstop: a cart that neither signals nor exits gets killed.
405
+ backstop = setTimeout(() => {
406
+ if (settled) return;
407
+ proc.kill();
408
+ finish('timeout');
409
+ }, opts.backstopMs);
410
+ });
411
+ }
412
+ }
@@ -0,0 +1,105 @@
1
+ import {mkdtempSync, rmSync} from 'node:fs';
2
+ import {tmpdir} from 'node:os';
3
+ import {join} from 'node:path';
4
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
5
+ import {
6
+ encodeFrame,
7
+ FrameDecoder,
8
+ SESSION_BASE_DIRNAME,
9
+ SessionRegistry,
10
+ type SessionRequest,
11
+ type SessionResponse,
12
+ } from './supervisor.js';
13
+
14
+ /**
15
+ * The session SUPERVISOR's pure pieces are CI-tested without a socket or binary
16
+ * (ADR-0011): the id/path registry (so a session can never escape its controlled
17
+ * base) and the newline-JSON frame codec (so the client<->daemon protocol is
18
+ * exact). The live daemon is the manual/opt-in tier.
19
+ */
20
+
21
+ describe('SessionRegistry: id validation (no path traversal, controlled base)', () => {
22
+ it('accepts filesystem-safe ids', () => {
23
+ for (const id of ['run1', 's-abc123', 'A.b_c-1']) {
24
+ expect(SessionRegistry.validateId(id)).toBe(id);
25
+ }
26
+ });
27
+
28
+ it('rejects traversal + unsafe ids (structured throw)', () => {
29
+ for (const bad of ['..', '.', 'a/b', '../etc', 'a b', '', 'x'.repeat(65)]) {
30
+ expect(() => SessionRegistry.validateId(bad)).toThrow();
31
+ }
32
+ });
33
+
34
+ it('fresh ids are unique + safe', () => {
35
+ const a = SessionRegistry.freshId();
36
+ const b = SessionRegistry.freshId();
37
+ expect(a).not.toBe(b);
38
+ expect(SessionRegistry.validateId(a)).toBe(a);
39
+ });
40
+ });
41
+
42
+ describe('SessionRegistry: path derivation under a controlled base', () => {
43
+ let base: string;
44
+ beforeEach(() => {
45
+ base = mkdtempSync(join(tmpdir(), 'pp-sess-'));
46
+ });
47
+ afterEach(() => rmSync(base, {recursive: true, force: true}));
48
+
49
+ it('resolves a session id to its dir/socket/shots under the base', () => {
50
+ const reg = new SessionRegistry(base);
51
+ const p = reg.resolve('run1');
52
+ expect(p.dir).toBe(join(base, 'run1'));
53
+ expect(p.socket.startsWith(p.dir)).toBe(true);
54
+ expect(p.shotDir.startsWith(p.dir)).toBe(true);
55
+ });
56
+
57
+ it('a resolved socket/shotDir NEVER escapes the base (no traversal path)', () => {
58
+ const reg = new SessionRegistry(base);
59
+ expect(() => reg.resolve('../escape')).toThrow();
60
+ });
61
+
62
+ it('create makes the dir + shotDir and list/remove round-trip', () => {
63
+ const reg = new SessionRegistry(base);
64
+ reg.create('a');
65
+ reg.create('b');
66
+ expect(reg.list()).toEqual(['a', 'b']);
67
+ reg.remove('a');
68
+ expect(reg.list()).toEqual(['b']);
69
+ });
70
+
71
+ it('the default base sits under tmp (never ~/Desktop or the carts root)', () => {
72
+ const reg = new SessionRegistry();
73
+ expect(reg.base).toBe(join(tmpdir(), SESSION_BASE_DIRNAME));
74
+ });
75
+ });
76
+
77
+ describe('the session frame codec (client <-> daemon protocol)', () => {
78
+ it('encodes a request as one newline-terminated JSON frame', () => {
79
+ const req: SessionRequest = {verb: 'step', frames: 30};
80
+ expect(encodeFrame(req)).toBe(`${JSON.stringify(req)}\n`);
81
+ });
82
+
83
+ it('decodes whole frames + buffers a partial one across chunks', () => {
84
+ const a: SessionResponse = {
85
+ ok: true,
86
+ value: {id: 'x', frame: 1, alive: true},
87
+ };
88
+ const b: SessionResponse = {ok: false, code: 'e', message: 'boom'};
89
+ const wire = encodeFrame(a) + encodeFrame(b);
90
+
91
+ // A single push of the whole stream yields both frames, in order.
92
+ expect(new FrameDecoder<SessionResponse>().push(wire)).toEqual([a, b]);
93
+
94
+ // A frame split across two chunks still decodes exactly once (no half frame).
95
+ const dec = new FrameDecoder<SessionResponse>();
96
+ const cut = Math.floor(wire.indexOf('\n') / 2); // mid the FIRST frame
97
+ expect(dec.push(wire.slice(0, cut))).toEqual([]); // no complete frame yet
98
+ expect(dec.push(wire.slice(cut))).toEqual([a, b]); // both complete now
99
+ });
100
+
101
+ it('ignores blank lines between frames', () => {
102
+ const dec = new FrameDecoder<SessionRequest>();
103
+ expect(dec.push('\n\n')).toEqual([]);
104
+ });
105
+ });