picopilot 0.0.0 → 0.1.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 (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 +202 -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 +217 -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 +460 -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 +238 -0
  243. package/src/commands/run.ts +259 -0
  244. package/src/commands/serve.test.ts +218 -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 +236 -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
package/src/bin.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import {createCli} from './cli.js';
3
+
4
+ createCli().serve();
package/src/cli.ts ADDED
@@ -0,0 +1,61 @@
1
+ import {Cli} from 'incur';
2
+ import {registerAudio} from './commands/audio.js';
3
+ import {registerExport} from './commands/export.js';
4
+ import {registerGfx} from './commands/gfx.js';
5
+ import {registerInit} from './commands/init.js';
6
+ import {registerLint} from './commands/lint.js';
7
+ import {registerMinify} from './commands/minify.js';
8
+ import {registerMusic} from './commands/music.js';
9
+ import {registerPlaytest} from './commands/playtest.js';
10
+ import {registerRun} from './commands/run.js';
11
+ import {registerServe} from './commands/serve.js';
12
+ import {registerSfx} from './commands/sfx.js';
13
+ import {registerTokens} from './commands/tokens.js';
14
+ import {registerVerify} from './commands/verify.js';
15
+ import {registerVersion} from './commands/version.js';
16
+ import {VERSION} from './version.js';
17
+
18
+ /**
19
+ * Builds the root picopilot CLI.
20
+ *
21
+ * One `Cli.create()` definition yields the CLI, an MCP server, auto-installable
22
+ * skills, TOON output, Zod schemas, CTAs, and the structured error envelope.
23
+ * Command groups are mounted here through their `register*` helpers; each group
24
+ * owns its own module under `src/commands/` and its engine under `src/engine/`,
25
+ * so later tasks add a line here without restructuring.
26
+ *
27
+ * Exposed as a factory (not a module-level singleton) so tests can build a fresh
28
+ * CLI and drive it with `cli.serve(argv, { stdout, exit, env })` DI overrides.
29
+ */
30
+ export function createCli(): Cli.Cli {
31
+ const cli = Cli.create('picopilot', {
32
+ version: VERSION,
33
+ description: 'An agent-first toolchain for PICO-8 game development.',
34
+ // `depth: 1` yields one skill file per top-level command group (US #20).
35
+ sync: {
36
+ depth: 1,
37
+ },
38
+ // Per-cart policy config (e.g. `allowMapOverlap`); precedence argv > config > defaults.
39
+ config: {
40
+ flag: 'config',
41
+ files: ['picopilot.json'],
42
+ },
43
+ });
44
+
45
+ registerVersion(cli);
46
+ registerInit(cli);
47
+ registerGfx(cli);
48
+ registerTokens(cli);
49
+ registerLint(cli);
50
+ registerMinify(cli);
51
+ registerVerify(cli);
52
+ registerRun(cli);
53
+ registerExport(cli);
54
+ registerServe(cli);
55
+ registerPlaytest(cli);
56
+ registerSfx(cli);
57
+ registerMusic(cli);
58
+ registerAudio(cli);
59
+
60
+ return cli;
61
+ }
@@ -0,0 +1,280 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import {homedir, tmpdir} from 'node:os';
10
+ import {join} from 'node:path';
11
+ import {Cli} from 'incur';
12
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
13
+ import {mmlToSfxRow} from '../engine/audio/sfx.js';
14
+ import {mergeSfxRow} from '../engine/audio/section.js';
15
+ import {Cart} from '../engine/cart/index.js';
16
+ import type {
17
+ Pico8Adapter,
18
+ Pico8RecordResult,
19
+ RecordOptions,
20
+ RunOptions,
21
+ } from '../engine/pico8/index.js';
22
+ import {pico8NotFound} from '../engine/pico8/index.js';
23
+ import {type Pico8AdapterFactory, registerAudio} from './audio.js';
24
+
25
+ const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
26
+
27
+ let dir: string;
28
+ let cartPath: string;
29
+
30
+ /** A cart with SFX slot 0 authored, so `render --sfx 0` has something to render. */
31
+ beforeEach(() => {
32
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-audio-'));
33
+ cartPath = join(dir, 'main.p8');
34
+ const cart = Cart.parse(`${HEADER}__lua__\nfunction _update() end\n`);
35
+ mergeSfxRow(cart, 0, mmlToSfxRow('s8 @1 v6 c e g').row);
36
+ writeFileSync(cartPath, cart.serialize());
37
+ });
38
+
39
+ afterEach(() => {
40
+ try {
41
+ execFileSync('rm', ['-rf', dir]);
42
+ } catch {
43
+ // ignore
44
+ }
45
+ });
46
+
47
+ /**
48
+ * A stub {@link Pico8Adapter} that never spawns PICO-8, so no test depends on the
49
+ * paid binary. It RECORDS the {@link RecordOptions} it was handed (for the
50
+ * isolation + orchestration assertions) and returns a chosen result.
51
+ */
52
+ function stubAdapter(
53
+ recordResult: Pico8RecordResult,
54
+ seen?: {record?: RecordOptions},
55
+ ): Pico8Adapter {
56
+ return {
57
+ async run(_options: RunOptions) {
58
+ return {
59
+ ok: true,
60
+ value: {screenshots: [], printh: '', exitReason: 'sentinel'},
61
+ };
62
+ },
63
+ async record(options) {
64
+ if (seen !== undefined) seen.record = options;
65
+ return recordResult;
66
+ },
67
+ };
68
+ }
69
+
70
+ /** A successful record report with a WAV the fake "captured". */
71
+ function recorded(wavPath: string | undefined): Pico8RecordResult {
72
+ return {ok: true, value: {wavPath, printh: 'ok\n', exitReason: 'sentinel'}};
73
+ }
74
+
75
+ /** Drives an `audio ...` subcommand through incur's serve DI with an injected adapter. */
76
+ async function runAudio(
77
+ factory: Pico8AdapterFactory,
78
+ argv: string[],
79
+ env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
80
+ ) {
81
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
82
+ registerAudio(cli, factory);
83
+ let stdout = '';
84
+ let exitCode = 0;
85
+ await cli.serve(argv, {
86
+ stdout(s) {
87
+ stdout += s;
88
+ },
89
+ exit(code) {
90
+ exitCode = code;
91
+ },
92
+ env,
93
+ });
94
+ return {stdout, exitCode};
95
+ }
96
+
97
+ describe('audio record/render: PICO-8 absent (the CI-testable boundary)', () => {
98
+ it('record returns structured pico8-not-found + nonzero exit (never a crash)', async () => {
99
+ const {stdout, exitCode} = await runAudio(
100
+ () => stubAdapter(pico8NotFound()),
101
+ ['audio', 'record', cartPath, '--json'],
102
+ );
103
+ expect(exitCode).not.toBe(0);
104
+ expect(stdout).toContain('set PICO8_PATH or install PICO-8');
105
+ expect(stdout).toContain('pico8');
106
+ });
107
+
108
+ it('render returns structured pico8-not-found + nonzero exit', async () => {
109
+ const {stdout, exitCode} = await runAudio(
110
+ () => stubAdapter(pico8NotFound()),
111
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
112
+ );
113
+ expect(exitCode).not.toBe(0);
114
+ expect(stdout).toContain('set PICO8_PATH or install PICO-8');
115
+ });
116
+ });
117
+
118
+ describe('audio render: orchestration against a fake runner', () => {
119
+ it('records a WAV and returns its path + the target description', async () => {
120
+ const seen: {record?: RecordOptions} = {};
121
+ const wav = join(dir, 'out.wav');
122
+ writeFileSync(wav, 'x');
123
+ const {stdout, exitCode} = await runAudio(
124
+ () => stubAdapter(recorded(wav), seen),
125
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
126
+ );
127
+ expect(exitCode).toBe(0);
128
+ const out = JSON.parse(stdout);
129
+ expect(out.wav).toBe(wav);
130
+ expect(out.captured).toBe(true);
131
+ expect(out.target).toBe('sfx 0');
132
+ // The adapter got a real A/V record request for a THROWAWAY harness cart,
133
+ // never the user's cart directly.
134
+ expect(seen.record?.cartPath).not.toBe(cartPath);
135
+ expect(seen.record?.cartPath.endsWith('.p8')).toBe(true);
136
+ });
137
+
138
+ it('injects a play-harness whose _update plays the target + records it', async () => {
139
+ const seen: {record?: RecordOptions} = {};
140
+ await runAudio(
141
+ () => stubAdapter(recorded(undefined), seen),
142
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
143
+ );
144
+ // The throwaway harness cart the adapter was pointed at contains the
145
+ // record recipe + the target play call (assert on-disk, not just the arg).
146
+ const harness = readFileSync(seen.record!.cartPath, 'utf8');
147
+ expect(harness).toContain('extcmd("audio_rec")');
148
+ expect(harness).toContain('sfx(0)');
149
+ expect(harness).toContain('extcmd("audio_end",1)');
150
+ });
151
+
152
+ it('the whole-song default renders music(0)', async () => {
153
+ const seen: {record?: RecordOptions} = {};
154
+ await runAudio(
155
+ () => stubAdapter(recorded(undefined), seen),
156
+ ['audio', 'render', cartPath, '--json'],
157
+ );
158
+ expect(readFileSync(seen.record!.cartPath, 'utf8')).toContain('music(0)');
159
+ });
160
+
161
+ it('refuses --sfx and --pattern together (a render targets ONE thing)', async () => {
162
+ const {stdout, exitCode} = await runAudio(
163
+ () => stubAdapter(recorded(undefined)),
164
+ ['audio', 'render', cartPath, '--sfx', '0', '--pattern', '1', '--json'],
165
+ );
166
+ expect(exitCode).not.toBe(0);
167
+ expect(stdout).toContain('ambiguous-target');
168
+ });
169
+
170
+ it('refuses an empty SFX slot (nothing to render), no capture attempted', async () => {
171
+ let recordCalled = false;
172
+ const {stdout, exitCode} = await runAudio(
173
+ () => ({
174
+ async run() {
175
+ return {
176
+ ok: true,
177
+ value: {screenshots: [], printh: '', exitReason: 'sentinel'},
178
+ };
179
+ },
180
+ async record() {
181
+ recordCalled = true;
182
+ return recorded(undefined);
183
+ },
184
+ }),
185
+ ['audio', 'render', cartPath, '--sfx', '9', '--json'],
186
+ );
187
+ expect(exitCode).not.toBe(0);
188
+ expect(stdout).toContain('audio-render-target-empty');
189
+ expect(recordCalled).toBe(false); // the target refusal precedes any capture
190
+ });
191
+
192
+ it('a no-audio capture surfaces captured:false + a CTA, not a hard error', async () => {
193
+ const {stdout, exitCode} = await runAudio(
194
+ () => stubAdapter(recorded(undefined)),
195
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
196
+ );
197
+ expect(exitCode).toBe(0); // the orchestration worked; there was just no audio
198
+ const out = JSON.parse(stdout);
199
+ expect(out.captured).toBe(false);
200
+ expect(out.wav).toBeUndefined();
201
+ });
202
+ });
203
+
204
+ describe('audio record: orchestration against a fake runner', () => {
205
+ it('records the running cart and returns the WAV path', async () => {
206
+ const seen: {record?: RecordOptions} = {};
207
+ const wav = join(dir, 'rec.wav');
208
+ writeFileSync(wav, 'x');
209
+ const {stdout, exitCode} = await runAudio(
210
+ () => stubAdapter(recorded(wav), seen),
211
+ ['audio', 'record', cartPath, '--json'],
212
+ );
213
+ expect(exitCode).toBe(0);
214
+ const out = JSON.parse(stdout);
215
+ expect(out.wav).toBe(wav);
216
+ expect(out.captured).toBe(true);
217
+ expect(out.target).toBe('the running cart');
218
+ // The recorder harness keeps the cart's OWN code (captures its playback).
219
+ const harness = readFileSync(seen.record!.cartPath, 'utf8');
220
+ expect(harness).toContain('function _update() end'); // the cart's code survives
221
+ expect(harness).toContain('extcmd("audio_rec")');
222
+ });
223
+
224
+ it('errors with a nonzero exit when the cart path does not exist', async () => {
225
+ const {stdout, exitCode} = await runAudio(
226
+ () => stubAdapter(recorded(undefined)),
227
+ ['audio', 'record', join(dir, 'nope.p8'), '--json'],
228
+ );
229
+ expect(exitCode).not.toBe(0);
230
+ expect(stdout).toContain('no cart at');
231
+ });
232
+ });
233
+
234
+ describe('audio: WAV-dir isolation (never ~/Desktop or the carts root)', () => {
235
+ it('defaults the WAV dir to an isolated temp dir, not ~/Desktop', async () => {
236
+ const seen: {record?: RecordOptions} = {};
237
+ await runAudio(
238
+ () => stubAdapter(recorded(undefined), seen),
239
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
240
+ );
241
+ const wavDir = seen.record?.wavDir ?? '';
242
+ expect(wavDir.startsWith(tmpdir())).toBe(true);
243
+ expect(wavDir).not.toContain(join(homedir(), 'Desktop'));
244
+ expect(existsSync(wavDir)).toBe(true); // created for PICO-8 to write into
245
+ });
246
+
247
+ it('leaves the real ~/Desktop BYTE-untouched across a record run', async () => {
248
+ const desktop = join(homedir(), 'Desktop');
249
+ const snap = (): string[] =>
250
+ existsSync(desktop) ? readdirSync(desktop).sort() : [];
251
+ const before = snap();
252
+ await runAudio(
253
+ () => stubAdapter(recorded(undefined)),
254
+ ['audio', 'record', cartPath, '--json'],
255
+ );
256
+ expect(snap()).toEqual(before);
257
+ });
258
+
259
+ it('never mutates the user cart (the throwaway harness is a separate file)', async () => {
260
+ const before = readFileSync(cartPath, 'utf8');
261
+ await runAudio(
262
+ () => stubAdapter(recorded(undefined)),
263
+ ['audio', 'render', cartPath, '--sfx', '0', '--json'],
264
+ );
265
+ expect(readFileSync(cartPath, 'utf8')).toBe(before);
266
+ });
267
+ });
268
+
269
+ describe('audio: honest help text (record-based, NOT an offline export)', () => {
270
+ it('render help states it is a real-time recording, not an offline export', async () => {
271
+ const {stdout} = await runAudio(
272
+ () => stubAdapter(recorded(undefined)),
273
+ ['audio', 'render', '--help'],
274
+ );
275
+ const lower = stdout.toLowerCase();
276
+ expect(lower).toContain('real-time');
277
+ expect(lower).toContain('not');
278
+ expect(lower).toContain('offline export');
279
+ });
280
+ });