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.
- package/LICENSE +661 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/audio.d.ts +17 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +315 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/export.d.ts +36 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +257 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/gfx.d.ts +28 -0
- package/dist/commands/gfx.d.ts.map +1 -0
- package/dist/commands/gfx.js +521 -0
- package/dist/commands/gfx.js.map +1 -0
- package/dist/commands/init.d.ts +34 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/lint.d.ts +20 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +90 -0
- package/dist/commands/lint.js.map +1 -0
- package/dist/commands/minify.d.ts +20 -0
- package/dist/commands/minify.d.ts.map +1 -0
- package/dist/commands/minify.js +129 -0
- package/dist/commands/minify.js.map +1 -0
- package/dist/commands/music.d.ts +13 -0
- package/dist/commands/music.d.ts.map +1 -0
- package/dist/commands/music.js +241 -0
- package/dist/commands/music.js.map +1 -0
- package/dist/commands/playtest.d.ts +29 -0
- package/dist/commands/playtest.d.ts.map +1 -0
- package/dist/commands/playtest.js +631 -0
- package/dist/commands/playtest.js.map +1 -0
- package/dist/commands/run.d.ts +30 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/serve.d.ts +50 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +213 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/sfx.d.ts +13 -0
- package/dist/commands/sfx.d.ts.map +1 -0
- package/dist/commands/sfx.js +254 -0
- package/dist/commands/sfx.js.map +1 -0
- package/dist/commands/tokens.d.ts +36 -0
- package/dist/commands/tokens.d.ts.map +1 -0
- package/dist/commands/tokens.js +129 -0
- package/dist/commands/tokens.js.map +1 -0
- package/dist/commands/verify.d.ts +57 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +213 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +24 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/engine/audio/index.d.ts +14 -0
- package/dist/engine/audio/index.d.ts.map +1 -0
- package/dist/engine/audio/index.js +14 -0
- package/dist/engine/audio/index.js.map +1 -0
- package/dist/engine/audio/music.d.ts +116 -0
- package/dist/engine/audio/music.d.ts.map +1 -0
- package/dist/engine/audio/music.js +138 -0
- package/dist/engine/audio/music.js.map +1 -0
- package/dist/engine/audio/section.d.ts +34 -0
- package/dist/engine/audio/section.d.ts.map +1 -0
- package/dist/engine/audio/section.js +67 -0
- package/dist/engine/audio/section.js.map +1 -0
- package/dist/engine/audio/sfx.d.ts +141 -0
- package/dist/engine/audio/sfx.d.ts.map +1 -0
- package/dist/engine/audio/sfx.js +506 -0
- package/dist/engine/audio/sfx.js.map +1 -0
- package/dist/engine/cart/cart.d.ts +75 -0
- package/dist/engine/cart/cart.d.ts.map +1 -0
- package/dist/engine/cart/cart.js +153 -0
- package/dist/engine/cart/cart.js.map +1 -0
- package/dist/engine/cart/errors.d.ts +29 -0
- package/dist/engine/cart/errors.d.ts.map +1 -0
- package/dist/engine/cart/errors.js +26 -0
- package/dist/engine/cart/errors.js.map +1 -0
- package/dist/engine/cart/gfx.d.ts +79 -0
- package/dist/engine/cart/gfx.d.ts.map +1 -0
- package/dist/engine/cart/gfx.js +171 -0
- package/dist/engine/cart/gfx.js.map +1 -0
- package/dist/engine/cart/index.d.ts +15 -0
- package/dist/engine/cart/index.d.ts.map +1 -0
- package/dist/engine/cart/index.js +15 -0
- package/dist/engine/cart/index.js.map +1 -0
- package/dist/engine/cart/map.d.ts +46 -0
- package/dist/engine/cart/map.d.ts.map +1 -0
- package/dist/engine/cart/map.js +118 -0
- package/dist/engine/cart/map.js.map +1 -0
- package/dist/engine/config/config.d.ts +74 -0
- package/dist/engine/config/config.d.ts.map +1 -0
- package/dist/engine/config/config.js +103 -0
- package/dist/engine/config/config.js.map +1 -0
- package/dist/engine/config/index.d.ts +7 -0
- package/dist/engine/config/index.d.ts.map +1 -0
- package/dist/engine/config/index.js +7 -0
- package/dist/engine/config/index.js.map +1 -0
- package/dist/engine/gfx/grid.d.ts +51 -0
- package/dist/engine/gfx/grid.d.ts.map +1 -0
- package/dist/engine/gfx/grid.js +116 -0
- package/dist/engine/gfx/grid.js.map +1 -0
- package/dist/engine/gfx/index.d.ts +17 -0
- package/dist/engine/gfx/index.d.ts.map +1 -0
- package/dist/engine/gfx/index.js +17 -0
- package/dist/engine/gfx/index.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +34 -0
- package/dist/engine/gfx/label.d.ts.map +1 -0
- package/dist/engine/gfx/label.js +190 -0
- package/dist/engine/gfx/label.js.map +1 -0
- package/dist/engine/gfx/overlap.d.ts +42 -0
- package/dist/engine/gfx/overlap.d.ts.map +1 -0
- package/dist/engine/gfx/overlap.js +57 -0
- package/dist/engine/gfx/overlap.js.map +1 -0
- package/dist/engine/gfx/palette.d.ts +29 -0
- package/dist/engine/gfx/palette.d.ts.map +1 -0
- package/dist/engine/gfx/palette.js +46 -0
- package/dist/engine/gfx/palette.js.map +1 -0
- package/dist/engine/gfx/png.d.ts +31 -0
- package/dist/engine/gfx/png.d.ts.map +1 -0
- package/dist/engine/gfx/png.js +165 -0
- package/dist/engine/gfx/png.js.map +1 -0
- package/dist/engine/gfx/render.d.ts +42 -0
- package/dist/engine/gfx/render.d.ts.map +1 -0
- package/dist/engine/gfx/render.js +72 -0
- package/dist/engine/gfx/render.js.map +1 -0
- package/dist/engine/index.d.ts +22 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +22 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/pico8/adapter.d.ts +267 -0
- package/dist/engine/pico8/adapter.d.ts.map +1 -0
- package/dist/engine/pico8/adapter.js +37 -0
- package/dist/engine/pico8/adapter.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts +308 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -0
- package/dist/engine/pico8/drive.js +581 -0
- package/dist/engine/pico8/drive.js.map +1 -0
- package/dist/engine/pico8/harness.d.ts +146 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -0
- package/dist/engine/pico8/harness.js +251 -0
- package/dist/engine/pico8/harness.js.map +1 -0
- package/dist/engine/pico8/index.d.ts +21 -0
- package/dist/engine/pico8/index.d.ts.map +1 -0
- package/dist/engine/pico8/index.js +21 -0
- package/dist/engine/pico8/index.js.map +1 -0
- package/dist/engine/pico8/sentinel.d.ts +63 -0
- package/dist/engine/pico8/sentinel.d.ts.map +1 -0
- package/dist/engine/pico8/sentinel.js +86 -0
- package/dist/engine/pico8/sentinel.js.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
- package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-entry.js +11 -0
- package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
- package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
- package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon-main.js +71 -0
- package/dist/engine/pico8/session-daemon-main.js.map +1 -0
- package/dist/engine/pico8/session-daemon.d.ts +68 -0
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
- package/dist/engine/pico8/session-daemon.js +264 -0
- package/dist/engine/pico8/session-daemon.js.map +1 -0
- package/dist/engine/pico8/session.d.ts +140 -0
- package/dist/engine/pico8/session.d.ts.map +1 -0
- package/dist/engine/pico8/session.js +250 -0
- package/dist/engine/pico8/session.js.map +1 -0
- package/dist/engine/pico8/shell.d.ts +103 -0
- package/dist/engine/pico8/shell.d.ts.map +1 -0
- package/dist/engine/pico8/shell.js +324 -0
- package/dist/engine/pico8/shell.js.map +1 -0
- package/dist/engine/pico8/supervisor.d.ts +137 -0
- package/dist/engine/pico8/supervisor.d.ts.map +1 -0
- package/dist/engine/pico8/supervisor.js +140 -0
- package/dist/engine/pico8/supervisor.js.map +1 -0
- package/dist/engine/scaffold/index.d.ts +8 -0
- package/dist/engine/scaffold/index.d.ts.map +1 -0
- package/dist/engine/scaffold/index.js +8 -0
- package/dist/engine/scaffold/index.js.map +1 -0
- package/dist/engine/scaffold/scaffold.d.ts +49 -0
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
- package/dist/engine/scaffold/scaffold.js +259 -0
- package/dist/engine/scaffold/scaffold.js.map +1 -0
- package/dist/engine/shrinko/adapter.d.ts +167 -0
- package/dist/engine/shrinko/adapter.d.ts.map +1 -0
- package/dist/engine/shrinko/adapter.js +107 -0
- package/dist/engine/shrinko/adapter.js.map +1 -0
- package/dist/engine/shrinko/index.d.ts +13 -0
- package/dist/engine/shrinko/index.d.ts.map +1 -0
- package/dist/engine/shrinko/index.js +13 -0
- package/dist/engine/shrinko/index.js.map +1 -0
- package/dist/engine/shrinko/shell.d.ts +69 -0
- package/dist/engine/shrinko/shell.d.ts.map +1 -0
- package/dist/engine/shrinko/shell.js +156 -0
- package/dist/engine/shrinko/shell.js.map +1 -0
- package/dist/engine/skills/index.d.ts +8 -0
- package/dist/engine/skills/index.d.ts.map +1 -0
- package/dist/engine/skills/index.js +8 -0
- package/dist/engine/skills/index.js.map +1 -0
- package/dist/engine/skills/skills.d.ts +104 -0
- package/dist/engine/skills/skills.d.ts.map +1 -0
- package/dist/engine/skills/skills.js +149 -0
- package/dist/engine/skills/skills.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +48 -2
- package/src/bin.ts +4 -0
- package/src/cli.ts +61 -0
- package/src/commands/audio.test.ts +280 -0
- package/src/commands/audio.ts +410 -0
- package/src/commands/export.test.ts +310 -0
- package/src/commands/export.ts +316 -0
- package/src/commands/gfx-io.test.ts +178 -0
- package/src/commands/gfx.test.ts +470 -0
- package/src/commands/gfx.ts +645 -0
- package/src/commands/init.test.ts +482 -0
- package/src/commands/init.ts +193 -0
- package/src/commands/lint.test.ts +137 -0
- package/src/commands/lint.ts +117 -0
- package/src/commands/minify.test.ts +166 -0
- package/src/commands/minify.ts +156 -0
- package/src/commands/music.test.ts +272 -0
- package/src/commands/music.ts +288 -0
- package/src/commands/playtest.test.ts +317 -0
- package/src/commands/playtest.ts +773 -0
- package/src/commands/run.test.ts +264 -0
- package/src/commands/run.ts +285 -0
- package/src/commands/serve.test.ts +227 -0
- package/src/commands/serve.ts +272 -0
- package/src/commands/sfx.test.ts +349 -0
- package/src/commands/sfx.ts +300 -0
- package/src/commands/tokens.test.ts +212 -0
- package/src/commands/tokens.ts +168 -0
- package/src/commands/verify.test.ts +238 -0
- package/src/commands/verify.ts +256 -0
- package/src/commands/version.test.ts +70 -0
- package/src/commands/version.ts +24 -0
- package/src/engine/audio/index.ts +47 -0
- package/src/engine/audio/music.test.ts +188 -0
- package/src/engine/audio/music.ts +218 -0
- package/src/engine/audio/section.test.ts +124 -0
- package/src/engine/audio/section.ts +79 -0
- package/src/engine/audio/sfx.test.ts +410 -0
- package/src/engine/audio/sfx.ts +694 -0
- package/src/engine/cart/cart.test.ts +304 -0
- package/src/engine/cart/cart.ts +211 -0
- package/src/engine/cart/errors.ts +39 -0
- package/src/engine/cart/fixtures/crlf.p8 +6 -0
- package/src/engine/cart/fixtures/full.p8 +174 -0
- package/src/engine/cart/fixtures/header-only.p8 +2 -0
- package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
- package/src/engine/cart/fixtures/minimal.p8 +10 -0
- package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
- package/src/engine/cart/gfx.ts +195 -0
- package/src/engine/cart/index.ts +27 -0
- package/src/engine/cart/map.ts +134 -0
- package/src/engine/config/config.ts +115 -0
- package/src/engine/config/index.ts +12 -0
- package/src/engine/gfx/grid.test.ts +128 -0
- package/src/engine/gfx/grid.ts +136 -0
- package/src/engine/gfx/index.ts +34 -0
- package/src/engine/gfx/label.test.ts +105 -0
- package/src/engine/gfx/label.ts +229 -0
- package/src/engine/gfx/overlap.test.ts +71 -0
- package/src/engine/gfx/overlap.ts +66 -0
- package/src/engine/gfx/palette.test.ts +35 -0
- package/src/engine/gfx/palette.ts +54 -0
- package/src/engine/gfx/png.test.ts +111 -0
- package/src/engine/gfx/png.ts +196 -0
- package/src/engine/gfx/render.test.ts +180 -0
- package/src/engine/gfx/render.ts +106 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pico8/adapter.ts +303 -0
- package/src/engine/pico8/drive.test.ts +415 -0
- package/src/engine/pico8/drive.ts +729 -0
- package/src/engine/pico8/harness.test.ts +181 -0
- package/src/engine/pico8/harness.ts +354 -0
- package/src/engine/pico8/index.ts +20 -0
- package/src/engine/pico8/sentinel.test.ts +80 -0
- package/src/engine/pico8/sentinel.ts +93 -0
- package/src/engine/pico8/session-daemon-entry.ts +12 -0
- package/src/engine/pico8/session-daemon-main.ts +98 -0
- package/src/engine/pico8/session-daemon.test.ts +212 -0
- package/src/engine/pico8/session-daemon.ts +306 -0
- package/src/engine/pico8/session.test.ts +239 -0
- package/src/engine/pico8/session.ts +321 -0
- package/src/engine/pico8/shell.test.ts +583 -0
- package/src/engine/pico8/shell.ts +412 -0
- package/src/engine/pico8/supervisor.test.ts +105 -0
- package/src/engine/pico8/supervisor.ts +197 -0
- package/src/engine/scaffold/index.ts +15 -0
- package/src/engine/scaffold/scaffold.ts +278 -0
- package/src/engine/shrinko/adapter.ts +241 -0
- package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
- package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
- package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
- package/src/engine/shrinko/index.ts +33 -0
- package/src/engine/shrinko/shell.ts +224 -0
- package/src/engine/shrinko/shrinko.test.ts +182 -0
- package/src/engine/skills/index.ts +14 -0
- package/src/engine/skills/skills.test.ts +376 -0
- package/src/engine/skills/skills.ts +205 -0
- package/src/index.ts +13 -0
- package/src/skills/game-design-reference/SKILL.md +70 -0
- package/src/skills/game-jam/SKILL.md +45 -0
- package/src/skills/picopilot-art/SKILL.md +62 -0
- package/src/skills/picopilot-audio/SKILL.md +194 -0
- package/src/skills/picopilot-code/SKILL.md +97 -0
- package/src/skills/picopilot-code/reference/README.md +42 -0
- package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
- package/src/skills/picopilot-code/reference/platformer.md +119 -0
- package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
- package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
- package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
- package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
- package/src/skills/picopilot-debug/SKILL.md +195 -0
- package/src/skills/picopilot-overview/SKILL.md +52 -0
- package/src/version.ts +22 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {existsSync, mkdtempSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {Cli} from 'incur';
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
7
|
+
import type {
|
|
8
|
+
ExportOptions,
|
|
9
|
+
Pico8Adapter,
|
|
10
|
+
Pico8ExportResult,
|
|
11
|
+
} from '../engine/pico8/index.js';
|
|
12
|
+
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
13
|
+
import {
|
|
14
|
+
type CleanupRegistrar,
|
|
15
|
+
type Pico8AdapterFactory,
|
|
16
|
+
registerServe,
|
|
17
|
+
type ServerFactory,
|
|
18
|
+
} from './serve.js';
|
|
19
|
+
|
|
20
|
+
const CART_TEXT =
|
|
21
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\nprint("hi")\n';
|
|
22
|
+
|
|
23
|
+
let dir: string;
|
|
24
|
+
let cartPath: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
|
|
28
|
+
cartPath = join(dir, 'main.p8');
|
|
29
|
+
writeFileSync(cartPath, CART_TEXT);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync('rm', ['-rf', dir]);
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A stub adapter whose `export` writes the bundle into `outDir` so the serve
|
|
42
|
+
* loop can verify the export step ran and produced a payload, WITHOUT the paid
|
|
43
|
+
* binary. Records the {@link ExportOptions} for assertions.
|
|
44
|
+
*/
|
|
45
|
+
function stubAdapter(
|
|
46
|
+
result: Pico8ExportResult | 'writes-bundle' | 'writes-no-js',
|
|
47
|
+
seen?: {options?: ExportOptions},
|
|
48
|
+
): Pico8Adapter {
|
|
49
|
+
const notUsed = () => {
|
|
50
|
+
throw new Error('not used by serve');
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
run: notUsed as never,
|
|
54
|
+
record: notUsed as never,
|
|
55
|
+
drive: notUsed as never,
|
|
56
|
+
async export(options) {
|
|
57
|
+
if (seen !== undefined) seen.options = options;
|
|
58
|
+
if (result === 'writes-bundle') {
|
|
59
|
+
const html = join(options.outDir, 'index.html');
|
|
60
|
+
const js = join(options.outDir, 'index.js');
|
|
61
|
+
writeFileSync(html, '<html></html>');
|
|
62
|
+
writeFileSync(js, '// runtime');
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
value: {
|
|
66
|
+
htmlPath: html,
|
|
67
|
+
jsPath: js,
|
|
68
|
+
files: [html, js],
|
|
69
|
+
labelWarning: false,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (result === 'writes-no-js') {
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
value: {
|
|
77
|
+
htmlPath: undefined,
|
|
78
|
+
jsPath: undefined,
|
|
79
|
+
files: [],
|
|
80
|
+
labelWarning: true,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A fake {@link ServerFactory} that never opens a real socket: it records the
|
|
91
|
+
* root dir it was asked to serve and returns a dummy server + the port it would
|
|
92
|
+
* have bound. This keeps the serve loop testable with no dangling listener.
|
|
93
|
+
*/
|
|
94
|
+
function fakeServer(seen?: {rootDir?: string; port?: number}): ServerFactory {
|
|
95
|
+
return async (rootDir, port) => {
|
|
96
|
+
if (seen !== undefined) {
|
|
97
|
+
seen.rootDir = rootDir;
|
|
98
|
+
seen.port = port;
|
|
99
|
+
}
|
|
100
|
+
// A minimal object satisfying the return contract; nothing listens.
|
|
101
|
+
return {server: {close() {}} as never, port: port === 0 ? 49152 : port};
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runServe(
|
|
106
|
+
factory: Pico8AdapterFactory,
|
|
107
|
+
serverFactory: ServerFactory,
|
|
108
|
+
argv: string[] = ['serve', cartPath, '--json'],
|
|
109
|
+
env: Record<string, string | undefined> = {PATH: '/does/not/matter'},
|
|
110
|
+
// Captures the cleanup fn instead of wiring real process signal handlers.
|
|
111
|
+
cleanupSink?: {cleanup?: () => void},
|
|
112
|
+
) {
|
|
113
|
+
const cli = Cli.create('picopilot', {version: '0.0.0'});
|
|
114
|
+
const registerCleanup: CleanupRegistrar = (cleanup) => {
|
|
115
|
+
if (cleanupSink !== undefined) cleanupSink.cleanup = cleanup;
|
|
116
|
+
};
|
|
117
|
+
registerServe(cli, factory, serverFactory, registerCleanup);
|
|
118
|
+
let stdout = '';
|
|
119
|
+
let exitCode = 0;
|
|
120
|
+
await cli.serve(argv, {
|
|
121
|
+
stdout(s) {
|
|
122
|
+
stdout += s;
|
|
123
|
+
},
|
|
124
|
+
exit(code) {
|
|
125
|
+
exitCode = code;
|
|
126
|
+
},
|
|
127
|
+
env,
|
|
128
|
+
});
|
|
129
|
+
return {stdout, exitCode};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe('picopilot serve: PICO-8 absent (serve always exports, so it requires PICO-8)', () => {
|
|
133
|
+
it('returns structured pico8-not-found + nonzero exit before serving anything', async () => {
|
|
134
|
+
const seen: {rootDir?: string} = {};
|
|
135
|
+
const {stdout, exitCode} = await runServe(
|
|
136
|
+
() => stubAdapter(pico8NotFound()),
|
|
137
|
+
fakeServer(seen),
|
|
138
|
+
);
|
|
139
|
+
expect(exitCode).not.toBe(0);
|
|
140
|
+
expect(stdout).toContain('set PICO8_PATH or install PICO-8');
|
|
141
|
+
// The server was never bound because the export failed first.
|
|
142
|
+
expect(seen.rootDir).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('picopilot serve: export-then-serve loop', () => {
|
|
147
|
+
it('exports the cart, then serves the export dir and reports the URL', async () => {
|
|
148
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
149
|
+
const serveSeen: {rootDir?: string; port?: number} = {};
|
|
150
|
+
const {stdout, exitCode} = await runServe(
|
|
151
|
+
() => stubAdapter('writes-bundle', exportSeen),
|
|
152
|
+
fakeServer(serveSeen),
|
|
153
|
+
['serve', cartPath, '--port', '8080', '--json'],
|
|
154
|
+
);
|
|
155
|
+
expect(exitCode).toBe(0);
|
|
156
|
+
const out = JSON.parse(stdout);
|
|
157
|
+
expect(out.url).toBe('http://127.0.0.1:8080/');
|
|
158
|
+
expect(out.port).toBe(8080);
|
|
159
|
+
// The dir served is exactly the dir the export wrote into (a temp dir).
|
|
160
|
+
expect(serveSeen.rootDir).toBe(exportSeen.options?.outDir);
|
|
161
|
+
expect(serveSeen.rootDir?.startsWith(tmpdir())).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('registers a cleanup that removes the temp serve dir (no leak on a long-lived serve)', async () => {
|
|
165
|
+
const serveSeen: {rootDir?: string} = {};
|
|
166
|
+
const cleanupSink: {cleanup?: () => void} = {};
|
|
167
|
+
await runServe(
|
|
168
|
+
() => stubAdapter('writes-bundle'),
|
|
169
|
+
fakeServer(serveSeen),
|
|
170
|
+
['serve', cartPath, '--json'],
|
|
171
|
+
{PATH: '/does/not/matter'},
|
|
172
|
+
cleanupSink,
|
|
173
|
+
);
|
|
174
|
+
const serveDir = serveSeen.rootDir!;
|
|
175
|
+
// The dir exists while serving; the registered cleanup removes it on exit.
|
|
176
|
+
expect(existsSync(serveDir)).toBe(true);
|
|
177
|
+
expect(cleanupSink.cleanup).toBeTypeOf('function');
|
|
178
|
+
cleanupSink.cleanup!();
|
|
179
|
+
expect(existsSync(serveDir)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('reports the OS-assigned port when --port 0 is chosen', async () => {
|
|
183
|
+
const {stdout} = await runServe(
|
|
184
|
+
() => stubAdapter('writes-bundle'),
|
|
185
|
+
fakeServer(),
|
|
186
|
+
['serve', cartPath, '--port', '0', '--json'],
|
|
187
|
+
);
|
|
188
|
+
const out = JSON.parse(stdout);
|
|
189
|
+
// The fake maps 0 -> 49152 to model an OS-assigned free port.
|
|
190
|
+
expect(out.port).toBe(49152);
|
|
191
|
+
expect(out.url).toBe('http://127.0.0.1:49152/');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('picopilot serve: failure paths', () => {
|
|
196
|
+
it('errors when the export produced no bundle, and removes the temp dir immediately', async () => {
|
|
197
|
+
const serveSeen: {rootDir?: string} = {};
|
|
198
|
+
const exportSeen: {options?: ExportOptions} = {};
|
|
199
|
+
const {stdout, exitCode} = await runServe(
|
|
200
|
+
() => stubAdapter('writes-no-js', exportSeen),
|
|
201
|
+
fakeServer(serveSeen),
|
|
202
|
+
['serve', cartPath, '--json'],
|
|
203
|
+
);
|
|
204
|
+
expect(exitCode).not.toBe(0);
|
|
205
|
+
expect(serveSeen.rootDir).toBeUndefined();
|
|
206
|
+
// The temp dir created before the failed export is cleaned up on the spot.
|
|
207
|
+
expect(existsSync(exportSeen.options!.outDir)).toBe(false);
|
|
208
|
+
// The labelless message redirects to the self-verify tools (run/playtest)
|
|
209
|
+
// and points at the real label remedy, NOT hand-editing main.p8: this is the
|
|
210
|
+
// exact loop weak models fell into.
|
|
211
|
+
const lower = stdout.toLowerCase();
|
|
212
|
+
expect(lower).toContain('picopilot run');
|
|
213
|
+
expect(lower).toContain('playtest');
|
|
214
|
+
expect(lower).toContain('export --label');
|
|
215
|
+
expect(lower).toContain('do not hand-edit main.p8');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('errors with a nonzero exit when the cart path does not exist', async () => {
|
|
219
|
+
const {stdout, exitCode} = await runServe(
|
|
220
|
+
() => stubAdapter('writes-bundle'),
|
|
221
|
+
fakeServer(),
|
|
222
|
+
['serve', join(dir, 'nope.p8'), '--json'],
|
|
223
|
+
);
|
|
224
|
+
expect(exitCode).not.toBe(0);
|
|
225
|
+
expect(stdout).toContain('no cart at');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createReadStream,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import {createServer, type Server} from 'node:http';
|
|
9
|
+
import {tmpdir} from 'node:os';
|
|
10
|
+
import {extname, isAbsolute, join, normalize, resolve} from 'node:path';
|
|
11
|
+
import {type Cli, z} from 'incur';
|
|
12
|
+
import {
|
|
13
|
+
EXPORT_HTML_NAME,
|
|
14
|
+
type Pico8Adapter,
|
|
15
|
+
ShellPico8Adapter,
|
|
16
|
+
} from '../engine/pico8/index.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Injects the PICO-8 adapter `serve` uses (defaults to the shell adapter). Same
|
|
20
|
+
* seam as `export`/`run`: a test passes a stub so the export step needs no paid
|
|
21
|
+
* binary, and the child `env` (`PICO8_PATH`/`PATH`) stays isolated.
|
|
22
|
+
*/
|
|
23
|
+
export type Pico8AdapterFactory = (env: NodeJS.ProcessEnv) => Pico8Adapter;
|
|
24
|
+
|
|
25
|
+
const defaultAdapterFactory: Pico8AdapterFactory = (env) =>
|
|
26
|
+
new ShellPico8Adapter({env});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Injects the HTTP-server factory `serve` binds. The default is a zero-dep
|
|
30
|
+
* `node:http` static server over the export dir; a test injects a fake to assert
|
|
31
|
+
* the serve loop WITHOUT opening a real socket. The factory returns the started
|
|
32
|
+
* server plus the bound port (so `:0` -> an OS-assigned port is observable).
|
|
33
|
+
*/
|
|
34
|
+
export type ServerFactory = (
|
|
35
|
+
rootDir: string,
|
|
36
|
+
port: number,
|
|
37
|
+
) => Promise<{server: Server; port: number}>;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_PORT = 5858;
|
|
40
|
+
const DEFAULT_BACKSTOP_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort recursive removal of the temp export dir, swallowing any error (a
|
|
44
|
+
* cleanup failure must never mask the command's own result or crash on exit).
|
|
45
|
+
*/
|
|
46
|
+
function removeDir(dir: string): void {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(dir, {recursive: true, force: true});
|
|
49
|
+
} catch {
|
|
50
|
+
// A stubborn temp dir is not fatal; the OS reaps it eventually.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Registers a one-shot cleanup for the serving case: `serve` runs until the user
|
|
56
|
+
* interrupts it (the socket keeps the process alive), so the temp export dir is
|
|
57
|
+
* removed on SIGINT/SIGTERM and on normal `exit`. The handler fires at most once.
|
|
58
|
+
* The default uses `process`; a test injects a fake to assert the wiring without
|
|
59
|
+
* touching real signal handlers.
|
|
60
|
+
*/
|
|
61
|
+
export type CleanupRegistrar = (cleanup: () => void) => void;
|
|
62
|
+
|
|
63
|
+
const defaultCleanupRegistrar: CleanupRegistrar = (cleanup) => {
|
|
64
|
+
let done = false;
|
|
65
|
+
const once = (): void => {
|
|
66
|
+
if (done) return;
|
|
67
|
+
done = true;
|
|
68
|
+
cleanup();
|
|
69
|
+
};
|
|
70
|
+
process.once('exit', once);
|
|
71
|
+
// On a signal, run cleanup then re-exit so the temp dir is gone even on Ctrl-C.
|
|
72
|
+
process.once('SIGINT', () => {
|
|
73
|
+
once();
|
|
74
|
+
process.exit(130);
|
|
75
|
+
});
|
|
76
|
+
process.once('SIGTERM', () => {
|
|
77
|
+
once();
|
|
78
|
+
process.exit(143);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** MIME types the static server needs for a PICO-8 export (html + the runtime js). */
|
|
83
|
+
const MIME: Record<string, string> = {
|
|
84
|
+
'.html': 'text/html; charset=utf-8',
|
|
85
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
86
|
+
'.wasm': 'application/wasm',
|
|
87
|
+
'.png': 'image/png',
|
|
88
|
+
'.json': 'application/json; charset=utf-8',
|
|
89
|
+
'.css': 'text/css; charset=utf-8',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The default {@link ServerFactory}: a minimal, zero-dependency `node:http`
|
|
94
|
+
* static file server rooted at `rootDir`. It resolves `/` to `index.html`,
|
|
95
|
+
* path-traversal-guards every request against `rootDir`, and streams files with
|
|
96
|
+
* a best-effort content type. It exists to PLAY an export locally, not to be a
|
|
97
|
+
* production server. Binding `:0` yields an OS-assigned port, reported back so
|
|
98
|
+
* the command can print the real URL.
|
|
99
|
+
*/
|
|
100
|
+
const defaultServerFactory: ServerFactory = (rootDir, port) =>
|
|
101
|
+
new Promise((resolvePromise, reject) => {
|
|
102
|
+
const server = createServer((req, res) => {
|
|
103
|
+
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]!);
|
|
104
|
+
// Resolve the request against the root and guard against traversal: the
|
|
105
|
+
// resolved path must stay inside rootDir.
|
|
106
|
+
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
|
|
107
|
+
let filePath = join(rootDir, rel);
|
|
108
|
+
if (!filePath.startsWith(rootDir)) {
|
|
109
|
+
res.writeHead(403).end('forbidden');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
|
114
|
+
filePath = join(filePath, 'index.html');
|
|
115
|
+
}
|
|
116
|
+
if (!existsSync(filePath)) {
|
|
117
|
+
res.writeHead(404).end('not found');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
res.writeHead(200, {
|
|
121
|
+
'content-type':
|
|
122
|
+
MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream',
|
|
123
|
+
});
|
|
124
|
+
createReadStream(filePath).pipe(res);
|
|
125
|
+
} catch {
|
|
126
|
+
res.writeHead(500).end('server error');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.on('error', reject);
|
|
130
|
+
server.listen(port, '127.0.0.1', () => {
|
|
131
|
+
const addr = server.address();
|
|
132
|
+
const bound =
|
|
133
|
+
typeof addr === 'object' && addr !== null ? addr.port : port;
|
|
134
|
+
resolvePromise({server, port: bound});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Registers `picopilot serve`, the "play this cart in a browser" loop. It ALWAYS
|
|
140
|
+
* exports first (the quick round-trip: cart -> standalone HTML bundle -> local
|
|
141
|
+
* server -> URL you open), so it takes a cart path, not a pre-built dir. The
|
|
142
|
+
* export step hard-requires PICO-8 (absence is the structured `pico8-not-found`
|
|
143
|
+
* value + a nonzero exit, mirroring `run`/`export`); the static server itself is
|
|
144
|
+
* a zero-dep `node:http` file server over the temp export dir, so no extra
|
|
145
|
+
* dependency and no ~/Desktop pollution.
|
|
146
|
+
*
|
|
147
|
+
* It is distinct from `export`: `export` PRODUCES a bundle at a chosen dest (for
|
|
148
|
+
* the showcase); `serve` throws the bundle in a temp dir and serves it for a
|
|
149
|
+
* quick manual play. It runs until interrupted (the server keeps the process
|
|
150
|
+
* alive), which is why the injected {@link ServerFactory} seam lets a test drive
|
|
151
|
+
* the export + bind path without leaving a real socket open.
|
|
152
|
+
*
|
|
153
|
+
* @param adapterFactory injects the PICO-8 adapter (defaults to the shell one).
|
|
154
|
+
* @param serverFactory injects the HTTP server (defaults to the zero-dep static
|
|
155
|
+
* server); a test passes a fake to assert the loop without a real socket.
|
|
156
|
+
* @param registerCleanup injects the exit/signal cleanup wiring (defaults to the
|
|
157
|
+
* real `process` handlers); a test passes a fake to capture the cleanup fn.
|
|
158
|
+
*/
|
|
159
|
+
export function registerServe(
|
|
160
|
+
cli: Cli.Cli,
|
|
161
|
+
adapterFactory: Pico8AdapterFactory = defaultAdapterFactory,
|
|
162
|
+
serverFactory: ServerFactory = defaultServerFactory,
|
|
163
|
+
registerCleanup: CleanupRegistrar = defaultCleanupRegistrar,
|
|
164
|
+
): void {
|
|
165
|
+
cli.command('serve', {
|
|
166
|
+
description:
|
|
167
|
+
'Export a cart and serve it locally so a HUMAN can play it in a browser (for SHARING a playable build). Requires PICO-8, and a cart __label__. To verify your OWN build works, use `picopilot run` (screenshots) or `picopilot playtest` instead: serve is not the self-verification path.',
|
|
168
|
+
args: z.object({
|
|
169
|
+
cart: z
|
|
170
|
+
.string()
|
|
171
|
+
.default('main.p8')
|
|
172
|
+
.describe(
|
|
173
|
+
'The .p8 cart to export and serve. Defaults to main.p8 in the current folder.',
|
|
174
|
+
),
|
|
175
|
+
}),
|
|
176
|
+
options: z.object({
|
|
177
|
+
port: z
|
|
178
|
+
.number()
|
|
179
|
+
.int()
|
|
180
|
+
.min(0)
|
|
181
|
+
.max(65535)
|
|
182
|
+
.default(DEFAULT_PORT)
|
|
183
|
+
.describe('Port to serve on (0 = an OS-assigned free port).'),
|
|
184
|
+
backstopMs: z
|
|
185
|
+
.number()
|
|
186
|
+
.int()
|
|
187
|
+
.positive()
|
|
188
|
+
.default(DEFAULT_BACKSTOP_MS)
|
|
189
|
+
.describe('Safety backstop for the export step.'),
|
|
190
|
+
}),
|
|
191
|
+
env: z.object({
|
|
192
|
+
PICO8_PATH: z
|
|
193
|
+
.string()
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('Explicit path to the PICO-8 binary (else `pico8` on PATH).'),
|
|
196
|
+
PATH: z.string().optional().describe('Used to locate `pico8`.'),
|
|
197
|
+
}),
|
|
198
|
+
output: z.object({
|
|
199
|
+
url: z.string().describe('The local URL to open in a browser.'),
|
|
200
|
+
port: z.number().describe('The port the server bound to.'),
|
|
201
|
+
serveDir: z
|
|
202
|
+
.string()
|
|
203
|
+
.describe('The temp dir the export bundle was served from.'),
|
|
204
|
+
}),
|
|
205
|
+
examples: [
|
|
206
|
+
{description: 'Export and serve main.p8'},
|
|
207
|
+
{description: 'Serve a specific cart', args: {cart: 'game.p8'}},
|
|
208
|
+
{
|
|
209
|
+
description: 'Serve on a chosen port',
|
|
210
|
+
args: {cart: 'game.p8'},
|
|
211
|
+
options: {port: 8080},
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
async run({args, options, env, error, ok}) {
|
|
215
|
+
const cartPath = isAbsolute(args.cart)
|
|
216
|
+
? args.cart
|
|
217
|
+
: resolve(process.cwd(), args.cart);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(cartPath)) {
|
|
220
|
+
return error({
|
|
221
|
+
code: 'cart-not-found',
|
|
222
|
+
message: `no cart at ${cartPath}`,
|
|
223
|
+
exitCode: 1,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Always export first, into an isolated temp dir (never a user path or
|
|
228
|
+
// ~/Desktop). The bundle is the standalone pair the static server plays.
|
|
229
|
+
const serveDir = mkdtempSync(join(tmpdir(), 'picopilot-serve-'));
|
|
230
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
231
|
+
const result = await adapter.export({
|
|
232
|
+
cartPath,
|
|
233
|
+
outDir: serveDir,
|
|
234
|
+
htmlName: EXPORT_HTML_NAME,
|
|
235
|
+
backstopMs: options.backstopMs,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!result.ok) {
|
|
239
|
+
// Nothing is being served: remove the temp dir now, not on exit.
|
|
240
|
+
removeDir(serveDir);
|
|
241
|
+
return error({
|
|
242
|
+
code: result.reason,
|
|
243
|
+
message: `PICO-8 is not installed. ${result.remedy} (needs: ${result.needs.join(', ')})`,
|
|
244
|
+
exitCode: 1,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (result.value.jsPath === undefined) {
|
|
249
|
+
removeDir(serveDir);
|
|
250
|
+
return error({
|
|
251
|
+
code: 'export-failed',
|
|
252
|
+
message: result.value.labelWarning
|
|
253
|
+
? 'PICO-8 produced no bundle: this cart has no __label__ (the export splash), which the browser export needs. To just SEE your own build work, use `picopilot run` or `picopilot playtest` (no label needed) instead of serve. If you genuinely want a browser build to SHARE, bake a label first with `picopilot export --label <128x128.png>` (do NOT hand-edit main.p8), then re-serve.'
|
|
254
|
+
: 'PICO-8 produced no export bundle. Check the cart runs without a boot error (via `picopilot run`).',
|
|
255
|
+
exitCode: 1,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const {server, port} = await serverFactory(serveDir, options.port);
|
|
260
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
261
|
+
|
|
262
|
+
// The server keeps the process alive until interrupted; the temp export dir
|
|
263
|
+
// is reaped on SIGINT/SIGTERM/exit so a long-lived serve leaves no leak.
|
|
264
|
+
// `server` is referenced so its lifetime is the command's; a test's fake
|
|
265
|
+
// resolves without a real socket.
|
|
266
|
+
registerCleanup(() => removeDir(serveDir));
|
|
267
|
+
void server;
|
|
268
|
+
|
|
269
|
+
return ok({url, port, serveDir});
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|