lasal-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/core/envelope.js +8 -0
  4. package/dist/core/errors.js +12 -0
  5. package/dist/core/http.js +19 -0
  6. package/dist/core/process.js +30 -0
  7. package/dist/core/response.js +37 -0
  8. package/dist/core/scratch.js +6 -0
  9. package/dist/core/staticServer.js +88 -0
  10. package/dist/server.js +230 -0
  11. package/dist/state.js +57 -0
  12. package/dist/tools/applyProjectChanges.js +371 -0
  13. package/dist/tools/deployAll.js +320 -0
  14. package/dist/tools/hmiBrowser.js +224 -0
  15. package/dist/tools/hmiRuntime.js +273 -0
  16. package/dist/tools/inspectProject.js +162 -0
  17. package/dist/tools/inspectVisuProject.js +474 -0
  18. package/dist/tools/larsRuntime.js +536 -0
  19. package/dist/tools/lasalApps.js +111 -0
  20. package/dist/tools/plcControl.js +530 -0
  21. package/dist/tools/plcDiagnostics.js +172 -0
  22. package/dist/tools/readClassSource.js +147 -0
  23. package/dist/tools/selectProject.js +47 -0
  24. package/dist/tools/setTargetIp.js +114 -0
  25. package/dist/tools/status.js +146 -0
  26. package/dist/tools/visuControl.js +447 -0
  27. package/dist/tools/visuDashboard.js +571 -0
  28. package/dist/utils/batchScript.js +257 -0
  29. package/dist/utils/config.js +34 -0
  30. package/dist/utils/editTransaction.js +39 -0
  31. package/dist/utils/engine.js +163 -0
  32. package/dist/utils/lars.js +471 -0
  33. package/dist/utils/lasalXml.js +758 -0
  34. package/dist/utils/preflight.js +194 -0
  35. package/dist/utils/projectScanner.js +212 -0
  36. package/dist/utils/resolvePaths.js +46 -0
  37. package/dist/utils/respond.js +14 -0
  38. package/dist/utils/scriptRunner.js +161 -0
  39. package/dist/utils/visuDashboardIO.js +198 -0
  40. package/dist/utils/visuPropertyEncoding.js +174 -0
  41. package/dist/utils/visuScript.js +262 -0
  42. package/package.json +64 -0
@@ -0,0 +1,471 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
2
+ import { spawn, execSync } from "child_process";
3
+ import { join, basename, dirname } from "path";
4
+ import { homedir } from "os";
5
+ import { XMLParser } from "fast-xml-parser";
6
+ import { pingHost } from "./preflight.js";
7
+ import { updateLssConnection, readLssConnection } from "./projectScanner.js";
8
+ // ─── Executable paths ─────────────────────────────────────────────────────────
9
+ export const LARS_EXE = process.env.LASAL_LARS_EXE || "C:\\Program Files (x86)\\Sigmatek\\Lars\\Lars.exe";
10
+ export const LARS_CONFIG_EXE = process.env.LASAL_LARS_CONFIG_EXE || "C:\\Program Files (x86)\\Sigmatek\\Lars\\LARSConfigTool.exe";
11
+ export function larsConfigPath() {
12
+ return process.env.LASAL_LARS_CONFIG || join(process.env.APPDATA || homedir(), "lasalos2.xml");
13
+ }
14
+ export const DEFAULT_ONLINE_PORT = 1954;
15
+ export const DEFAULT_COMLINK_BASE = 1000;
16
+ const PORT_STEP = 10;
17
+ function num(v, fallback) {
18
+ const n = parseInt(String(v ?? ""), 10);
19
+ return isNaN(n) ? fallback : n;
20
+ }
21
+ function str(v, fallback) {
22
+ return typeof v === "string" && v.length > 0 ? v : fallback;
23
+ }
24
+ function asArray(v) {
25
+ if (v === undefined)
26
+ return [];
27
+ return Array.isArray(v) ? v : [v];
28
+ }
29
+ function escapeXml(s) {
30
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
31
+ }
32
+ export function readLarsWorkspaces() {
33
+ const path = larsConfigPath();
34
+ if (!existsSync(path))
35
+ return [];
36
+ try {
37
+ const raw = readFileSync(path, "utf-8");
38
+ const parser = new XMLParser({ ignoreAttributes: false, parseAttributeValue: false });
39
+ const doc = parser.parse(raw);
40
+ const root = doc.LARSCONFIGURATIONS;
41
+ if (!root)
42
+ return [];
43
+ const workspaces = [];
44
+ for (const ws of asArray(root.WORKSPACE)) {
45
+ if (!ws || typeof ws !== "object")
46
+ continue;
47
+ const name = str(ws["@_Name"], "");
48
+ if (!name)
49
+ continue;
50
+ const memory = (ws.MEMORY ?? {});
51
+ const pathElem = (ws.PATH ?? {});
52
+ const com = (ws.COMTCP ?? {});
53
+ workspaces.push({
54
+ name,
55
+ onlinePort: num(com.ONLINE, DEFAULT_ONLINE_PORT),
56
+ comlinkServerPort: num(com.COMLINK_SRVR, DEFAULT_ONLINE_PORT + 1),
57
+ comlinkBasePort: num(com.COMLINK, DEFAULT_COMLINK_BASE),
58
+ alarmPort: num(com.ALARM, DEFAULT_ONLINE_PORT + 3),
59
+ activeData: str(pathElem.ACTIVEDAT, "C:\\"),
60
+ autoexec: str(pathElem.AUTOEXEC, "C:\\Autoexec.lsl"),
61
+ lslWork: str(pathElem.LSLWORK, "C:\\LSLWORK"),
62
+ sramData: str(pathElem.SRAMDAT, "C:\\"),
63
+ classProjectPath: typeof pathElem.CLASS_PRJ_PATH === "string" ? pathElem.CLASS_PRJ_PATH : undefined,
64
+ screenProjectPath: typeof pathElem.SCREEN_PRJ_PATH === "string" ? pathElem.SCREEN_PRJ_PATH : undefined,
65
+ dataLenMb: num(memory.DATALEN, 40),
66
+ codeLenMb: num(memory.CODELEN, 8),
67
+ });
68
+ }
69
+ return workspaces;
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ }
75
+ export function writeLarsWorkspaces(workspaces) {
76
+ const path = larsConfigPath();
77
+ const dir = dirname(path);
78
+ if (dir && !existsSync(dir))
79
+ mkdirSync(dir, { recursive: true });
80
+ const blocks = workspaces.map((ws) => {
81
+ const mem = ws.dataLenMb || ws.codeLenMb
82
+ ? `\t\t<MEMORY>\n\t\t\t<DATALEN Unit="MiB">${ws.dataLenMb}</DATALEN>\n\t\t\t<CODELEN Unit="MiB">${ws.codeLenMb}</CODELEN>\n\t\t</MEMORY>`
83
+ : '\t\t<MEMORY>\n\t\t\t<DATALEN Unit="MiB">40</DATALEN>\n\t\t\t<CODELEN Unit="MiB">8</CODELEN>\n\t\t</MEMORY>';
84
+ const paths = [
85
+ `\t\t\t<ACTIVEDAT>${escapeXml(ws.activeData)}</ACTIVEDAT>`,
86
+ `\t\t\t<AUTOEXEC>${escapeXml(ws.autoexec)}</AUTOEXEC>`,
87
+ `\t\t\t<LSLWORK>${escapeXml(ws.lslWork)}</LSLWORK>`,
88
+ `\t\t\t<SRAMDAT>${escapeXml(ws.sramData)}</SRAMDAT>`,
89
+ ws.classProjectPath ? `\t\t\t<CLASS_PRJ_PATH>${escapeXml(ws.classProjectPath)}</CLASS_PRJ_PATH>` : "",
90
+ ws.screenProjectPath ? `\t\t\t<SCREEN_PRJ_PATH>${escapeXml(ws.screenProjectPath)}</SCREEN_PRJ_PATH>` : "",
91
+ ]
92
+ .filter(Boolean)
93
+ .join("\n");
94
+ const com = [
95
+ `\t\t\t<ONLINE>${ws.onlinePort}</ONLINE>`,
96
+ `\t\t\t<COMLINK_SRVR>${ws.comlinkServerPort}</COMLINK_SRVR>`,
97
+ `\t\t\t<COMLINK>${ws.comlinkBasePort}</COMLINK>`,
98
+ `\t\t\t<ALARM>${ws.alarmPort}</ALARM>`,
99
+ ].join("\n");
100
+ return [
101
+ `\t<WORKSPACE Name="${escapeXml(ws.name)}">`,
102
+ mem,
103
+ `\t\t<PATH>`,
104
+ paths,
105
+ `\t\t</PATH>`,
106
+ `\t\t<COMTCP>`,
107
+ com,
108
+ `\t\t</COMTCP>`,
109
+ `\t\t<RESOLUTION />`,
110
+ `\t\t<DRIVEMAP>`,
111
+ `\t\t\t<DRIVEMAP_ELEMENT>`,
112
+ `\t\t\t\t<DRIVE>C:</DRIVE>`,
113
+ `\t\t\t\t<PATH>${escapeXml(ws.activeData)}</PATH>`,
114
+ `\t\t\t</DRIVEMAP_ELEMENT>`,
115
+ `\t\t</DRIVEMAP>`,
116
+ `\t\t<IP_MAP />`,
117
+ `\t</WORKSPACE>`,
118
+ ].join("\n");
119
+ });
120
+ const content = `<?xml version="1.0" encoding="UTF-8"?>\n` +
121
+ `<LARSCONFIGURATIONS ConfigVersion="2">\n` +
122
+ blocks.join("\n") +
123
+ `\n</LARSCONFIGURATIONS>\n`;
124
+ if (existsSync(path)) {
125
+ const bak = `${path}.bak`;
126
+ if (!existsSync(bak)) {
127
+ try {
128
+ writeFileSync(bak, readFileSync(path, "utf-8"), "utf-8");
129
+ }
130
+ catch { }
131
+ }
132
+ }
133
+ writeFileSync(path, content, "utf-8");
134
+ }
135
+ /** Allocate a unique set of ports for a new workspace (each LARS instance needs distinct ports). */
136
+ export function allocateLarsPorts(existing, baseOnline = DEFAULT_ONLINE_PORT) {
137
+ const usedOnline = new Set(existing.map((w) => w.onlinePort));
138
+ const usedComlink = new Set(existing.map((w) => w.comlinkBasePort));
139
+ let onlinePort = baseOnline;
140
+ while (usedOnline.has(onlinePort))
141
+ onlinePort += PORT_STEP;
142
+ let comlinkBasePort = DEFAULT_COMLINK_BASE;
143
+ while (usedComlink.has(comlinkBasePort))
144
+ comlinkBasePort += PORT_STEP;
145
+ return {
146
+ onlinePort,
147
+ comlinkServerPort: onlinePort + 1,
148
+ comlinkBasePort,
149
+ alarmPort: onlinePort + 3,
150
+ };
151
+ }
152
+ export function upsertLarsWorkspace(name, partial) {
153
+ const workspaces = readLarsWorkspaces();
154
+ let workspace = workspaces.find((w) => w.name === name);
155
+ if (workspace) {
156
+ Object.assign(workspace, partial);
157
+ }
158
+ else {
159
+ const ports = allocateLarsPorts(workspaces);
160
+ workspace = {
161
+ name,
162
+ onlinePort: ports.onlinePort,
163
+ comlinkServerPort: ports.comlinkServerPort,
164
+ comlinkBasePort: ports.comlinkBasePort,
165
+ alarmPort: ports.alarmPort,
166
+ activeData: "C:\\",
167
+ autoexec: "C:\\Autoexec.lsl",
168
+ lslWork: "C:\\LSLWORK",
169
+ sramData: "C:\\",
170
+ dataLenMb: 40,
171
+ codeLenMb: 8,
172
+ ...partial,
173
+ };
174
+ workspaces.push(workspace);
175
+ }
176
+ writeLarsWorkspaces(workspaces);
177
+ return { workspaces, workspace };
178
+ }
179
+ export function removeLarsWorkspace(name) {
180
+ const workspaces = readLarsWorkspaces().filter((w) => w.name !== name);
181
+ writeLarsWorkspaces(workspaces);
182
+ return workspaces;
183
+ }
184
+ function stationTargetsLars(value, onlinePort) {
185
+ if (!value)
186
+ return false;
187
+ const stripped = value.replace(/^TCPIP:/i, "");
188
+ return stripped === `127.0.0.1:${onlinePort}` || stripped.startsWith(`127.0.0.1:${onlinePort}:`);
189
+ }
190
+ function findStationsJsonFiles(dirs) {
191
+ const out = [];
192
+ for (const dir of dirs) {
193
+ if (!dir || !existsSync(dir))
194
+ continue;
195
+ const walk = (d, depth) => {
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(d, { withFileTypes: true });
199
+ }
200
+ catch {
201
+ return;
202
+ }
203
+ for (const e of entries) {
204
+ const p = join(d, e.name);
205
+ if (e.isDirectory() && depth > 0)
206
+ walk(p, depth - 1);
207
+ else if (e.isFile() && e.name === "stations.json")
208
+ out.push(p);
209
+ }
210
+ };
211
+ walk(dir, 3);
212
+ }
213
+ return out;
214
+ }
215
+ function stationsJsonPointsAt(file, stationName, stationId, onlinePort) {
216
+ try {
217
+ const doc = JSON.parse(readFileSync(file, "utf-8"));
218
+ if (!Array.isArray(doc?.stations))
219
+ return false;
220
+ for (const st of doc.stations) {
221
+ if (!st || typeof st !== "object")
222
+ continue;
223
+ const stRec = st;
224
+ const nameOk = stationName !== undefined && stRec.name === stationName;
225
+ const num = typeof stRec.station === "number"
226
+ ? stRec.station
227
+ : typeof stRec.station === "string"
228
+ ? parseInt(stRec.station, 10)
229
+ : NaN;
230
+ const idOk = stationId !== undefined && stRec.station !== undefined && !isNaN(num) && num === stationId;
231
+ if (!nameOk && !idOk)
232
+ continue;
233
+ // Published layout: { ip: "127.0.0.1", port: <onlinePort> }
234
+ const ip = typeof stRec.ip === "string" ? stRec.ip : undefined;
235
+ if (ip === "127.0.0.1" && String(stRec.port) === String(onlinePort))
236
+ return true;
237
+ // Design-time layout: connection/conType = "127.0.0.1:<onlinePort>"
238
+ const v = typeof stRec.connection === "string"
239
+ ? stRec.connection
240
+ : typeof stRec.conType === "string"
241
+ ? stRec.conType
242
+ : undefined;
243
+ if (stationTargetsLars(v, onlinePort))
244
+ return true;
245
+ }
246
+ }
247
+ catch { }
248
+ return false;
249
+ }
250
+ export function gcLarsWorkspaces(opts = {}) {
251
+ const workspaces = readLarsWorkspaces();
252
+ const instances = opts.instances ?? {};
253
+ const isRunning = opts.isRunning ?? ((name) => getLarsPids(name).length > 0);
254
+ const minAgeH = opts.minAgeH ?? 0;
255
+ const now = Date.now();
256
+ const larsGc = { ...(opts.larsGc ?? {}) };
257
+ const removed = [];
258
+ const kept = [];
259
+ const candidates = [];
260
+ const stationsJsonFiles = findStationsJsonFiles(opts.dataDirs ?? []);
261
+ for (const ws of workspaces) {
262
+ const inst = instances[ws.name];
263
+ let reason;
264
+ if (!inst) {
265
+ reason = "manual (no instance bookkeeping) — kept";
266
+ }
267
+ else if (isRunning(ws.name)) {
268
+ reason = "running";
269
+ }
270
+ else {
271
+ const lssPoints = inst.stationLssPath
272
+ ? (() => {
273
+ const conn = readLssConnection(inst.stationLssPath);
274
+ return !("error" in conn) && conn.ip === "127.0.0.1" && conn.port === String(ws.onlinePort);
275
+ })()
276
+ : false;
277
+ const dsPoints = inst.stationName !== undefined || inst.stationId !== undefined
278
+ ? stationsJsonFiles.some((f) => stationsJsonPointsAt(f, inst.stationName, inst.stationId, ws.onlinePort))
279
+ : false;
280
+ reason =
281
+ lssPoints || dsPoints ? "referenced (station .lss or DataService stations.json points at it)" : "unreferenced";
282
+ }
283
+ if (reason !== "unreferenced") {
284
+ delete larsGc[ws.name];
285
+ kept.push({ name: ws.name, onlinePort: ws.onlinePort, reason });
286
+ }
287
+ else {
288
+ const since = larsGc[ws.name]?.since ?? now;
289
+ const ageH = (now - since) / 3_600_000;
290
+ if (ageH >= minAgeH) {
291
+ if (!opts.dryRun) {
292
+ removeLarsWorkspace(ws.name);
293
+ delete larsGc[ws.name];
294
+ }
295
+ removed.push({ name: ws.name, onlinePort: ws.onlinePort, reason: `unreferenced for ${ageH.toFixed(1)}h` });
296
+ }
297
+ else {
298
+ if (!opts.dryRun)
299
+ larsGc[ws.name] = { since };
300
+ candidates.push({ name: ws.name, unreferencedForH: Number(ageH.toFixed(2)) });
301
+ kept.push({ name: ws.name, onlinePort: ws.onlinePort, reason: "unreferenced — below min age" });
302
+ }
303
+ }
304
+ }
305
+ return { removed, kept, candidates, larsGc };
306
+ }
307
+ // ─── Process management ───────────────────────────────────────────────────────
308
+ export function getLarsPids(name) {
309
+ try {
310
+ const filter = name ? ` | Where-Object { $_.MainWindowTitle -like '*${name}*' }` : "";
311
+ const out = execSync(`powershell -NoProfile -Command "Get-Process -Name Lars -ErrorAction SilentlyContinue${filter} | Select-Object -ExpandProperty Id"`, { encoding: "utf-8" });
312
+ return out
313
+ .split(/\r?\n/)
314
+ .map((s) => parseInt(s.trim(), 10))
315
+ .filter((n) => !isNaN(n));
316
+ }
317
+ catch {
318
+ return [];
319
+ }
320
+ }
321
+ export function killLars(name) {
322
+ for (const pid of getLarsPids(name)) {
323
+ try {
324
+ execSync(`taskkill /PID ${pid} /F /T`, { stdio: "pipe" });
325
+ }
326
+ catch { }
327
+ }
328
+ }
329
+ /** Launch a LARS instance for the given workspace. */
330
+ export async function startLars(workspace) {
331
+ if (!existsSync(LARS_EXE)) {
332
+ return { pid: null, running: false, healthy: false, error: `LARS not found at ${LARS_EXE}` };
333
+ }
334
+ if (getLarsPids(workspace.name).length > 0) {
335
+ const healthy = await pingHost("127.0.0.1", workspace.onlinePort, 1000);
336
+ return { pid: null, running: true, healthy };
337
+ }
338
+ const config = larsConfigPath();
339
+ // NOTE: no embedded quotes in the args — Node's spawn does not escape inner
340
+ // quotes and would mangle `/c"C:\...xml"`; pass the raw path and let libuv quote.
341
+ const args = [`/c${config}`, `/n${workspace.name}`, "/sWIN"];
342
+ // LARS locates its runtime files (autoexec.lsl, lsldata, ...) relative to the
343
+ // install directory — NOT the workspace's data dir. Use the exe's dir as cwd.
344
+ const installDir = dirname(LARS_EXE);
345
+ try {
346
+ const child = spawn(LARS_EXE, args, {
347
+ cwd: installDir,
348
+ detached: true,
349
+ stdio: "ignore",
350
+ windowsHide: false,
351
+ });
352
+ const pid = child.pid ?? null;
353
+ child.unref();
354
+ return { pid, running: true, healthy: false };
355
+ }
356
+ catch (e) {
357
+ const msg = e instanceof Error ? e.message : String(e);
358
+ return { pid: null, running: false, healthy: false, error: msg };
359
+ }
360
+ }
361
+ export function isLarsHealthy(onlinePort, timeoutMs = 1000) {
362
+ return pingHost("127.0.0.1", onlinePort, timeoutMs);
363
+ }
364
+ // ─── Station targeting ────────────────────────────────────────────────────────
365
+ /**
366
+ * Point a station's .lss TCPIP profile at a local LARS instance (127.0.0.1:<onlinePort>).
367
+ * Returns the previous connection so it can be restored later.
368
+ */
369
+ export function pointStationAtLars(lssPath, onlinePort) {
370
+ const current = readLssConnection(lssPath);
371
+ if ("error" in current) {
372
+ // Station has no TCPIP profile yet — insert one pointing at LARS.
373
+ updateLssConnection(lssPath, { ip: "127.0.0.1", port: String(onlinePort) });
374
+ return { previousIp: "", previousPort: "" };
375
+ }
376
+ updateLssConnection(lssPath, { ip: "127.0.0.1", port: String(onlinePort) });
377
+ return { previousIp: current.ip, previousPort: current.port };
378
+ }
379
+ export function safeWorkspaceName(projectName, stationName) {
380
+ const clean = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^[0-9]+/, "_");
381
+ return `${clean(projectName)}_${clean(stationName)}`;
382
+ }
383
+ export function projectDisplayName(projectDir) {
384
+ return basename(projectDir);
385
+ }
386
+ /**
387
+ * Rewrite a published DataService stations.json so hardware-targeted stations
388
+ * point at a running local LARS instance (127.0.0.1:<onlinePort>).
389
+ *
390
+ * Two layouts are handled:
391
+ * - design-time layout: `{ name, connection: "TCPIP:10.0.0.5:1964" }` (or `conType`)
392
+ * - published runtime layout: `{ station: 10, ip: "10.195.0.10", conType: "TCP" }`
393
+ * (the DataService stores the target IP in `ip`; the port is carried separately)
394
+ *
395
+ * "INTERN"/"LOCAL" connections and stations without a matching LARS instance
396
+ * are left untouched.
397
+ */
398
+ export function mapStationsToLars(stations, larsInstances, isRunning = (name) => getLarsPids(name).length > 0) {
399
+ const runningByName = new Map();
400
+ const runningById = new Map();
401
+ for (const inst of Object.values(larsInstances)) {
402
+ if (!isRunning(inst.name))
403
+ continue;
404
+ if (inst.stationName)
405
+ runningByName.set(inst.stationName, inst);
406
+ if (inst.stationId !== undefined)
407
+ runningById.set(inst.stationId, inst);
408
+ }
409
+ if (runningByName.size === 0 && runningById.size === 0)
410
+ return [];
411
+ const changed = [];
412
+ for (const st of stations) {
413
+ const name = typeof st.name === "string" ? st.name : undefined;
414
+ const num = typeof st.station === "number" ? st.station : typeof st.station === "string" ? parseInt(st.station, 10) : NaN;
415
+ const inst = (name && runningByName.get(name)) ?? (!isNaN(num) && runningById.get(num));
416
+ if (!inst)
417
+ continue;
418
+ const to = `127.0.0.1:${inst.onlinePort}`;
419
+ // Published runtime layout: { station, ip, conType }
420
+ const ip = typeof st.ip === "string" ? st.ip : undefined;
421
+ if (ip !== undefined) {
422
+ const isHardwareIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !ip.startsWith("127.");
423
+ if (!isHardwareIp)
424
+ continue;
425
+ st.ip = "127.0.0.1";
426
+ st.port = inst.onlinePort;
427
+ changed.push({ station: name ?? String(num), from: ip, to });
428
+ continue;
429
+ }
430
+ // Design-time layout: { name, connection | conType }
431
+ const field = typeof st.connection === "string" ? "connection" : typeof st.conType === "string" ? "conType" : undefined;
432
+ if (!field)
433
+ continue;
434
+ const value = String(st[field]);
435
+ const isHardwareTarget = /^TCPIP:/i.test(value) || /^\d{1,3}(\.\d{1,3}){3}/.test(value);
436
+ if (!isHardwareTarget)
437
+ continue;
438
+ st[field] = to;
439
+ changed.push({ station: name ?? String(num), from: value, to });
440
+ }
441
+ return changed;
442
+ }
443
+ /**
444
+ * LARS is an x86 (PC) runtime. Projects compiled with <Target Processor="ARM">
445
+ * are rejected by LARS with a checksum error. Switching the project's compile
446
+ * target to PC (removing the Processor attribute, byte-preserving) makes
447
+ * batch.Compile produce an x86 image LARS can run. Restore with restoreProjectTarget.
448
+ */
449
+ export function switchProjectTargetToPC(lcpPath) {
450
+ const raw = readFileSync(lcpPath, "latin1");
451
+ const m = raw.match(/<Target\s[^>]*?>/);
452
+ if (!m)
453
+ return { error: `No <Target ...> element found in ${lcpPath}` };
454
+ const tag = m[0];
455
+ if (!/\bProcessor\s*=\s*"ARM"/.test(tag)) {
456
+ return { error: `Project is not ARM-targeted (tag: "${tag}") — nothing to switch.` };
457
+ }
458
+ const newTag = tag.replace(/\s+Processor\s*=\s*"ARM"/, "");
459
+ if (newTag === tag)
460
+ return { error: `Failed to remove Processor="ARM" from ${lcpPath}` };
461
+ writeFileSync(lcpPath, raw.replace(tag, newTag), "latin1");
462
+ return { previousTag: tag, newTag };
463
+ }
464
+ export function restoreProjectTarget(lcpPath, previousTag) {
465
+ const raw = readFileSync(lcpPath, "latin1");
466
+ const m = raw.match(/<Target\s[^>]*?>/);
467
+ if (!m)
468
+ return false;
469
+ writeFileSync(lcpPath, raw.replace(m[0], previousTag), "latin1");
470
+ return true;
471
+ }