c64bridge 0.7.0 → 0.7.2

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.2",
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.2",
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());
@@ -26,12 +26,84 @@ const run = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim();
26
26
  const nowDate = new Date().toISOString().slice(0, 10);
27
27
  const root = process.cwd();
28
28
 
29
- function getLastTag() {
29
+ function normaliseVersion(value) {
30
+ return value.startsWith('v') ? value.slice(1) : value;
31
+ }
32
+
33
+ function parseSemver(value) {
34
+ const cleaned = normaliseVersion(value);
35
+ const parts = cleaned.split('.').map((n) => Number.parseInt(n, 10));
36
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
37
+ return null;
38
+ }
39
+ return { tag: value, cleaned, parts };
40
+ }
41
+
42
+ function compareParts(a, b) {
43
+ for (let i = 0; i < 3; i += 1) {
44
+ if (a.parts[i] !== b.parts[i]) {
45
+ return a.parts[i] - b.parts[i];
46
+ }
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ function getLastTag(skipVersion = '') {
52
+ let raw;
30
53
  try {
31
- return run('git describe --tags --abbrev=0');
54
+ raw = run('git tag --sort=version:refname');
32
55
  } catch {
33
56
  return '';
34
57
  }
58
+
59
+ const entries = raw
60
+ .split('\n')
61
+ .map((line) => line.trim())
62
+ .filter(Boolean)
63
+ .map((tag) => parseSemver(tag))
64
+ .filter((info) => info !== null);
65
+
66
+ if (entries.length === 0) {
67
+ return '';
68
+ }
69
+
70
+ const grouped = new Map();
71
+ for (const entry of entries) {
72
+ if (!grouped.has(entry.cleaned)) {
73
+ grouped.set(entry.cleaned, []);
74
+ }
75
+ grouped.get(entry.cleaned).push(entry);
76
+ }
77
+
78
+ const ordered = Array.from(grouped.values())
79
+ .map((group) => ({ cleaned: group[0].cleaned, parts: group[0].parts, group }))
80
+ .sort((a, b) => compareParts(a, b));
81
+
82
+ if (ordered.length === 0) {
83
+ return '';
84
+ }
85
+
86
+ if (!skipVersion) {
87
+ const latest = ordered[ordered.length - 1];
88
+ return (latest.group.find((entry) => !entry.tag.startsWith('v')) || latest.group[latest.group.length - 1]).tag;
89
+ }
90
+
91
+ const skipCleaned = normaliseVersion(skipVersion);
92
+ let previous = null;
93
+ for (const entry of ordered) {
94
+ if (entry.cleaned === skipCleaned) {
95
+ break;
96
+ }
97
+ previous = entry;
98
+ }
99
+
100
+ if (previous) {
101
+ return (previous.group.find((entry) => !entry.tag.startsWith('v')) || previous.group[previous.group.length - 1]).tag;
102
+ }
103
+
104
+ // If skip version was not found among tags, fall back to latest tag.
105
+ const fallback = ordered[ordered.length - 1];
106
+ return (fallback.group.find((entry) => !entry.tag.startsWith('v')) || fallback.group[fallback.group.length - 1]).tag;
35
107
  }
36
108
 
37
109
  function getCommits(range) {
@@ -90,7 +162,7 @@ function renderSection(version, date, groups) {
90
162
 
91
163
  async function main() {
92
164
  const version = process.argv[2] || '';
93
- const lastTag = getLastTag();
165
+ const lastTag = getLastTag(version);
94
166
  const range = lastTag ? `${lastTag}..HEAD` : '';
95
167
  const rawCommits = getCommits(range)
96
168
  .filter((c) => c.subject && !/^Merge\s/.test(c.subject));
@@ -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) {