c64bridge 0.7.0 → 0.7.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/mcp.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Stdio-based Model Context Protocol (MCP) server that bridges Commodore 64 Ultimate hardware with AI agents.",
5
5
  "entry": "dist/index.js",
6
6
  "type": "stdio",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Local MCP server for controlling a Commodore 64 via Ultimate 64 REST API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "mcp": "node scripts/invoke-bun.mjs src/mcp-server.ts",
18
18
  "mcp:node": "node dist/mcp-server.js",
19
19
  "mcp:build": "npm run build && node dist/mcp-server.js",
20
- "build": "node scripts/prebuild.mjs && tsc -p tsconfig.json && node scripts/postbuild.mjs && node scripts/invoke-bun.mjs scripts/update-readme.ts",
20
+ "build": "node scripts/prebuild.mjs && tsc -p tsconfig.json && node scripts/postbuild.mjs && node scripts/invoke-bun.mjs scripts/update-readme.ts",
21
21
  "lint": "npm run check",
22
22
  "test": "node scripts/invoke-bun.mjs scripts/run-tests.ts",
23
23
  "test:node": "node scripts/run-tests.mjs",
@@ -15,7 +15,7 @@ import { setTimeout as sleep } from "node:timers/promises";
15
15
  import { Buffer } from "node:buffer";
16
16
  import { performance } from "node:perf_hooks";
17
17
  import YAML from "yaml";
18
- import { startMockC64Server } from "../test/mockC64Server.mjs";
18
+ import { startMockC64Server } from "./mockC64Server.mjs";
19
19
 
20
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
21
  const ROOT_DIR = path.resolve(__dirname, "..");
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Extract a specific version's changelog entry from CHANGELOG.md
4
+ * Usage: node scripts/extract-changelog-entry.mjs <version>
5
+ * Outputs the changelog section for the given version to stdout.
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ const version = process.argv[2];
11
+ if (!version) {
12
+ console.error('Error: version argument required');
13
+ process.exit(1);
14
+ }
15
+
16
+ function escapeRegex(str) {
17
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ }
19
+
20
+ const changelogPath = path.resolve('CHANGELOG.md');
21
+ let content;
22
+ try {
23
+ content = await readFile(changelogPath, 'utf8');
24
+ } catch (err) {
25
+ console.error(`Error reading CHANGELOG.md: ${err.message}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ // Match the version header (e.g., "## 0.7.0 - 2025-11-01")
30
+ const versionPattern = new RegExp(`^## ${escapeRegex(version)} - \\d{4}-\\d{2}-\\d{2}$`, 'm');
31
+ const match = content.match(versionPattern);
32
+
33
+ if (!match) {
34
+ console.error(`Error: changelog entry for version ${version} not found`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const startIndex = match.index;
39
+ // Find the next version header or end of file
40
+ const nextHeaderPattern = /^## \d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$/m;
41
+ const remainingContent = content.slice(startIndex + match[0].length);
42
+ const nextMatch = remainingContent.match(nextHeaderPattern);
43
+
44
+ let entry;
45
+ if (nextMatch) {
46
+ entry = content.slice(startIndex, startIndex + match[0].length + nextMatch.index);
47
+ } else {
48
+ entry = content.slice(startIndex);
49
+ }
50
+
51
+ // Trim trailing whitespace
52
+ console.log(entry.trimEnd());
@@ -0,0 +1,677 @@
1
+ import { createServer } from "node:http";
2
+ import { once } from "node:events";
3
+ import { Buffer } from "node:buffer";
4
+ import { getChargenGlyphs } from "../dist/chargen.js";
5
+
6
+ function parseNumeric(value, defaultRadix = 16) {
7
+ if (!value) {
8
+ return 0;
9
+ }
10
+ const trimmed = value.trim().toLowerCase();
11
+ if (trimmed.startsWith("$")) {
12
+ return Number.parseInt(trimmed.slice(1), 16);
13
+ }
14
+ if (trimmed.startsWith("0x")) {
15
+ return Number.parseInt(trimmed.slice(2), 16);
16
+ }
17
+ if (trimmed.startsWith("%")) {
18
+ return Number.parseInt(trimmed.slice(1), 2);
19
+ }
20
+ return Number.parseInt(trimmed, defaultRadix);
21
+ }
22
+
23
+ function normaliseHexString(input) {
24
+ if (!input) {
25
+ return "";
26
+ }
27
+ return input.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
28
+ }
29
+
30
+ function createDefaultConfigs() {
31
+ return {
32
+ Audio: {
33
+ Volume: "6",
34
+ Balance: "center",
35
+ },
36
+ Video: {
37
+ Mode: "PAL",
38
+ },
39
+ };
40
+ }
41
+
42
+ async function readRequestBody(req) {
43
+ const chunks = [];
44
+ for await (const chunk of req) {
45
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
46
+ }
47
+ if (chunks.length === 0) {
48
+ return Buffer.alloc(0);
49
+ }
50
+ return Buffer.concat(chunks);
51
+ }
52
+
53
+ async function readJson(req) {
54
+ try {
55
+ const body = await readRequestBody(req);
56
+ if (body.length === 0) {
57
+ return {};
58
+ }
59
+ return JSON.parse(body.toString("utf8"));
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ function sendJson(res, payload = {}, statusCode = 200) {
66
+ res.statusCode = statusCode;
67
+ res.setHeader("Content-Type", "application/json");
68
+ const body = { ...payload };
69
+ if (!Object.prototype.hasOwnProperty.call(body, "errors")) {
70
+ body.errors = [];
71
+ }
72
+ res.end(JSON.stringify(body));
73
+ }
74
+
75
+ function createInitialState() {
76
+ return {
77
+ lastPrg: null,
78
+ runCount: 0,
79
+ resets: 0,
80
+ reboots: 0,
81
+ poweroffs: 0,
82
+ memory: new Uint8Array(0x10000),
83
+ lastWrite: null,
84
+ lastRequest: null,
85
+ drives: {},
86
+ lastDriveOperation: null,
87
+ createdImages: [],
88
+ lastFileInfo: null,
89
+ sidplayCount: 0,
90
+ lastSidplay: null,
91
+ sidAttachmentCount: 0,
92
+ lastSidAttachment: null,
93
+ modplayCount: 0,
94
+ lastModplay: null,
95
+ paused: false,
96
+ lastPause: null,
97
+ lastResume: null,
98
+ lastPoweroff: null,
99
+ debugreg: "00",
100
+ configs: createDefaultConfigs(),
101
+ flashSnapshot: null,
102
+ lastConfigAction: null,
103
+ menuToggleCount: 0,
104
+ lastMenuTarget: null,
105
+ streams: {
106
+ video: { active: false, target: null },
107
+ audio: { active: false, target: null },
108
+ debug: { active: false, target: null },
109
+ },
110
+ lastStreamAction: null,
111
+ };
112
+ }
113
+
114
+ const SCREEN_CODE_LOOKUP = (() => {
115
+ const map = new Map();
116
+ for (const glyph of getChargenGlyphs()) {
117
+ if (!glyph || typeof glyph.screenCode !== "number") {
118
+ continue;
119
+ }
120
+ const code = glyph.screenCode & 0xff;
121
+ if (glyph.basic && glyph.basic.length === 1 && !map.has(glyph.basic)) {
122
+ map.set(glyph.basic, code);
123
+ }
124
+ if (!map.has(String.fromCharCode(glyph.petsciiCode & 0xff))) {
125
+ map.set(String.fromCharCode(glyph.petsciiCode & 0xff), code);
126
+ }
127
+ }
128
+ if (!map.has(" ")) {
129
+ map.set(" ", 0x20);
130
+ }
131
+ return map;
132
+ })();
133
+
134
+ function toScreenCode(char) {
135
+ return SCREEN_CODE_LOOKUP.get(char) ?? SCREEN_CODE_LOOKUP.get(" ") ?? 0x20;
136
+ }
137
+
138
+ function seedReadyPrompt(state) {
139
+ const text = "READY.";
140
+ const buffer = new Uint8Array(text.length + 1);
141
+ for (let i = 0; i < text.length; i += 1) {
142
+ buffer[i] = toScreenCode(text[i]);
143
+ }
144
+ buffer[text.length] = toScreenCode(" ");
145
+ state.memory.set(buffer, 0x0400);
146
+ }
147
+
148
+ export async function startMockC64Server() {
149
+ const state = createInitialState();
150
+
151
+ // seed memory with READY prompt at $0400 using screen codes
152
+ seedReadyPrompt(state);
153
+
154
+ function ensureDriveState(id) {
155
+ if (!state.drives[id]) {
156
+ state.drives[id] = {
157
+ mountedImage: null,
158
+ mode: "1541",
159
+ power: "off",
160
+ resetCount: 0,
161
+ lastRom: null,
162
+ };
163
+ }
164
+ return state.drives[id];
165
+ }
166
+
167
+ ensureDriveState("drive8");
168
+
169
+ function resetState() {
170
+ const fresh = createInitialState();
171
+ Object.assign(state, fresh);
172
+ seedReadyPrompt(state);
173
+ ensureDriveState("drive8");
174
+ }
175
+
176
+ const server = createServer(async (req, res) => {
177
+ const { method, url } = req;
178
+
179
+ if (!method || !url) {
180
+ res.statusCode = 400;
181
+ res.end();
182
+ return;
183
+ }
184
+
185
+ // Track last request metadata
186
+ state.lastRequest = { method, url, headers: req.headers };
187
+
188
+ if (method === "GET" && (url === "/" || url.startsWith("/?"))) {
189
+ sendJson(res, { status: "ok", host: "mock" });
190
+ return;
191
+ }
192
+
193
+ if (method === "GET" && url === "/v1/version") {
194
+ sendJson(res, { version: "0.1-mock" });
195
+ return;
196
+ }
197
+
198
+ if (method === "GET" && url === "/v1/info") {
199
+ sendJson(res, { product: "U64-MOCK", firmware_version: "3.12-mock", hostname: "mockc64" });
200
+ return;
201
+ }
202
+
203
+ if (method === "GET" && url === "/v1/drives") {
204
+ const drives = {};
205
+ for (const [driveId, driveState] of Object.entries(state.drives)) {
206
+ drives[driveId] = {
207
+ enabled: driveState.power !== "off",
208
+ power: driveState.power,
209
+ mode: driveState.mode,
210
+ image: driveState.mountedImage,
211
+ };
212
+ }
213
+ sendJson(res, { drives });
214
+ return;
215
+ }
216
+
217
+ if (method === "PUT" && url === "/v1/machine:pause") {
218
+ state.paused = true;
219
+ state.lastPause = Date.now();
220
+ sendJson(res, { result: "paused" });
221
+ return;
222
+ }
223
+
224
+ if (method === "PUT" && url === "/v1/machine:resume") {
225
+ state.paused = false;
226
+ state.lastResume = Date.now();
227
+ sendJson(res, { result: "resumed" });
228
+ return;
229
+ }
230
+
231
+ if (method === "PUT" && url === "/v1/machine:poweroff") {
232
+ state.poweroffs += 1;
233
+ state.lastPoweroff = Date.now();
234
+ sendJson(res, { result: "poweroff" });
235
+ return;
236
+ }
237
+
238
+ if (method === "PUT" && url === "/v1/machine:menu_button") {
239
+ state.menuToggleCount += 1;
240
+ const target = req.headers["x-target"] ?? null;
241
+ state.lastMenuTarget = target;
242
+ sendJson(res, { result: "menu", target });
243
+ return;
244
+ }
245
+
246
+ if (method === "GET" && url === "/v1/machine:debugreg") {
247
+ sendJson(res, { value: state.debugreg ?? "00" });
248
+ return;
249
+ }
250
+
251
+ if (method === "PUT" && url.startsWith("/v1/machine:debugreg")) {
252
+ const routeUrl = new URL(url, "http://mock.local");
253
+ const value = (routeUrl.searchParams.get("value") ?? "00").toUpperCase();
254
+ state.debugreg = value;
255
+ sendJson(res, { value });
256
+ return;
257
+ }
258
+
259
+ if (method === "POST" && url === "/v1/runners:run_prg") {
260
+ const chunks = [];
261
+ for await (const chunk of req) {
262
+ chunks.push(chunk);
263
+ }
264
+
265
+ const prg = Buffer.concat(chunks);
266
+ state.lastPrg = prg;
267
+ state.runCount += 1;
268
+
269
+ sendJson(res, { result: "ok", bytes: prg.length });
270
+ return;
271
+ }
272
+
273
+ if (url.startsWith("/v1/runners:sidplay")) {
274
+ let routeUrl;
275
+ try {
276
+ routeUrl = new URL(url, "http://mock.local");
277
+ } catch {
278
+ routeUrl = null;
279
+ }
280
+
281
+ if (routeUrl) {
282
+ const songnrParam = routeUrl.searchParams.get("songnr");
283
+ const songnr = songnrParam === null ? null : Number.parseInt(songnrParam, 10);
284
+
285
+ if (method === "PUT") {
286
+ const file = routeUrl.searchParams.get("file") ?? "";
287
+ state.sidplayCount += 1;
288
+ state.lastSidplay = { file, songnr };
289
+ sendJson(res, { result: "sidplay", file, songnr });
290
+ return;
291
+ }
292
+
293
+ if (method === "POST") {
294
+ const chunks = [];
295
+ for await (const chunk of req) {
296
+ chunks.push(chunk);
297
+ }
298
+
299
+ const attachment = Buffer.concat(chunks);
300
+ state.sidAttachmentCount += 1;
301
+ state.lastSidAttachment = { songnr, bytes: attachment.length };
302
+ sendJson(res, { result: "sidplay_attachment", bytes: attachment.length, songnr });
303
+ return;
304
+ }
305
+ }
306
+ }
307
+
308
+ if (url.startsWith("/v1/runners:modplay")) {
309
+ let routeUrl;
310
+ try {
311
+ routeUrl = new URL(url, "http://mock.local");
312
+ } catch {
313
+ routeUrl = null;
314
+ }
315
+
316
+ if (routeUrl) {
317
+ if (method === "PUT") {
318
+ const file = routeUrl.searchParams.get("file") ?? "";
319
+ state.modplayCount += 1;
320
+ state.lastModplay = { file };
321
+ sendJson(res, { result: "modplay", file });
322
+ return;
323
+ }
324
+
325
+ if (method === "POST") {
326
+ const chunks = [];
327
+ for await (const chunk of req) {
328
+ chunks.push(chunk);
329
+ }
330
+
331
+ const attachment = Buffer.concat(chunks);
332
+ state.modplayCount += 1;
333
+ state.lastModplay = { file: null, bytes: attachment.length };
334
+ sendJson(res, { result: "modplay_attachment", bytes: attachment.length });
335
+ return;
336
+ }
337
+ }
338
+ }
339
+
340
+ if (method === "GET" && url.startsWith("/v1/machine:readmem")) {
341
+ const routeUrl = new URL(url, "http://mock.local");
342
+ const addressValue = routeUrl.searchParams.get("address") ?? "0";
343
+ const lengthValue = routeUrl.searchParams.get("length") ?? "256";
344
+ const address = parseNumeric(addressValue);
345
+ const length = Math.max(0, parseNumeric(lengthValue, 10));
346
+ const bytes = state.memory.slice(address, address + length);
347
+
348
+ const accept = String(req.headers["accept"] || "");
349
+ if (accept.includes("application/octet-stream")) {
350
+ res.setHeader("Content-Type", "application/octet-stream");
351
+ res.end(Buffer.from(bytes));
352
+ } else {
353
+ const payload = Buffer.from(bytes).toString("base64");
354
+ sendJson(res, { data: payload });
355
+ }
356
+ return;
357
+ }
358
+
359
+ if (method === "PUT" && url === "/v1/machine:reset") {
360
+ state.resets += 1;
361
+ sendJson(res, { result: "reset" });
362
+ return;
363
+ }
364
+
365
+ if (method === "PUT" && url === "/v1/machine:reboot") {
366
+ state.reboots += 1;
367
+ sendJson(res, { result: "reboot" });
368
+ return;
369
+ }
370
+
371
+ if (method === "PUT" && url.startsWith("/v1/machine:writemem")) {
372
+ const routeUrl = new URL(url, "http://mock.local");
373
+ const addressValue = routeUrl.searchParams.get("address") ?? "0";
374
+ const dataValue = normaliseHexString(routeUrl.searchParams.get("data") ?? "");
375
+ const address = parseNumeric(addressValue);
376
+ const bytes = Buffer.from(dataValue, "hex");
377
+
378
+ state.memory.set(bytes, address);
379
+ state.lastWrite = { address, bytes };
380
+
381
+ sendJson(res, { result: "wrote", address, length: bytes.length });
382
+ return;
383
+ }
384
+
385
+ if (method === "POST" && url.startsWith("/v1/machine:writemem")) {
386
+ const routeUrl = new URL(url, "http://mock.local");
387
+ const addressValue = routeUrl.searchParams.get("address") ?? "0";
388
+ const address = parseNumeric(addressValue);
389
+
390
+ const chunks = [];
391
+ for await (const chunk of req) {
392
+ chunks.push(chunk);
393
+ }
394
+ const bytes = Buffer.concat(chunks);
395
+
396
+ state.memory.set(bytes, address);
397
+ state.lastWrite = { address, bytes };
398
+
399
+ sendJson(res, { result: "wrote", address, length: bytes.length });
400
+ return;
401
+ }
402
+
403
+ if (method === "GET" && url === "/v1/configs") {
404
+ sendJson(res, { categories: Object.keys(state.configs), configs: state.configs });
405
+ return;
406
+ }
407
+
408
+ if (method === "POST" && url === "/v1/configs") {
409
+ const payload = await readJson(req);
410
+ if (payload && typeof payload === "object") {
411
+ for (const [category, items] of Object.entries(payload)) {
412
+ if (!state.configs[category]) {
413
+ state.configs[category] = {};
414
+ }
415
+ if (items && typeof items === "object") {
416
+ for (const [item, value] of Object.entries(items)) {
417
+ state.configs[category][item] = String(value);
418
+ }
419
+ }
420
+ }
421
+ }
422
+ state.lastConfigAction = { action: "batch_update", payload };
423
+ sendJson(res, { result: "batch_update", categories: Object.keys(payload ?? {}) });
424
+ return;
425
+ }
426
+
427
+ if (url.startsWith("/v1/configs:")) {
428
+ const routeUrl = new URL(url, "http://mock.local");
429
+ const action = routeUrl.pathname.slice("/v1/configs:".length);
430
+
431
+ if (method === "PUT" && action === "load_from_flash") {
432
+ if (state.flashSnapshot) {
433
+ state.configs = JSON.parse(JSON.stringify(state.flashSnapshot));
434
+ }
435
+ state.lastConfigAction = { action: "load_from_flash" };
436
+ sendJson(res, { result: "loaded", restored: Boolean(state.flashSnapshot) });
437
+ return;
438
+ }
439
+
440
+ if (method === "PUT" && action === "save_to_flash") {
441
+ state.flashSnapshot = JSON.parse(JSON.stringify(state.configs));
442
+ state.lastConfigAction = { action: "save_to_flash" };
443
+ sendJson(res, { result: "saved" });
444
+ return;
445
+ }
446
+
447
+ if (method === "PUT" && action === "reset_to_default") {
448
+ state.configs = createDefaultConfigs();
449
+ state.lastConfigAction = { action: "reset_to_default" };
450
+ sendJson(res, { result: "reset" });
451
+ return;
452
+ }
453
+ }
454
+
455
+ if (url.startsWith("/v1/configs/")) {
456
+ const routeUrl = new URL(url, "http://mock.local");
457
+ const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2); // remove v1 + configs
458
+
459
+ if (segments.length === 1) {
460
+ const [category] = segments;
461
+
462
+ if (method === "GET") {
463
+ const categoryData = state.configs[category] ?? {};
464
+ sendJson(res, { ...categoryData });
465
+ return;
466
+ }
467
+ }
468
+
469
+ if (segments.length === 2) {
470
+ const [category, item] = segments;
471
+ if (method === "GET") {
472
+ const categoryData = state.configs[category] ?? {};
473
+ const value = categoryData[item];
474
+ sendJson(res, { value });
475
+ return;
476
+ }
477
+
478
+ if (method === "PUT") {
479
+ const queryValue = routeUrl.searchParams.get("value");
480
+ const body = await readJson(req);
481
+ const value = queryValue ?? body?.value ?? "";
482
+ if (!state.configs[category]) {
483
+ state.configs[category] = {};
484
+ }
485
+ state.configs[category][item] = String(value);
486
+ state.lastConfigAction = { action: "set", category, item, value: String(value) };
487
+ sendJson(res, { result: "updated", category, item, value: String(value) });
488
+ return;
489
+ }
490
+ }
491
+ }
492
+
493
+ if (url.startsWith("/v1/streams/")) {
494
+ const routeUrl = new URL(url, "http://mock.local");
495
+ const match = /^\/v1\/streams\/([^:]+):(start|stop)$/.exec(routeUrl.pathname);
496
+ if (match) {
497
+ const stream = decodeURIComponent(match[1]);
498
+ const action = match[2];
499
+ if (!state.streams[stream]) {
500
+ state.streams[stream] = { active: false, target: null };
501
+ }
502
+
503
+ if (action === "start" && method === "PUT") {
504
+ const body = await readJson(req);
505
+ const target = routeUrl.searchParams.get("ip") ?? routeUrl.searchParams.get("target") ?? body?.ip ?? body?.target ?? null;
506
+ state.streams[stream] = { active: true, target };
507
+ state.lastStreamAction = { action: "start", stream, target };
508
+ sendJson(res, { result: "started", stream, target });
509
+ return;
510
+ }
511
+
512
+ if (action === "stop" && method === "PUT") {
513
+ state.streams[stream] = { active: false, target: null };
514
+ state.lastStreamAction = { action: "stop", stream };
515
+ sendJson(res, { result: "stopped", stream });
516
+ return;
517
+ }
518
+ }
519
+ }
520
+
521
+ if (url.startsWith("/v1/drives/")) {
522
+ let routeUrl;
523
+ try {
524
+ routeUrl = new URL(url, "http://mock.local");
525
+ } catch {
526
+ routeUrl = null;
527
+ }
528
+ if (routeUrl) {
529
+ const match = /^\/v1\/drives\/([^:]+):(mount|remove|reset|on|off|load_rom|set_mode)$/.exec(routeUrl.pathname);
530
+ if (match) {
531
+ const driveId = decodeURIComponent(match[1]);
532
+ const action = match[2];
533
+ const driveState = ensureDriveState(driveId);
534
+
535
+ const respond = (payload) => {
536
+ sendJson(res, payload);
537
+ };
538
+
539
+ state.lastDriveOperation = {
540
+ action,
541
+ drive: driveId,
542
+ params: Object.fromEntries(routeUrl.searchParams.entries()),
543
+ method,
544
+ };
545
+
546
+ if (action === "mount" && method === "PUT") {
547
+ const image = routeUrl.searchParams.get("image") ?? "";
548
+ const type = routeUrl.searchParams.get("type") ?? null;
549
+ const mode = routeUrl.searchParams.get("mode") ?? null;
550
+ driveState.mountedImage = {
551
+ image,
552
+ type,
553
+ mode,
554
+ };
555
+ respond({ result: "mounted", drive: driveId, image, type, mode });
556
+ return;
557
+ }
558
+
559
+ if (action === "remove" && method === "PUT") {
560
+ driveState.mountedImage = null;
561
+ respond({ result: "removed", drive: driveId });
562
+ return;
563
+ }
564
+
565
+ if (action === "reset" && method === "PUT") {
566
+ driveState.resetCount += 1;
567
+ respond({ result: "reset", drive: driveId, count: driveState.resetCount });
568
+ return;
569
+ }
570
+
571
+ if (action === "on" && method === "PUT") {
572
+ driveState.power = "on";
573
+ respond({ result: "power_on", drive: driveId });
574
+ return;
575
+ }
576
+
577
+ if (action === "off" && method === "PUT") {
578
+ driveState.power = "off";
579
+ respond({ result: "power_off", drive: driveId });
580
+ return;
581
+ }
582
+
583
+ if (action === "load_rom" && (method === "PUT" || method === "POST")) {
584
+ const file = routeUrl.searchParams.get("file") ?? "";
585
+ driveState.lastRom = file;
586
+ respond({ result: "rom_loaded", drive: driveId, file });
587
+ return;
588
+ }
589
+
590
+ if (action === "set_mode" && method === "PUT") {
591
+ const mode = routeUrl.searchParams.get("mode") ?? "1541";
592
+ driveState.mode = mode;
593
+ respond({ result: "mode_set", drive: driveId, mode });
594
+ return;
595
+ }
596
+ }
597
+ }
598
+ }
599
+
600
+ if (url.startsWith("/v1/files/")) {
601
+ let routeUrl;
602
+ try {
603
+ routeUrl = new URL(url, "http://mock.local");
604
+ } catch {
605
+ routeUrl = null;
606
+ }
607
+ if (routeUrl) {
608
+ const match = /^\/v1\/files\/([^:]+):(info|create_d64|create_d71|create_d81|create_dnp)$/.exec(routeUrl.pathname);
609
+ if (match) {
610
+ const encodedPath = match[1];
611
+ const action = match[2];
612
+ const decodedPath = decodeURIComponent(encodedPath);
613
+
614
+ const respond = (payload) => {
615
+ sendJson(res, payload);
616
+ };
617
+
618
+ if (action === "info" && method === "GET") {
619
+ state.lastFileInfo = decodedPath;
620
+ respond({ path: decodedPath, size: 4096, type: "file" });
621
+ return;
622
+ }
623
+
624
+ if (action === "create_d64" && method === "PUT") {
625
+ const tracks = Number.parseInt(routeUrl.searchParams.get("tracks") ?? "35", 10);
626
+ const diskname = routeUrl.searchParams.get("diskname") ?? null;
627
+ state.createdImages.push({ type: "d64", path: decodedPath, tracks, diskname });
628
+ respond({ result: "created", type: "d64", path: decodedPath, tracks, diskname });
629
+ return;
630
+ }
631
+
632
+ if (action === "create_d71" && method === "PUT") {
633
+ const diskname = routeUrl.searchParams.get("diskname") ?? null;
634
+ state.createdImages.push({ type: "d71", path: decodedPath, diskname });
635
+ respond({ result: "created", type: "d71", path: decodedPath, diskname });
636
+ return;
637
+ }
638
+
639
+ if (action === "create_d81" && method === "PUT") {
640
+ const diskname = routeUrl.searchParams.get("diskname") ?? null;
641
+ state.createdImages.push({ type: "d81", path: decodedPath, diskname });
642
+ respond({ result: "created", type: "d81", path: decodedPath, diskname });
643
+ return;
644
+ }
645
+
646
+ if (action === "create_dnp" && method === "PUT") {
647
+ const tracks = Number.parseInt(routeUrl.searchParams.get("tracks") ?? "0", 10);
648
+ const diskname = routeUrl.searchParams.get("diskname") ?? null;
649
+ state.createdImages.push({ type: "dnp", path: decodedPath, tracks, diskname });
650
+ respond({ result: "created", type: "dnp", path: decodedPath, tracks, diskname });
651
+ return;
652
+ }
653
+ }
654
+ }
655
+ }
656
+
657
+ res.statusCode = 404;
658
+ res.end();
659
+ });
660
+
661
+ server.listen(0, "127.0.0.1");
662
+ await once(server, "listening");
663
+ const address = server.address();
664
+ if (!address || typeof address === "string") {
665
+ throw new Error("Unable to determine mock server address");
666
+ }
667
+
668
+ return {
669
+ baseUrl: `http://127.0.0.1:${address.port}`,
670
+ state,
671
+ async close() {
672
+ server.close();
673
+ await once(server, "close");
674
+ },
675
+ reset: resetState,
676
+ };
677
+ }
@@ -17,7 +17,7 @@ async function main() {
17
17
  };
18
18
  const outputFile = resolvePath(outputArg);
19
19
 
20
- const moduleUrl = new URL('../test/mockC64Server.mjs', import.meta.url);
20
+ const moduleUrl = new URL('./mockC64Server.mjs', import.meta.url);
21
21
  const { startMockC64Server } = await import(moduleUrl.href);
22
22
  const server = await startMockC64Server();
23
23
  const baseUrl = server.baseUrl;
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  const here = path.dirname(fileURLToPath(import.meta.url));
9
9
  const root = path.resolve(here, '..');
10
10
 
11
- const { startMockC64Server } = await import(path.join(root, 'test', 'mockC64Server.mjs'));
11
+ const { startMockC64Server } = await import(path.join(root, 'scripts', 'mockC64Server.mjs'));
12
12
 
13
13
  const outPath = process.argv[2];
14
14
  if (!outPath) {