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,310 @@
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 {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 {encodePng} from '../engine/gfx/index.js';
14
+ import {PICO8_PALETTE} from '../engine/gfx/index.js';
15
+ import type {
16
+ ExportOptions,
17
+ Pico8Adapter,
18
+ Pico8ExportResult,
19
+ } from '../engine/pico8/index.js';
20
+ import {pico8NotFound} from '../engine/pico8/index.js';
21
+ import {type Pico8AdapterFactory, registerExport} from './export.js';
22
+
23
+ /** A trivial valid cart the command reads (existence + a path to hand the adapter). */
24
+ const CART_TEXT =
25
+ 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
26
+
27
+ /** A 128x128 solid PNG (index 12, blue) usable as a --label fixture. */
28
+ function labelPng(): Uint8Array {
29
+ const c = PICO8_PALETTE[12]!;
30
+ const rgb = new Uint8Array(128 * 128 * 3);
31
+ for (let i = 0; i < 128 * 128; i++) {
32
+ rgb[i * 3] = c.r;
33
+ rgb[i * 3 + 1] = c.g;
34
+ rgb[i * 3 + 2] = c.b;
35
+ }
36
+ return encodePng({width: 128, height: 128, rgb});
37
+ }
38
+
39
+ let dir: string;
40
+ let cartPath: string;
41
+
42
+ beforeEach(() => {
43
+ dir = mkdtempSync(join(tmpdir(), 'picopilot-export-'));
44
+ cartPath = join(dir, 'main.p8');
45
+ writeFileSync(cartPath, CART_TEXT);
46
+ });
47
+
48
+ afterEach(() => {
49
+ try {
50
+ execFileSync('rm', ['-rf', dir]);
51
+ } catch {
52
+ // ignore
53
+ }
54
+ });
55
+
56
+ /**
57
+ * A stub {@link Pico8Adapter} whose `export` REALLY writes the bundle files into
58
+ * the `outDir` it is handed, so the command's file collection + the
59
+ * --payload-only file split are exercised end-to-end WITHOUT the paid binary. It
60
+ * records the {@link ExportOptions} so a test can assert the chosen out dir.
61
+ */
62
+ function stubAdapter(
63
+ result: Pico8ExportResult | 'writes-bundle' | 'writes-no-js',
64
+ seen?: {options?: ExportOptions},
65
+ labelWarning = false,
66
+ ): Pico8Adapter {
67
+ const notUsed = () => {
68
+ throw new Error('not used by export');
69
+ };
70
+ return {
71
+ run: notUsed as never,
72
+ record: notUsed as never,
73
+ drive: notUsed as never,
74
+ async export(options) {
75
+ if (seen !== undefined) seen.options = options;
76
+ if (result === 'writes-bundle') {
77
+ const html = join(options.outDir, 'index.html');
78
+ const js = join(options.outDir, 'index.js');
79
+ writeFileSync(html, '<html><script src="index.js"></script></html>');
80
+ writeFileSync(js, '// pico8 runtime');
81
+ return {
82
+ ok: true,
83
+ value: {htmlPath: html, jsPath: js, files: [html, js], labelWarning},
84
+ };
85
+ }
86
+ if (result === 'writes-no-js') {
87
+ return {
88
+ ok: true,
89
+ value: {
90
+ htmlPath: undefined,
91
+ jsPath: undefined,
92
+ files: [],
93
+ labelWarning,
94
+ },
95
+ };
96
+ }
97
+ return result;
98
+ },
99
+ };
100
+ }
101
+
102
+ /** Drives `picopilot export` through incur's serve DI with an injected adapter. */
103
+ async function runExport(
104
+ factory: Pico8AdapterFactory,
105
+ argv: string[],
106
+ env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
107
+ ) {
108
+ const cli = Cli.create('picopilot', {version: '0.0.0'});
109
+ registerExport(cli, factory);
110
+ let stdout = '';
111
+ let exitCode = 0;
112
+ await cli.serve(argv, {
113
+ stdout(s) {
114
+ stdout += s;
115
+ },
116
+ exit(code) {
117
+ exitCode = code;
118
+ },
119
+ env,
120
+ });
121
+ return {stdout, exitCode};
122
+ }
123
+
124
+ describe('picopilot export: PICO-8 absent (the CI-testable boundary)', () => {
125
+ it('returns structured pico8-not-found + nonzero exit (mirrors run, never a crash)', async () => {
126
+ const {stdout, exitCode} = await runExport(
127
+ () => stubAdapter(pico8NotFound()),
128
+ ['export', cartPath, '--json'],
129
+ );
130
+ expect(exitCode).not.toBe(0);
131
+ expect(stdout).toContain('set PICO8_PATH or install PICO-8');
132
+ expect(stdout).toContain('pico8');
133
+ });
134
+ });
135
+
136
+ describe('picopilot export: standalone bundle into a dest dir', () => {
137
+ it('writes index.html + index.js and reports both paths', async () => {
138
+ const dest = join(dir, 'out');
139
+ const {stdout, exitCode} = await runExport(
140
+ () => stubAdapter('writes-bundle'),
141
+ ['export', cartPath, dest, '--json'],
142
+ );
143
+ expect(exitCode).toBe(0);
144
+ const out = JSON.parse(stdout);
145
+ expect(out.outDir).toBe(dest);
146
+ expect(out.html.endsWith('index.html')).toBe(true);
147
+ expect(out.js.endsWith('index.js')).toBe(true);
148
+ // The dest dir is directly serveable: both files are present on disk.
149
+ expect(existsSync(join(dest, 'index.html'))).toBe(true);
150
+ expect(existsSync(join(dest, 'index.js'))).toBe(true);
151
+ });
152
+
153
+ it('names the export index.html so a dest folder plays directly', async () => {
154
+ const seen: {options?: ExportOptions} = {};
155
+ await runExport(
156
+ () => stubAdapter('writes-bundle', seen),
157
+ ['export', cartPath, join(dir, 'out'), '--json'],
158
+ );
159
+ expect(seen.options?.htmlName).toBe('index.html');
160
+ });
161
+
162
+ it('defaults the dest to an isolated temp dir (never a user path)', async () => {
163
+ const seen: {options?: ExportOptions} = {};
164
+ await runExport(
165
+ () => stubAdapter('writes-bundle', seen),
166
+ ['export', cartPath, '--json'],
167
+ );
168
+ expect(seen.options?.outDir.startsWith(tmpdir())).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('picopilot export: --payload-only (drop the shell page for a site component)', () => {
173
+ it('removes index.html and keeps only the js payload', async () => {
174
+ const dest = join(dir, 'payload');
175
+ const {stdout, exitCode} = await runExport(
176
+ () => stubAdapter('writes-bundle'),
177
+ ['export', cartPath, dest, '--payload-only', '--json'],
178
+ );
179
+ expect(exitCode).toBe(0);
180
+ const out = JSON.parse(stdout);
181
+ expect(out.html).toBeUndefined();
182
+ expect(out.js.endsWith('index.js')).toBe(true);
183
+ // The shell page is gone; the runtime payload remains.
184
+ expect(existsSync(join(dest, 'index.html'))).toBe(false);
185
+ expect(existsSync(join(dest, 'index.js'))).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe('picopilot export: --label (bake a PNG in as the __label__ splash)', () => {
190
+ it('injects the label into a copy and exports that (original cart untouched)', async () => {
191
+ const seen: {options?: ExportOptions} = {};
192
+ const labelFile = join(dir, 'label.png');
193
+ writeFileSync(labelFile, labelPng());
194
+ const {exitCode} = await runExport(
195
+ () => stubAdapter('writes-bundle', seen),
196
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
197
+ );
198
+ expect(exitCode).toBe(0);
199
+ // The cart handed to PICO-8 is a labeled COPY, not the original path.
200
+ const handed = seen.options!.cartPath;
201
+ expect(handed).not.toBe(cartPath);
202
+ // The original cart is byte-untouched (no label added to it).
203
+ expect(readFileSync(cartPath, 'utf8')).toBe(CART_TEXT);
204
+ expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
205
+ });
206
+
207
+ it('cleans up the temporary labeled copy after export', async () => {
208
+ const seen: {options?: ExportOptions} = {};
209
+ const labelFile = join(dir, 'label.png');
210
+ writeFileSync(labelFile, labelPng());
211
+ await runExport(
212
+ () => stubAdapter('writes-bundle', seen),
213
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
214
+ );
215
+ // No stray .picopilot-export-*.p8 copy left beside the cart.
216
+ const leftovers = readdirSync(dir).filter((f) =>
217
+ f.startsWith('.picopilot-export-'),
218
+ );
219
+ expect(leftovers).toEqual([]);
220
+ });
221
+
222
+ it('ignores --label when the cart ALREADY has a label (does not override)', async () => {
223
+ const seen: {options?: ExportOptions} = {};
224
+ const labeled = join(dir, 'labeled.p8');
225
+ writeFileSync(labeled, `${CART_TEXT}__label__\n${'0'.repeat(128)}\n`);
226
+ const labelFile = join(dir, 'label.png');
227
+ writeFileSync(labelFile, labelPng());
228
+ const {exitCode} = await runExport(
229
+ () => stubAdapter('writes-bundle', seen),
230
+ ['export', labeled, join(dir, 'out'), '--label', labelFile, '--json'],
231
+ );
232
+ expect(exitCode).toBe(0);
233
+ // The cart already had a label, so it is exported AS-IS (no copy made).
234
+ expect(seen.options!.cartPath).toBe(labeled);
235
+ });
236
+
237
+ it('errors when the --label file does not exist', async () => {
238
+ const {stdout, exitCode} = await runExport(
239
+ () => stubAdapter('writes-bundle'),
240
+ [
241
+ 'export',
242
+ cartPath,
243
+ join(dir, 'out'),
244
+ '--label',
245
+ join(dir, 'nope.png'),
246
+ '--json',
247
+ ],
248
+ );
249
+ expect(exitCode).not.toBe(0);
250
+ expect(stdout).toContain('no label image at');
251
+ });
252
+
253
+ it('errors on a wrong-size label image (must be 128x128)', async () => {
254
+ const small = encodePng({
255
+ width: 64,
256
+ height: 64,
257
+ rgb: new Uint8Array(64 * 64 * 3),
258
+ });
259
+ const labelFile = join(dir, 'small.png');
260
+ writeFileSync(labelFile, small);
261
+ const {stdout, exitCode} = await runExport(
262
+ () => stubAdapter('writes-bundle'),
263
+ ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
264
+ );
265
+ expect(exitCode).not.toBe(0);
266
+ expect(stdout).toContain('128x128');
267
+ });
268
+ });
269
+
270
+ describe('picopilot export: labelless cart + failure paths', () => {
271
+ it('surfaces the labelWarning and CTAs toward capturing a label (standalone)', async () => {
272
+ const {stdout, exitCode} = await runExport(
273
+ () => stubAdapter('writes-bundle', undefined, true),
274
+ ['export', cartPath, join(dir, 'out'), '--json'],
275
+ );
276
+ expect(exitCode).toBe(0);
277
+ const out = JSON.parse(stdout);
278
+ expect(out.labelWarning).toBe(true);
279
+ expect(stdout.toLowerCase()).toContain('label');
280
+ });
281
+
282
+ it('errors when PICO-8 produced no js payload (labelless bail)', async () => {
283
+ const {stdout, exitCode} = await runExport(
284
+ () => stubAdapter('writes-no-js', undefined, true),
285
+ ['export', cartPath, join(dir, 'out'), '--json'],
286
+ );
287
+ expect(exitCode).not.toBe(0);
288
+ expect(stdout.toLowerCase()).toContain('label');
289
+ });
290
+ });
291
+
292
+ describe('picopilot export: cart-not-found (picopilot-side error, distinct from pico8 absence)', () => {
293
+ it('errors with a nonzero exit when the cart path does not exist', async () => {
294
+ const {stdout, exitCode} = await runExport(
295
+ () => stubAdapter('writes-bundle'),
296
+ ['export', join(dir, 'nope.p8'), '--json'],
297
+ );
298
+ expect(exitCode).not.toBe(0);
299
+ expect(stdout).toContain('no cart at');
300
+ });
301
+
302
+ it('does NOT invoke the adapter when the cart is missing (fails fast)', async () => {
303
+ let called = false;
304
+ await runExport(() => {
305
+ called = true;
306
+ return stubAdapter('writes-bundle');
307
+ }, ['export', join(dir, 'missing.p8'), '--json']);
308
+ expect(called).toBe(false);
309
+ });
310
+ });
@@ -0,0 +1,316 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import {tmpdir} from 'node:os';
11
+ import {dirname, isAbsolute, join, resolve} from 'node:path';
12
+ import {type Cli, z} from 'incur';
13
+ import {Cart, CartParseError} from '../engine/cart/index.js';
14
+ import {labelHexFromPng, LabelError} from '../engine/gfx/index.js';
15
+ import {
16
+ EXPORT_HTML_NAME,
17
+ type Pico8Adapter,
18
+ ShellPico8Adapter,
19
+ } from '../engine/pico8/index.js';
20
+
21
+ /**
22
+ * Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
23
+ * native `pico8 <cart> -export <name>.html -x`). Mirrors the `run` seam: a test
24
+ * passes a stub to drive the present/absent paths without the paid binary, and
25
+ * to isolate the child `env` (its `PICO8_PATH`/`PATH`).
26
+ */
27
+ export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
28
+
29
+ const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
30
+ new ShellPico8Adapter({env});
31
+
32
+ /** The default backstop: kill an export that never completes/exits after this many ms. */
33
+ const DEFAULT_BACKSTOP_MS = 30_000;
34
+
35
+ /**
36
+ * Registers `picopilot export`, the THIN structured wrapper over PICO-8's HTML
37
+ * export (`pico8 <cart> -export <name>.html -x`, the headless `-x` path, no
38
+ * display needed). It is NOT a `pico8` reimplementation: PICO-8 owns the bundle
39
+ * (an Emscripten runtime `.js` with the cart baked in, plus a shell `.html`).
40
+ * The value picopilot adds is the glue an agent should not hand-roll: resolve
41
+ * the cart, pick/create the output dir, name the pair `index.html` + `index.js`
42
+ * so a dest folder is directly serveable/showcase-ready, and return ONE
43
+ * structured result (output dir + file paths + the labelless-cart warning). It
44
+ * hard-requires PICO-8; absence is a first-class `pico8-not-found` value + a
45
+ * nonzero exit (mirrors `run`), never a crash or a hang. Live export is the
46
+ * manual/opt-in tier; the absent path is the CI-testable one.
47
+ *
48
+ * Two shapes, one command:
49
+ * - default (standalone): keep the `index.html` + `index.js` pair; the html
50
+ * references its sibling by name, so the dir plays directly (and `serve`
51
+ * uses this internally).
52
+ * - `--payload-only`: keep only the `.js` runtime payload; drop the shell page.
53
+ * For a website that provides its OWN player shell (a Svelte component that
54
+ * ports PICO-8's small bootstrap and loads the exported `.js`). The split is
55
+ * a post-export file op the command owns, never the PICO-8 binary's concern.
56
+ *
57
+ * @param adapterFactory injects the adapter (defaults to the shell adapter);
58
+ * tests pass a stub to drive present/absent without the real binary.
59
+ */
60
+ export function registerExport(
61
+ cli: Cli.Cli,
62
+ adapterFactory: Pico8AdapterFactory = defaultAdapterFactory,
63
+ ): void {
64
+ cli.command('export', {
65
+ description:
66
+ "Export a cart to a playable HTML bundle (index.html + index.js) via the user's PICO-8. Requires PICO-8.",
67
+ args: z.object({
68
+ cart: z
69
+ .string()
70
+ .default('main.p8')
71
+ .describe(
72
+ 'The .p8 cart to export. Defaults to main.p8 in the current folder.',
73
+ ),
74
+ dest: z
75
+ .string()
76
+ .optional()
77
+ .describe(
78
+ 'Output folder (created if missing). Point it at a showcase dir (e.g. ./website/static/games/<slug>/). Defaults to an isolated temp dir.',
79
+ ),
80
+ }),
81
+ options: z.object({
82
+ label: z
83
+ .string()
84
+ .optional()
85
+ .describe(
86
+ 'Path to a 128x128 PNG to bake in as the cart __label__ (the export splash), for a cart that has none. Ignored if the cart already has a label.',
87
+ ),
88
+ payloadOnly: z
89
+ .boolean()
90
+ .default(false)
91
+ .describe(
92
+ 'Keep only the .js runtime payload (drop the shell .html), for a site that provides its own player component.',
93
+ ),
94
+ backstopMs: z
95
+ .number()
96
+ .int()
97
+ .positive()
98
+ .default(DEFAULT_BACKSTOP_MS)
99
+ .describe(
100
+ 'Safety backstop: kill PICO-8 after this many ms if the export never completes.',
101
+ ),
102
+ }),
103
+ // incur resolves these from the (test-overridable) env source and hands
104
+ // 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
+ }),
112
+ output: z.object({
113
+ outDir: z
114
+ .string()
115
+ .describe('Absolute path of the folder the bundle was written to.'),
116
+ html: z
117
+ .string()
118
+ .optional()
119
+ .describe(
120
+ 'Absolute path of the shell index.html (omitted with --payload-only).',
121
+ ),
122
+ js: z.string().describe('Absolute path of the runtime payload index.js.'),
123
+ files: z
124
+ .array(z.string())
125
+ .describe('Absolute paths of every file the bundle contains.'),
126
+ labelWarning: z
127
+ .boolean()
128
+ .describe(
129
+ 'True when the cart has no __label__: it exports with a blank/ugly splash.',
130
+ ),
131
+ }),
132
+ examples: [
133
+ {description: 'Export main.p8 to a temp dir'},
134
+ {
135
+ description: 'Export a cart into a showcase folder',
136
+ args: {cart: 'game.p8', dest: './website/static/games/my-game'},
137
+ },
138
+ {
139
+ description: 'Bake a PNG as the label splash for a labelless cart',
140
+ args: {cart: 'game.p8', dest: './website/static/games/my-game'},
141
+ options: {label: './label.png'},
142
+ },
143
+ {
144
+ description:
145
+ 'Export only the runtime payload for a site player component',
146
+ args: {cart: 'game.p8', dest: './website/static/games/my-game'},
147
+ options: {payloadOnly: true},
148
+ },
149
+ ],
150
+ async run({args, options, env, error, ok}) {
151
+ const cartPath = isAbsolute(args.cart)
152
+ ? args.cart
153
+ : resolve(process.cwd(), args.cart);
154
+
155
+ // A missing cart is a picopilot-side error, distinct from PICO-8 absence.
156
+ if (!existsSync(cartPath)) {
157
+ return error({
158
+ code: 'cart-not-found',
159
+ message: `no cart at ${cartPath}`,
160
+ exitCode: 1,
161
+ });
162
+ }
163
+
164
+ // --label <png>: bake a 128x128 PNG in as the cart's __label__ so a
165
+ // labelless cart gets a splash without an interactive F7 capture. We do
166
+ // NOT mutate the user's cart: we write a labeled copy NEXT TO it (same dir,
167
+ // so `#include main.lua` still resolves) and export that, then clean it up.
168
+ // A cart that ALREADY has a label wins (the flag is a fallback, not an
169
+ // override), matching the export precedence PICO-8 itself uses.
170
+ let exportCartPath = cartPath;
171
+ let labeledCopy: string | undefined;
172
+ if (options.label !== undefined) {
173
+ const labelPath = isAbsolute(options.label)
174
+ ? options.label
175
+ : resolve(process.cwd(), options.label);
176
+ if (!existsSync(labelPath)) {
177
+ return error({
178
+ code: 'label-not-found',
179
+ message: `no label image at ${labelPath}`,
180
+ exitCode: 1,
181
+ });
182
+ }
183
+ try {
184
+ const cart = Cart.parse(readFileSync(cartPath, 'utf8'));
185
+ const existingLabel = cart.getSection('label');
186
+ if (existingLabel === undefined || existingLabel.trim() === '') {
187
+ const hex = labelHexFromPng(readFileSync(labelPath));
188
+ cart.setSection('label', hex);
189
+ labeledCopy = join(
190
+ dirname(cartPath),
191
+ `.picopilot-export-${process.pid}-${Date.now()}.p8`,
192
+ );
193
+ writeFileSync(labeledCopy, cart.serialize());
194
+ exportCartPath = labeledCopy;
195
+ }
196
+ // else: cart already has a label; keep it, ignore --label.
197
+ } catch (e) {
198
+ if (e instanceof LabelError) {
199
+ return error({
200
+ code: `label-${e.code}`,
201
+ message: e.message,
202
+ exitCode: 1,
203
+ });
204
+ }
205
+ if (e instanceof CartParseError) {
206
+ return error({
207
+ code: `cart-${e.code}`,
208
+ message: `could not parse the cart to inject a label: ${e.message}`,
209
+ exitCode: 1,
210
+ });
211
+ }
212
+ throw e;
213
+ }
214
+ }
215
+
216
+ // Output dir: a user-given dest (point it at the showcase), else an
217
+ // isolated temp dir. Create it so PICO-8 has somewhere to write.
218
+ const outDir =
219
+ args.dest !== undefined
220
+ ? isAbsolute(args.dest)
221
+ ? args.dest
222
+ : resolve(process.cwd(), args.dest)
223
+ : mkdtempSync(join(tmpdir(), 'picopilot-export-'));
224
+ if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
225
+
226
+ const adapter = adapterFactory(env as NodeJS.ProcessEnv);
227
+ const result = await adapter.export({
228
+ cartPath: exportCartPath,
229
+ outDir,
230
+ htmlName: EXPORT_HTML_NAME,
231
+ backstopMs: options.backstopMs,
232
+ });
233
+
234
+ // Remove the temporary labeled copy now that PICO-8 has read it (on every
235
+ // path below); best-effort, a leftover dotfile is not fatal.
236
+ if (labeledCopy !== undefined) {
237
+ try {
238
+ rmSync(labeledCopy);
239
+ } catch {
240
+ // ignore
241
+ }
242
+ }
243
+
244
+ if (!result.ok) {
245
+ // The two-tier structured failure: PICO-8 absent. The message carries
246
+ // the exact remedy + needs; the code is the handle.
247
+ return error({
248
+ code: result.reason,
249
+ message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
250
+ exitCode: 1,
251
+ });
252
+ }
253
+
254
+ let {htmlPath, jsPath, files, labelWarning} = result.value;
255
+
256
+ // PICO-8 must have produced the runtime payload; if not, the export failed
257
+ // (most often a labelless-cart bail that wrote nothing).
258
+ if (jsPath === undefined) {
259
+ return error({
260
+ code: 'export-failed',
261
+ message: labelWarning
262
+ ? 'PICO-8 produced no bundle (the cart has no __label__ to embed). Give it one with --label <128x128.png>, or capture a label in PICO-8 (F7), then re-export.'
263
+ : 'PICO-8 produced no export bundle. Check the cart runs without a boot error.',
264
+ exitCode: 1,
265
+ });
266
+ }
267
+
268
+ // --payload-only: drop the shell .html; keep just the runtime payload the
269
+ // site's own player component loads. A post-export file op we own, not the
270
+ // binary's concern.
271
+ if (options.payloadOnly && htmlPath !== undefined) {
272
+ try {
273
+ rmSync(htmlPath);
274
+ } catch {
275
+ // A stubborn html is not fatal: the payload is what matters.
276
+ }
277
+ htmlPath = undefined;
278
+ files = listFiles(outDir);
279
+ }
280
+
281
+ const cta =
282
+ labelWarning && !options.payloadOnly
283
+ ? {
284
+ description:
285
+ 'The cart has no __label__, so the export splash is blank. Add one for a nicer bundle:',
286
+ commands: [
287
+ {
288
+ command: 'export --label <128x128.png>',
289
+ description:
290
+ 'Bake a PNG in as the label splash, or capture one in PICO-8 (F7) before exporting.',
291
+ },
292
+ ],
293
+ }
294
+ : undefined;
295
+
296
+ return ok(
297
+ {
298
+ outDir,
299
+ html: htmlPath,
300
+ js: jsPath,
301
+ files: [...files],
302
+ labelWarning,
303
+ },
304
+ cta === undefined ? undefined : {cta},
305
+ );
306
+ },
307
+ });
308
+ }
309
+
310
+ /** Absolute, sorted file paths currently in `dir` (post file-op re-scan). */
311
+ function listFiles(dir: string): string[] {
312
+ if (!existsSync(dir)) return [];
313
+ return readdirSync(dir)
314
+ .sort()
315
+ .map((f) => join(dir, f));
316
+ }