c64bridge 1.0.0 → 1.0.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/dist/device.js CHANGED
@@ -4,13 +4,13 @@
4
4
  import axios from "axios";
5
5
  import { Buffer } from "node:buffer";
6
6
  import fs from "node:fs";
7
- import os from "node:os";
8
7
  import path from "node:path";
9
8
  import { Api } from "../generated/c64/index.js";
10
9
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
11
10
  import { ViceClient } from "./vice/viceClient.js";
12
11
  import { waitForBasicReady } from "./vice/readiness.js";
13
12
  import { startViceProcess } from "./vice/process.js";
13
+ import { loadConfig } from "./config.js";
14
14
  const DEFAULT_C64U_HOST = "c64u";
15
15
  const DEFAULT_C64U_PORT = 80;
16
16
  const DEFAULT_VICE_HOST = "127.0.0.1";
@@ -53,44 +53,12 @@ function coalesceMemoryWriteBlocks(blocks) {
53
53
  return merged;
54
54
  }
55
55
  function readConfigFile() {
56
- const envPath = process.env.C64BRIDGE_CONFIG;
57
- const candidates = [];
58
- if (envPath)
59
- candidates.push(envPath);
60
- // Repo root
61
- try {
62
- const here = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".c64bridge.json");
63
- candidates.push(here);
64
- }
65
- catch { }
66
- const home = process.env.HOME || os.homedir();
67
- if (home)
68
- candidates.push(path.join(home, ".c64bridge.json"));
69
- let foundCandidate = false;
70
- const merged = {};
71
- for (const p of candidates) {
72
- try {
73
- if (fs.existsSync(p)) {
74
- foundCandidate = true;
75
- const text = fs.readFileSync(p, "utf8");
76
- const json = JSON.parse(text);
77
- if (!merged.c64u && json?.c64u && typeof json.c64u === "object" && !Array.isArray(json.c64u)) {
78
- merged.c64u = json.c64u;
79
- }
80
- if (!merged.u2 && json?.u2 && typeof json.u2 === "object" && !Array.isArray(json.u2)) {
81
- merged.u2 = json.u2;
82
- }
83
- if (!merged.vice && json?.vice && typeof json.vice === "object" && !Array.isArray(json.vice)) {
84
- merged.vice = json.vice;
85
- }
86
- if ((merged.c64u || merged.u2) && merged.vice) {
87
- break;
88
- }
89
- }
90
- }
91
- catch { }
92
- }
93
- return foundCandidate ? merged : null;
56
+ const sections = loadConfig().backendConfig;
57
+ return Object.keys(sections).length === 0 ? null : {
58
+ c64u: sections.c64u,
59
+ u2: sections.u2,
60
+ vice: sections.vice,
61
+ };
94
62
  }
95
63
  class C64uBackend {
96
64
  constructor(config, type = "c64u") {
@@ -117,6 +85,14 @@ class C64uBackend {
117
85
  }
118
86
  }
119
87
  getBaseUrl() { return this.baseUrl; }
88
+ actionResult(data) {
89
+ const errors = data && typeof data === "object" && Array.isArray(data.errors)
90
+ ? data.errors.filter((error) => error !== undefined && error !== null && String(error).trim() !== "")
91
+ : [];
92
+ return errors.length > 0
93
+ ? { success: false, details: { response: data, errors: errors.map(String) } }
94
+ : { success: true, details: data };
95
+ }
120
96
  async ping() {
121
97
  try {
122
98
  const res = await axios.get(this.baseUrl, {
@@ -132,30 +108,35 @@ class C64uBackend {
132
108
  async runPrg(prg) {
133
109
  const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
134
110
  const res = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, { headers: { "Content-Type": "application/octet-stream" } });
135
- return { success: true, details: res.data };
111
+ return this.actionResult(res.data);
112
+ }
113
+ async loadPrg(prg) {
114
+ const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
115
+ const res = await this.api.v1.runnersLoadPrgCreate(":load_prg", payload, { headers: { "Content-Type": "application/octet-stream" } });
116
+ return this.actionResult(res.data);
136
117
  }
137
118
  async loadPrgFile(pathStr) {
138
119
  const res = await this.api.v1.runnersLoadPrgUpdate(":load_prg", { file: pathStr });
139
- return { success: true, details: res.data };
120
+ return this.actionResult(res.data);
140
121
  }
141
122
  async runPrgFile(pathStr) {
142
123
  const res = await this.api.v1.runnersRunPrgUpdate(":run_prg", { file: pathStr });
143
- return { success: true, details: res.data };
124
+ return this.actionResult(res.data);
144
125
  }
145
126
  async runCrtFile(pathStr) {
146
127
  const res = await this.api.v1.runnersRunCrtUpdate(":run_crt", { file: pathStr });
147
- return { success: true, details: res.data };
128
+ return this.actionResult(res.data);
148
129
  }
149
130
  async sidplayFile(pathStr, songnr) {
150
131
  const res = await this.api.v1.runnersSidplayUpdate(":sidplay", { file: pathStr, songnr });
151
- return { success: true, details: res.data };
132
+ return this.actionResult(res.data);
152
133
  }
153
134
  async sidplayAttachment(sid, options) {
154
135
  const form = { sid: Buffer.isBuffer(sid) ? sid : Buffer.from(sid) };
155
136
  if (options?.songlengths)
156
137
  form.songlengths = Buffer.isBuffer(options.songlengths) ? options.songlengths : Buffer.from(options.songlengths);
157
138
  const res = await this.api.v1.runnersSidplayCreate(":sidplay", form, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
158
- return { success: true, details: res.data };
139
+ return this.actionResult(res.data);
159
140
  }
160
141
  async readMemory(address, length) {
161
142
  const addrStr = address.toString(16).toUpperCase().padStart(4, "0");
@@ -189,13 +170,13 @@ class C64uBackend {
189
170
  const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
190
171
  await Promise.all(mergedBlocks.map(({ address, bytes }) => this.writeMemory(address, bytes)));
191
172
  }
192
- async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return { success: true, details: res.data }; }
193
- async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return { success: true, details: res.data }; }
194
- async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return { success: true, details: res.data }; }
195
- async resume() { const res = await this.api.v1.machineResumeUpdate(":resume"); return { success: true, details: res.data }; }
196
- async poweroff() { this.requireC64u("poweroff"); const res = await this.api.v1.machinePoweroffUpdate(":poweroff"); return { success: true, details: res.data }; }
173
+ async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return this.actionResult(res.data); }
174
+ async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return this.actionResult(res.data); }
175
+ async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return this.actionResult(res.data); }
176
+ async resume() { const res = await this.api.v1.machineResumeUpdate(":resume"); return this.actionResult(res.data); }
177
+ async poweroff() { this.requireC64u("poweroff"); const res = await this.api.v1.machinePoweroffUpdate(":poweroff"); return this.actionResult(res.data); }
197
178
  async powerCycle() { this.requireC64u("powerCycle"); throw unsupported("powerCycle must be coordinated by C64Client"); }
198
- async menuButton() { const res = await this.api.v1.machineMenuButtonUpdate(":menu_button"); return { success: true, details: res.data }; }
179
+ async menuButton() { const res = await this.api.v1.machineMenuButtonUpdate(":menu_button"); return this.actionResult(res.data); }
199
180
  async readMenuScreen() {
200
181
  const response = await this.api.v1.machineMenuScreenList(":menu_screen", { format: "arraybuffer", headers: { Accept: "application/octet-stream" } });
201
182
  const body = response.data;
@@ -215,29 +196,42 @@ class C64uBackend {
215
196
  async debugregWrite(value) { this.requireC64u("debugregWrite"); const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value }); return { success: true, value: res.data.value, details: res.data }; }
216
197
  async version() { const res = await this.api.v1.versionList(); return res.data; }
217
198
  async info() { const res = await this.api.v1.infoList(); return res.data; }
218
- async drivesList() { const res = await this.api.v1.drivesList(); return res.data; }
219
- async driveMount(d, img, options) { const res = await this.api.v1.drivesMountUpdate(d, ":mount", { image: img, type: options?.type, mode: options?.mode }); return { success: true, details: res.data }; }
220
- async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(d, ":remove"); return { success: true, details: res.data }; }
221
- async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(d, ":reset"); return { success: true, details: res.data }; }
222
- async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(d, ":on"); return { success: true, details: res.data }; }
223
- async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(d, ":off"); return { success: true, details: res.data }; }
224
- async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(d, ":set_mode", { mode }); return { success: true, details: res.data }; }
225
- async driveLoadRom(d, romPath) { const res = await this.api.v1.drivesLoadRomUpdate(d, ":load_rom", { file: romPath }); return { success: true, details: res.data }; }
226
- async streamStart(s, ip) { this.requireC64u("streamStart"); const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return { success: true, details: res.data }; }
227
- async streamStop(s) { this.requireC64u("streamStop"); const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return { success: true, details: res.data }; }
199
+ async drivesList() {
200
+ const res = await this.api.v1.drivesList();
201
+ const raw = res.data;
202
+ const entries = Array.isArray(raw.drives)
203
+ ? raw.drives
204
+ : raw.drives && typeof raw.drives === "object" ? [raw.drives] : [];
205
+ return entries.flatMap((entry) => Object.entries(entry).map(([id, info]) => ({
206
+ id,
207
+ power: info.enabled === true || info.enabled === "on" || info.power === "on" ? "on" : "off",
208
+ image: (info.image_path ?? info.image_file ?? info.image ?? null),
209
+ type: info.type ?? null,
210
+ raw: info,
211
+ })));
212
+ }
213
+ async driveMount(d, img, options) { const res = await this.api.v1.drivesMountUpdate(d, ":mount", { image: img, type: options?.type, mode: options?.mode }); return this.actionResult(res.data); }
214
+ async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(d, ":remove"); return this.actionResult(res.data); }
215
+ async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(d, ":reset"); return this.actionResult(res.data); }
216
+ async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(d, ":on"); return this.actionResult(res.data); }
217
+ async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(d, ":off"); return this.actionResult(res.data); }
218
+ async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(d, ":set_mode", { mode }); return this.actionResult(res.data); }
219
+ async driveLoadRom(d, romPath) { const res = await this.api.v1.drivesLoadRomUpdate(d, ":load_rom", { file: romPath }); return this.actionResult(res.data); }
220
+ async streamStart(s, ip) { this.requireC64u("streamStart"); const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return this.actionResult(res.data); }
221
+ async streamStop(s) { this.requireC64u("streamStop"); const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return this.actionResult(res.data); }
228
222
  async configsList() { const res = await this.api.v1.configsList(); return res.data; }
229
223
  async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(cat, item) : await this.api.v1.configsDetail(cat); return res.data; }
230
- async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return { success: true, details: res.data }; }
231
- async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return { success: true, details: res.data }; }
232
- async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return { success: true, details: res.data }; }
233
- async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return { success: true, details: res.data }; }
234
- async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return { success: true, details: res.data }; }
235
- async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeURIComponent(p), ":info"); return res.data; }
236
- async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeURIComponent(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return { success: true, details: res.data }; }
237
- async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeURIComponent(p), ":create_d71", { diskname: options?.diskname }); return { success: true, details: res.data }; }
238
- async filesCreateD81(p, options) { const res = await this.api.v1.filesCreateD81Update(encodeURIComponent(p), ":create_d81", { diskname: options?.diskname }); return { success: true, details: res.data }; }
239
- async filesCreateDnp(p, tracks, options) { const res = await this.api.v1.filesCreateDnpUpdate(encodeURIComponent(p), ":create_dnp", { tracks, diskname: options?.diskname }); return { success: true, details: res.data }; }
240
- async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return { success: true, details: res.data }; }
224
+ async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return this.actionResult(res.data); }
225
+ async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
226
+ async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
227
+ async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
228
+ async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return this.actionResult(res.data); }
229
+ async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeDevicePath(p), ":info"); return res.data; }
230
+ async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeDevicePath(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
231
+ async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeDevicePath(p), ":create_d71", { diskname: options?.diskname }); return this.actionResult(res.data); }
232
+ async filesCreateD81(p, options) { const res = await this.api.v1.filesCreateD81Update(encodeDevicePath(p), ":create_d81", { diskname: options?.diskname }); return this.actionResult(res.data); }
233
+ async filesCreateDnp(p, tracks, options) { const res = await this.api.v1.filesCreateDnpUpdate(encodeDevicePath(p), ":create_dnp", { tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
234
+ async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return this.actionResult(res.data); }
241
235
  }
242
236
  export class ViceBackend {
243
237
  constructor(config) {
@@ -402,13 +396,19 @@ export class ViceBackend {
402
396
  return;
403
397
  }
404
398
  ViceBackend.cleanupRegistered = true;
405
- /* c8 ignore next 5 -- global process exit hooks are not practical to exercise in the unit test process */
406
- process.once("exit", () => {
399
+ const cleanupSync = () => {
407
400
  for (const [, handle] of ViceBackend.supervisors) {
408
- handle.stop().catch(() => { });
401
+ handle.stopSync();
409
402
  }
410
403
  ViceBackend.supervisors.clear();
411
- });
404
+ };
405
+ process.once("exit", cleanupSync);
406
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
407
+ process.once(signal, () => {
408
+ cleanupSync();
409
+ process.exit(signal === "SIGINT" ? 130 : 143);
410
+ });
411
+ }
412
412
  }
413
413
  async withClient(fn, options) {
414
414
  const previous = this.monitorQueue;
@@ -472,7 +472,15 @@ export class ViceBackend {
472
472
  }
473
473
  return false;
474
474
  }
475
- async injectPrg(buffer) {
475
+ /**
476
+ * Reset, wait for a usable BASIC prompt, and load PRG bytes into memory.
477
+ * `finish` decides what happens next: a genuine BASIC PRG needs its
478
+ * pointers set and RUN typed, but machine code must never receive either
479
+ * — RUN would misinterpret raw opcodes as tokenized BASIC text, and
480
+ * rewriting TXTTAB/VARTAB/ARYTAB/STREND corrupts the BASIC workspace for
481
+ * an origin that isn't BASIC program text at all.
482
+ */
483
+ async loadPrgAndFinish(buffer, finish) {
476
484
  if (buffer.length < 2)
477
485
  throw new Error("PRG data too short");
478
486
  const loadAddress = buffer.readUInt16LE(0);
@@ -482,7 +490,12 @@ export class ViceBackend {
482
490
  await waitForBasicReady(client, { timeoutMs: 10000, ensurePrompt: true });
483
491
  if (body.length > 0)
484
492
  await client.memSet(loadAddress, body);
485
- const programEnd = loadAddress + body.length;
493
+ await finish(client, loadAddress, loadAddress + body.length);
494
+ await client.exitMonitor();
495
+ });
496
+ }
497
+ async injectPrg(buffer) {
498
+ await this.loadPrgAndFinish(buffer, async (client, loadAddress, programEnd) => {
486
499
  const ptrs = Buffer.alloc(8);
487
500
  ptrs.writeUInt16LE(loadAddress, 0);
488
501
  ptrs.writeUInt16LE(programEnd, 2);
@@ -490,7 +503,6 @@ export class ViceBackend {
490
503
  ptrs.writeUInt16LE(programEnd, 6);
491
504
  await client.memSet(0x002B, ptrs);
492
505
  await client.keyboardFeed("RUN\r");
493
- await client.exitMonitor();
494
506
  });
495
507
  }
496
508
  async runPrg(prg) {
@@ -498,6 +510,13 @@ export class ViceBackend {
498
510
  await this.injectPrg(buffer);
499
511
  return { success: true };
500
512
  }
513
+ /** Load PRG bytes without starting them — the caller decides how to enter
514
+ * the code (e.g. a subsequent typed `SYS <entry>` for machine code). */
515
+ async loadPrg(prg) {
516
+ const buffer = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
517
+ await this.loadPrgAndFinish(buffer, async () => { });
518
+ return { success: true };
519
+ }
501
520
  async loadPrgFile(_path) { throw unsupported("loadPrgFile"); }
502
521
  async runPrgFile(prgPath) {
503
522
  const data = fs.readFileSync(prgPath);
@@ -540,22 +559,29 @@ export class ViceBackend {
540
559
  });
541
560
  }
542
561
  async reset() {
562
+ let readiness;
563
+ // Overridable only for deterministic tests of the readiness-failure path;
564
+ // production always uses the 20s default.
565
+ const resetReadinessTimeoutMs = Number(process.env.C64BRIDGE_VICE_RESET_TIMEOUT_MS) || 20000;
543
566
  await this.withClient(async (client) => {
544
567
  await client.reset();
545
568
  const opts = this.debugEnabled
546
569
  ? {
547
- timeoutMs: 20000,
570
+ timeoutMs: resetReadinessTimeoutMs,
548
571
  ensurePrompt: true,
549
572
  onPointersSample: (p) => {
550
573
  console.error("[vice-backend] BASIC pointers", p);
551
574
  },
552
575
  }
553
- : { timeoutMs: 20000, ensurePrompt: true };
554
- const readiness = await waitForBasicReady(client, opts);
576
+ : { timeoutMs: resetReadinessTimeoutMs, ensurePrompt: true };
577
+ readiness = await waitForBasicReady(client, opts);
555
578
  if (this.debugEnabled)
556
579
  console.error("[vice-backend] waitForBasicReady result", readiness);
557
580
  });
558
- return { success: true };
581
+ const ready = Boolean(readiness?.pointersOk && readiness?.promptOk);
582
+ return ready
583
+ ? { success: true, details: readiness }
584
+ : { success: false, details: { readiness, message: "VICE reset completed but BASIC READY was not observed. Reset again or inspect the emulator monitor." } };
559
585
  }
560
586
  async reboot() { return this.reset(); }
561
587
  async pause() {
@@ -742,9 +768,12 @@ export class ViceBackend {
742
768
  });
743
769
  }
744
770
  async configSet(_category, item, value) {
745
- const numValue = Number(value);
746
- const parsed = !isNaN(numValue) && value.trim() !== "" ? numValue : value;
771
+ let parsed = value;
747
772
  await this.withClient(async (client) => {
773
+ const current = await client.resourceGet(item);
774
+ parsed = current.type === "int" ? Number(value) : value;
775
+ if (current.type === "int" && !Number.isFinite(parsed))
776
+ throw new Error(`VICE resource '${item}' requires an integer value`);
748
777
  await client.resourceSet(item, parsed);
749
778
  });
750
779
  return { success: true, details: { item, value: parsed } };
@@ -756,8 +785,10 @@ export class ViceBackend {
756
785
  for (const [item, value] of Object.entries(items)) {
757
786
  try {
758
787
  const str = String(value ?? "");
759
- const numValue = Number(str);
760
- const parsed = !isNaN(numValue) && str.trim() !== "" ? numValue : str;
788
+ const current = await client.resourceGet(item);
789
+ const parsed = current.type === "int" ? Number(str) : str;
790
+ if (current.type === "int" && !Number.isFinite(parsed))
791
+ throw new Error(`VICE resource '${item}' requires an integer value`);
761
792
  await client.resourceSet(item, parsed);
762
793
  results.push({ item: `${category}/${item}`, success: true });
763
794
  }
@@ -784,6 +815,11 @@ export class ViceBackend {
784
815
  ViceBackend.supervisors = new Map();
785
816
  ViceBackend.cleanupRegistered = false;
786
817
  function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
818
+ /** Ultimate file APIs treat slash as a path separator. Encode each name once,
819
+ * preserving leading and nested separators for firmware routing. */
820
+ function encodeDevicePath(value) {
821
+ return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
822
+ }
787
823
  function parseDriveNumber(drive) {
788
824
  const match = /\d+/.exec(drive);
789
825
  const n = match ? parseInt(match[0], 10) : NaN;
package/dist/knowledge.js CHANGED
@@ -82,7 +82,7 @@ Key SID Registers (decimal addresses):
82
82
  **PAL vs NTSC Clock Differences:**
83
83
  - PAL: 985,248 Hz SID clock
84
84
  - NTSC: 1,022,727 Hz SID clock
85
- - Formula: freq_value = (frequency_Hz × 65536) / clock_rate
85
+ - Formula: freq_value = round((frequency_Hz × 16777216) / clock_rate) (SID has a 24-bit phase accumulator)
86
86
 
87
87
  **Complete SID Frequency Lookup Table (All Notes, Multiple Octaves):**
88
88
 
@@ -1,5 +1,17 @@
1
1
  import { HttpClient } from "../generated/c64/index.js";
2
2
  import { formatErrorMessage, formatPayloadForDebug, loggerFor, payloadByteLength } from "./logger.js";
3
+ /** Headers are useful in diagnostics, but credentials must never leave the
4
+ * transport layer. Axios normalises some keys and preserves others, so do a
5
+ * case-insensitive pass instead of relying on one spelling. */
6
+ function redactHeaders(headers) {
7
+ if (!headers)
8
+ return headers;
9
+ const sensitive = /^(x-password|authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
10
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [
11
+ key,
12
+ sensitive.test(key) ? "***" : value,
13
+ ]));
14
+ }
3
15
  export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
4
16
  const http = new HttpClient(config);
5
17
  http.instance.interceptors.request.use((request) => {
@@ -9,7 +21,7 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
9
21
  startedAt: Date.now(),
10
22
  method,
11
23
  path,
12
- headers: request.headers ? { ...request.headers } : undefined,
24
+ headers: redactHeaders(request.headers),
13
25
  params: request.params ? { ...request.params } : undefined,
14
26
  body: request.data,
15
27
  };
@@ -40,7 +52,7 @@ function handleResponse(response, logger) {
40
52
  });
41
53
  logger.debug(`response ${method} ${path}`, {
42
54
  status: response.status,
43
- headers: response.headers ?? {},
55
+ headers: redactHeaders(response.headers),
44
56
  body: formatPayloadForDebug(response.data),
45
57
  });
46
58
  }
@@ -68,7 +80,7 @@ function handleError(error, logger) {
68
80
  });
69
81
  logger.debug(`error ${method} ${path}`, {
70
82
  status,
71
- headers: response?.headers ?? {},
83
+ headers: redactHeaders(response?.headers),
72
84
  body: response ? formatPayloadForDebug(response.data) : null,
73
85
  message,
74
86
  });
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import "./bootstrap/stdio-logger.js";
3
+ import { createServer } from "node:http";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
9
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
10
  import { loadConfig } from "./config.js";
9
11
  import { C64Client } from "./c64Client.js";
@@ -32,7 +34,7 @@ function createPromptRegistryGetter() {
32
34
  return registry;
33
35
  };
34
36
  }
35
- function parseCliOptions(argv) {
37
+ export function parseCliOptions(argv) {
36
38
  const httpIndex = argv.indexOf("--http");
37
39
  if (httpIndex === -1) {
38
40
  return { mode: "stdio" };
@@ -420,12 +422,34 @@ async function main() {
420
422
  throw error;
421
423
  }
422
424
  });
423
- // Connect via stdio
424
- const transport = new StdioServerTransport();
425
- await server.connect(transport);
426
- writeDiagnosticEvent("mcp_transport_connected", { mode: "stdio" });
425
+ const cli = parseCliOptions(process.argv.slice(2));
426
+ if (cli.mode === "http") {
427
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
428
+ await server.connect(transport);
429
+ const port = cli.port ?? 3000;
430
+ const httpServer = createServer((request, response) => {
431
+ if (request.url?.split("?")[0] !== "/mcp") {
432
+ response.statusCode = 404;
433
+ response.end("Not found");
434
+ return;
435
+ }
436
+ void transport.handleRequest(request, response).catch((error) => {
437
+ if (!response.headersSent)
438
+ response.statusCode = 500;
439
+ response.end(error instanceof Error ? error.message : "MCP transport error");
440
+ });
441
+ });
442
+ await new Promise((resolve, reject) => httpServer.listen(port, "127.0.0.1", resolve).once("error", reject));
443
+ writeDiagnosticEvent("mcp_transport_connected", { mode: "http", port });
444
+ console.error(`c64bridge MCP server running on HTTP at http://127.0.0.1:${port}/mcp`);
445
+ }
446
+ else {
447
+ const transport = new StdioServerTransport();
448
+ await server.connect(transport);
449
+ writeDiagnosticEvent("mcp_transport_connected", { mode: "stdio" });
450
+ console.error("c64bridge MCP server running on stdio");
451
+ }
427
452
  await logConnectivity(client, baseUrl);
428
- console.error("c64bridge MCP server running on stdio");
429
453
  writeDiagnosticEvent("server_ready", {
430
454
  diagnosticsFile: diagnostics.filePath,
431
455
  });
@@ -447,11 +471,18 @@ function createPlatformResourceDescriptor() {
447
471
  },
448
472
  };
449
473
  }
450
- async function renderPlatformStatusMarkdown(client) {
474
+ export async function renderPlatformStatusMarkdown(client) {
451
475
  const status = getPlatformStatus();
452
476
  const availableBackends = client.getAvailableBackends();
453
477
  const capabilities = describePlatformCapabilities(toolRegistry.list());
454
- const nativeEndpoints = await client.getNativeEndpointCapabilities();
478
+ // Resource reads must remain responsive while an Ultimate boots or is
479
+ // unreachable. The probe itself remains retryable (unknown is not cached).
480
+ const nativeEndpoints = await Promise.race([
481
+ client.getNativeEndpointCapabilities(),
482
+ new Promise((resolve) => {
483
+ setTimeout(() => resolve({ machineInput: "unknown", machineMenuScreen: "unknown" }), 750).unref();
484
+ }),
485
+ ]);
455
486
  const lines = [
456
487
  "# MCP Platform Status",
457
488
  "",
@@ -590,11 +621,21 @@ function toPromptMessage(segment) {
590
621
  },
591
622
  };
592
623
  }
593
- main().catch((error) => {
594
- writeDiagnosticEvent("server_fatal", {
595
- diagnosticsFile: getDiagnosticsSessionInfo()?.filePath,
596
- error,
624
+ // The documented entrypoint (src/index.ts / dist/index.js, per package.json
625
+ // "main"/"bin") always imports this module as a side effect rather than
626
+ // invoking it as the process's own script, so process.argv[1] never points
627
+ // here — an entrypoint-identity check would wrongly skip startup in every
628
+ // real deployment. Test code that only needs a pure export (e.g.
629
+ // parseCliOptions, renderPlatformStatusMarkdown) sets this opt-out instead,
630
+ // mirroring the existing C64BRIDGE_START_SKIP_AUTO_LAUNCH convention in
631
+ // scripts/start.mjs.
632
+ if (process.env.C64BRIDGE_SKIP_AUTO_START !== "1") {
633
+ main().catch((error) => {
634
+ writeDiagnosticEvent("server_fatal", {
635
+ diagnosticsFile: getDiagnosticsSessionInfo()?.filePath,
636
+ error,
637
+ });
638
+ console.error("Fatal error in MCP server:", error);
639
+ process.exit(1);
597
640
  });
598
- console.error("Fatal error in MCP server:", error);
599
- process.exit(1);
600
- });
641
+ }
@@ -4,16 +4,19 @@ GPL-2.0-only
4
4
  */
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import fsSync from "node:fs";
8
- const BASIC_DIR = path.resolve("data/basic/examples");
9
- const ASM_DIR = path.resolve("data/assembly/examples");
10
- const EXTERNAL_DIR = path.resolve("external");
11
- const DOC_ROOT = path.resolve("doc");
9
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const ASSET_ROOT = process.env.C64BRIDGE_ASSET_ROOT ? path.resolve(process.env.C64BRIDGE_ASSET_ROOT) : PACKAGE_ROOT;
11
+ const BASIC_DIR = path.join(ASSET_ROOT, "data/basic/examples");
12
+ const ASM_DIR = path.join(ASSET_ROOT, "data/assembly/examples");
13
+ const EXTERNAL_DIR = path.join(ASSET_ROOT, "external");
14
+ const DOC_ROOT = path.join(ASSET_ROOT, "doc");
12
15
  const DOC_EXCLUDE_DIRS = new Set(["plans"]);
13
- const CONTEXT_DIR = path.resolve("data/context");
16
+ const CONTEXT_DIR = path.join(ASSET_ROOT, "data/context");
14
17
  const BOOTSTRAP_PATH = path.join(CONTEXT_DIR, "bootstrap.md");
15
- const AGENTS_PATH = path.resolve("AGENTS.md");
16
- const PROMPTS_DIR = path.resolve(".github/prompts");
18
+ const AGENTS_PATH = path.join(ASSET_ROOT, "AGENTS.md");
19
+ const PROMPTS_DIR = path.join(ASSET_ROOT, ".github/prompts");
17
20
  const CHAT_PATH = path.join(CONTEXT_DIR, "chat.md");
18
21
  // RAG_DOC_FILES env var can add extra specific files, comma-separated
19
22
  const ENV_DOC_FILES = (process.env.RAG_DOC_FILES ?? "")
@@ -34,7 +37,7 @@ function shouldExcludeDocFile(file) {
34
37
  return firstSegment !== undefined && DOC_EXCLUDE_DIRS.has(firstSegment);
35
38
  }
36
39
  function resolveEmbeddingsDir(override) {
37
- return path.resolve(override ?? process.env.RAG_EMBEDDINGS_DIR ?? "data");
40
+ return path.resolve(override ?? process.env.RAG_EMBEDDINGS_DIR ?? path.join(ASSET_ROOT, "data"));
38
41
  }
39
42
  function embeddingIndexPaths(dir) {
40
43
  return {
package/dist/rag/init.js CHANGED
@@ -4,22 +4,25 @@ GPL-2.0-only
4
4
  */
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { setTimeout as delay } from "node:timers/promises";
8
9
  import { LocalMiniHashEmbedding } from "./embeddings.js";
9
10
  import { buildAllIndexes, loadIndexes } from "./indexer.js";
10
11
  import { LocalRagRetriever } from "./retriever.js";
11
12
  import { LoggingRagRetriever } from "./loggingRetriever.js";
12
- const EXTERNAL_DIR = path.resolve("external");
13
- const BASIC_DATA_DIR = path.resolve("data/basic/examples");
14
- const ASM_DATA_DIR = path.resolve("data/assembly/examples");
15
- const DOC_DIR = path.resolve("doc");
16
- const CONTEXT_DIR = path.resolve("data/context");
13
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
14
+ const assetRoot = process.env.C64BRIDGE_ASSET_ROOT ? path.resolve(process.env.C64BRIDGE_ASSET_ROOT) : PACKAGE_ROOT;
15
+ const EXTERNAL_DIR = path.join(assetRoot, "external");
16
+ const BASIC_DATA_DIR = path.join(assetRoot, "data/basic/examples");
17
+ const ASM_DATA_DIR = path.join(assetRoot, "data/assembly/examples");
18
+ const DOC_DIR = path.join(assetRoot, "doc");
19
+ const CONTEXT_DIR = path.join(assetRoot, "data/context");
17
20
  const BOOTSTRAP_PATH = path.join(CONTEXT_DIR, "bootstrap.md");
18
- const AGENTS_PATH = path.resolve("AGENTS.md");
19
- const PROMPTS_DIR = path.resolve(".github/prompts");
21
+ const AGENTS_PATH = path.join(assetRoot, "AGENTS.md");
22
+ const PROMPTS_DIR = path.join(assetRoot, ".github/prompts");
20
23
  const CHAT_PATH = path.join(CONTEXT_DIR, "chat.md");
21
24
  function resolveEmbeddingsDir() {
22
- return path.resolve(process.env.RAG_EMBEDDINGS_DIR ?? "data");
25
+ return path.resolve(process.env.RAG_EMBEDDINGS_DIR ?? path.join(assetRoot, "data"));
23
26
  }
24
27
  function embeddingIndexPaths() {
25
28
  const dir = resolveEmbeddingsDir();
@@ -169,9 +169,9 @@ export function compileSidwaveToSid(doc, prg, options) {
169
169
  const body = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
170
170
  return { sid: Buffer.concat([header, body]) };
171
171
  }
172
- function hzToSidFrequency(hz, system) {
172
+ export function hzToSidFrequency(hz, system) {
173
173
  const phi2 = system === "PAL" ? 985248 : 1022727;
174
- const value = Math.round((hz * 65536) / phi2);
174
+ const value = Math.round((hz * 16777216) / phi2);
175
175
  return Math.max(0, Math.min(0xffff, value));
176
176
  }
177
177
  function waveformToCtrl(w) {