@tsplc/build 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/cli.js +1696 -0
- package/dist/cli.js.map +34 -0
- package/dist/index.js +1683 -0
- package/dist/index.js.map +31 -0
- package/dist/libraries.js +128 -0
- package/dist/libraries.js.map +10 -0
- package/docker/Dockerfile +173 -0
- package/docker/Dockerfile.dockerignore +18 -0
- package/docker/Dockerfile.source +15 -0
- package/docker/build-image +38 -0
- package/docker/codesys-build +5 -0
- package/docker/config.ts +8 -0
- package/docker/control-linux-sl/Dockerfile +38 -0
- package/docker/edge-gateway/Dockerfile +17 -0
- package/docker/entrypoint.sh +10 -0
- package/docker/install/NOTICE.md +21 -0
- package/docker/install/core.sh +36 -0
- package/docker/install/decode.c +12 -0
- package/docker/install/device/Runtime.ico +0 -0
- package/docker/install/device/Runtime.png +0 -0
- package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
- package/docker/install/extract.py +43 -0
- package/docker/install/helper-launcher.c +46 -0
- package/docker/install/install.sh +94 -0
- package/docker/install/prerequisites.sh +15 -0
- package/docker/install/verify.py +133 -0
- package/docker/install/verify.sh +34 -0
- package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
- package/docker/install/wine-ncrypt/README.md +28 -0
- package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
- package/docker/install/wine-ncrypt/main.c +619 -0
- package/docker/install/wine-ncrypt/oaep.patch +11 -0
- package/docker/install/wine-signal/README.md +22 -0
- package/docker/install/wine-signal/build.sh +10 -0
- package/docker/install/wine-signal/check.sh +34 -0
- package/docker/install/wine-signal/noop-i386.c +2 -0
- package/docker/install/wine-signal/signal-state.c +105 -0
- package/docker/install/wine-signal/tests/avx-registers.c +53 -0
- package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
- package/docker/install/wine-signal/tests/loader-i386.c +8 -0
- package/docker/install/wine-signal/tests/signal-return.c +47 -0
- package/docker/install/wine-signal/wine +4 -0
- package/docker/runner.ts +156 -0
- package/package.json +53 -4
- package/python/build.py +660 -0
- package/python/codesys_login.py +100 -0
- package/python/codesys_online.py +327 -0
- package/python/test_build.py +444 -0
- package/python/test_codesys_login.py +120 -0
- package/python/test_codesys_online.py +184 -0
- package/python/test_installer_packages.py +216 -0
- package/python/test_installer_verification.py +307 -0
- package/python/test_worker.py +142 -0
- package/python/worker.py +102 -0
- package/runtime/install.sh +74 -0
- package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
- package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
- package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
- package/src/artifacts.ts +77 -0
- package/src/build.ts +87 -0
- package/src/cli.ts +116 -0
- package/src/config.ts +12 -0
- package/src/controller-online.ts +297 -0
- package/src/controller-session.ts +298 -0
- package/src/diagnostics.ts +68 -0
- package/src/docker-build.ts +163 -0
- package/src/docker-context.ts +35 -0
- package/src/docker-run.ts +103 -0
- package/src/host/codesys-command.ts +45 -0
- package/src/host/codesys-host.ts +145 -0
- package/src/host/codesys-worker.ts +348 -0
- package/src/host/windows-path.ts +73 -0
- package/src/index.ts +43 -0
- package/src/io.ts +127 -0
- package/src/libraries.ts +166 -0
- package/src/package-path.ts +5 -0
- package/src/run.ts +133 -0
- package/src/runtime-status.ts +21 -0
- package/src/sources.ts +248 -0
- package/src/st/declarations.ts +229 -0
- package/src/st/lexer.ts +125 -0
- package/src/st/parse.ts +202 -0
- package/src/st/reader.ts +50 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Engineering CLI lifecycle and diagnostics.
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
copyFile,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
unlink,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import type { ResolvedConfig } from "../config";
|
|
18
|
+
import { CodesysCompilerError, compilerDiagnosticSchema } from "../diagnostics";
|
|
19
|
+
import { packageDirectory } from "../package-path";
|
|
20
|
+
import { codesysCommand, getHostCommand } from "./codesys-command";
|
|
21
|
+
import { createCodesysHost } from "./codesys-host";
|
|
22
|
+
|
|
23
|
+
const pythonDirectory = resolve(packageDirectory, "python");
|
|
24
|
+
|
|
25
|
+
const sessionSchema = z.object({
|
|
26
|
+
directory: z.string(),
|
|
27
|
+
fingerprint: z.string(),
|
|
28
|
+
pid: z.int().positive(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type Session = z.infer<typeof sessionSchema>;
|
|
32
|
+
|
|
33
|
+
type WorkerCommand =
|
|
34
|
+
| { action: "build" | "online" | "ping" | "stop" }
|
|
35
|
+
| { action: "login"; arguments: Record<string, unknown> };
|
|
36
|
+
|
|
37
|
+
const hasCode = (error: unknown, code: string) =>
|
|
38
|
+
error instanceof Error && "code" in error && error.code === code;
|
|
39
|
+
|
|
40
|
+
const isProcessAlive = (pid: number) => {
|
|
41
|
+
try {
|
|
42
|
+
// Signal 0 checks process existence and permissions without killing it.
|
|
43
|
+
// Only ESRCH means it is gone; permission errors must still propagate.
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (hasCode(error, "ESRCH")) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const readJson = async (path: string): Promise<unknown> => {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (hasCode(error, "ENOENT")) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const writeJson = async (path: string, value: unknown) => {
|
|
66
|
+
// Readers may poll while we write. A temporary file in the same directory
|
|
67
|
+
// lets rename publish the complete JSON atomically, without exposing partial data.
|
|
68
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
69
|
+
await writeFile(temporary, JSON.stringify(value, null, 2));
|
|
70
|
+
await rename(temporary, path);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Serializes startup and builds across CLI processes, including separate terminals. */
|
|
74
|
+
export const withWorkerLock = async <T>(
|
|
75
|
+
config: Pick<ResolvedConfig, "outDir" | "timeouts">,
|
|
76
|
+
run: () => Promise<T>,
|
|
77
|
+
) => {
|
|
78
|
+
await mkdir(resolve(config.outDir, ".codesys"), { recursive: true });
|
|
79
|
+
const path = resolve(config.outDir, ".codesys/command.lock");
|
|
80
|
+
const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;
|
|
81
|
+
while (true) {
|
|
82
|
+
const lock = await open(path, "wx").catch((error: unknown) => {
|
|
83
|
+
if (hasCode(error, "EEXIST")) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (lock) {
|
|
90
|
+
try {
|
|
91
|
+
await lock.writeFile(String(process.pid));
|
|
92
|
+
return await run();
|
|
93
|
+
} finally {
|
|
94
|
+
await lock.close();
|
|
95
|
+
await unlink(path);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const owner = Number(
|
|
100
|
+
await readFile(path, "utf8").catch((error: unknown) => {
|
|
101
|
+
if (hasCode(error, "ENOENT")) {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
if (owner > 0 && !isProcessAlive(owner)) {
|
|
108
|
+
// Do not race another client by deleting a lock that it may have replaced.
|
|
109
|
+
throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() >= deadline) {
|
|
112
|
+
throw new Error(`Timed out waiting for another CODESYS command (${path})`);
|
|
113
|
+
}
|
|
114
|
+
await delay(200);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */
|
|
119
|
+
export const createCodesysWorker = (config: ResolvedConfig) => {
|
|
120
|
+
const host = createCodesysHost(config);
|
|
121
|
+
const statePath = resolve(config.outDir, ".codesys/session.json");
|
|
122
|
+
const verifiedProcesses = new Set<number>();
|
|
123
|
+
|
|
124
|
+
const readSession = async (): Promise<Session | undefined> => {
|
|
125
|
+
const value = await readJson(statePath);
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const session = sessionSchema.safeParse(value);
|
|
130
|
+
if (!session.success) {
|
|
131
|
+
throw new Error(`Invalid CODESYS session file: ${statePath}`, {
|
|
132
|
+
cause: session.error,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return session.data;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const terminate = async (session: Session) => {
|
|
139
|
+
if (isProcessAlive(session.pid)) {
|
|
140
|
+
await host.terminate(session.pid).catch((error: unknown) => {
|
|
141
|
+
if (isProcessAlive(session.pid)) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Let the launcher reap its child before another error handler or CLI
|
|
147
|
+
// checks this PID. Sending a second group kill can otherwise race exit.
|
|
148
|
+
const deadline = Date.now() + config.timeouts.terminate;
|
|
149
|
+
while (isProcessAlive(session.pid)) {
|
|
150
|
+
if (Date.now() >= deadline) {
|
|
151
|
+
throw new Error("CODESYS launcher did not exit after termination");
|
|
152
|
+
}
|
|
153
|
+
await delay(50);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {
|
|
158
|
+
const id = randomUUID();
|
|
159
|
+
const reply = resolve(session.directory, `${id}.json`);
|
|
160
|
+
await writeJson(resolve(session.directory, "request.json"), {
|
|
161
|
+
...command,
|
|
162
|
+
id,
|
|
163
|
+
});
|
|
164
|
+
const deadline = Date.now() + timeoutMs;
|
|
165
|
+
while (true) {
|
|
166
|
+
const result = await readJson(reply);
|
|
167
|
+
if (result !== undefined) {
|
|
168
|
+
await unlink(reply);
|
|
169
|
+
const response = z
|
|
170
|
+
.object({
|
|
171
|
+
error: z.string().optional(),
|
|
172
|
+
diagnostics: z.array(compilerDiagnosticSchema).optional(),
|
|
173
|
+
id: z.literal(id),
|
|
174
|
+
ok: z.boolean(),
|
|
175
|
+
result: z.unknown(),
|
|
176
|
+
})
|
|
177
|
+
.safeParse(result);
|
|
178
|
+
if (!response.success) {
|
|
179
|
+
throw new Error("CODESYS returned an invalid worker response", {
|
|
180
|
+
cause: response.error,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
verifiedProcesses.add(session.pid);
|
|
184
|
+
if (!response.data.ok) {
|
|
185
|
+
const message = response.data.error ?? "CODESYS build failed";
|
|
186
|
+
if (response.data.diagnostics) {
|
|
187
|
+
throw new CodesysCompilerError(message, response.data.diagnostics);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(message);
|
|
190
|
+
}
|
|
191
|
+
return response.data.result;
|
|
192
|
+
}
|
|
193
|
+
if (!isProcessAlive(session.pid)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (Date.now() >= deadline) {
|
|
199
|
+
// A saved PID can belong to another process after a reboot. Only kill
|
|
200
|
+
// a process we launched or whose worker answered in this invocation.
|
|
201
|
+
if (!verifiedProcesses.has(session.pid)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
await terminate(session);
|
|
207
|
+
throw new Error(
|
|
208
|
+
`CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
await delay(200);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const launch = async () => {
|
|
216
|
+
await access(config.codesys.executable);
|
|
217
|
+
const fingerprint = createHash("sha256")
|
|
218
|
+
.update(
|
|
219
|
+
JSON.stringify({
|
|
220
|
+
builder: await readFile(resolve(pythonDirectory, "build.py"), "utf8"),
|
|
221
|
+
online: await readFile(resolve(pythonDirectory, "codesys_online.py"), "utf8"),
|
|
222
|
+
login: await readFile(resolve(pythonDirectory, "codesys_login.py"), "utf8"),
|
|
223
|
+
command: codesysCommand,
|
|
224
|
+
codesys: config.codesys,
|
|
225
|
+
launcher: getHostCommand(config.codesys),
|
|
226
|
+
worker: await readFile(resolve(pythonDirectory, "worker.py"), "utf8"),
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
.digest("hex");
|
|
230
|
+
const previous = await readSession();
|
|
231
|
+
if (previous && isProcessAlive(previous.pid)) {
|
|
232
|
+
if (previous.fingerprint !== fingerprint) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
await request(previous, { action: "ping" }, config.timeouts.startup);
|
|
238
|
+
console.log(
|
|
239
|
+
`Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,
|
|
240
|
+
);
|
|
241
|
+
return previous;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const directory = resolve(config.outDir, ".codesys/worker");
|
|
245
|
+
await rm(directory, { recursive: true, force: true });
|
|
246
|
+
await mkdir(directory, { recursive: true });
|
|
247
|
+
await copyFile(resolve(pythonDirectory, "build.py"), resolve(directory, "build.py"));
|
|
248
|
+
await copyFile(resolve(pythonDirectory, "worker.py"), resolve(directory, "worker.py"));
|
|
249
|
+
for (const script of ["codesys_online.py", "codesys_login.py"]) {
|
|
250
|
+
await copyFile(resolve(pythonDirectory, script), resolve(directory, script));
|
|
251
|
+
}
|
|
252
|
+
console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);
|
|
253
|
+
const pid = await host.startWorker(directory);
|
|
254
|
+
const session = { directory, fingerprint, pid };
|
|
255
|
+
verifiedProcesses.add(pid);
|
|
256
|
+
try {
|
|
257
|
+
await writeJson(statePath, session);
|
|
258
|
+
await request(session, { action: "ping" }, config.timeouts.startup);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await terminate(session);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
console.log(`CODESYS worker ready (${session.pid})`);
|
|
264
|
+
return session;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const checkExit = async (session: Session) => {
|
|
268
|
+
const status = await host.exitStatus(session.directory, session.pid);
|
|
269
|
+
if (!status) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (status.code !== 0 || status.signal !== null) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`CODESYS launcher exited with ${status.signal ?? status.code ?? "unknown status"}. See ${session.directory}/codesys.log`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const stop = async () => {
|
|
282
|
+
const session = await readSession();
|
|
283
|
+
if (!session) {
|
|
284
|
+
console.log("CODESYS worker is already stopped");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!isProcessAlive(session.pid)) {
|
|
288
|
+
await checkExit(session);
|
|
289
|
+
console.log("CODESYS worker is already stopped");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
await request(session, { action: "stop" }, config.timeouts.stop);
|
|
293
|
+
const deadline = Date.now() + config.timeouts.stop;
|
|
294
|
+
while (isProcessAlive(session.pid)) {
|
|
295
|
+
if (Date.now() >= deadline) {
|
|
296
|
+
const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;
|
|
297
|
+
try {
|
|
298
|
+
await terminate(session);
|
|
299
|
+
} catch (cause) {
|
|
300
|
+
throw new Error(`${message}; the worker could not be terminated`, { cause });
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`${message}; the worker required forced termination`);
|
|
303
|
+
}
|
|
304
|
+
await delay(200);
|
|
305
|
+
}
|
|
306
|
+
await checkExit(session);
|
|
307
|
+
console.log("CODESYS worker stopped");
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return { launch, request, stop };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** Container jobs own a temporary worker; always stop it before returning a result. */
|
|
314
|
+
export const withWorkerShutdown = async <T>(
|
|
315
|
+
config: ResolvedConfig,
|
|
316
|
+
worker: Pick<ReturnType<typeof createCodesysWorker>, "stop">,
|
|
317
|
+
operation: () => Promise<T>,
|
|
318
|
+
) => {
|
|
319
|
+
const stop = async () => {
|
|
320
|
+
try {
|
|
321
|
+
await withWorkerLock(config, () => worker.stop());
|
|
322
|
+
} finally {
|
|
323
|
+
await rm(resolve(config.outDir, ".codesys/session.json"), { force: true });
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
return Promise.resolve()
|
|
327
|
+
.then(operation)
|
|
328
|
+
.then(
|
|
329
|
+
async (result) => {
|
|
330
|
+
await stop();
|
|
331
|
+
return result;
|
|
332
|
+
},
|
|
333
|
+
async (failure: unknown) => {
|
|
334
|
+
await stop().catch((shutdownFailure: unknown) => {
|
|
335
|
+
// Diagnostic mapping follows cause; keep the operation there and
|
|
336
|
+
// expose its shutdown failure alongside it in errors.
|
|
337
|
+
throw new AggregateError(
|
|
338
|
+
[failure, shutdownFailure],
|
|
339
|
+
"CODESYS operation and worker shutdown failed",
|
|
340
|
+
{
|
|
341
|
+
cause: failure,
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
throw failure;
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { relative, resolve, sep, win32 } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { ResolvedConfig } from "../config";
|
|
6
|
+
import { commandArgumentSchema } from "./codesys-command";
|
|
7
|
+
import { codesysTimeouts } from "@tsplc/config/build";
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export const toWindowsPath = async (
|
|
12
|
+
path: string,
|
|
13
|
+
wine: ResolvedConfig["codesys"]["wine"],
|
|
14
|
+
platform: NodeJS.Platform = process.platform,
|
|
15
|
+
) => {
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
return commandArgumentSchema.parse(win32.resolve(path));
|
|
18
|
+
}
|
|
19
|
+
if (!wine?.prefix) {
|
|
20
|
+
throw new Error("Configure codesys.wine.prefix for CODESYS on macOS/Linux");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve filesystem aliases (e.g. macOS /var -> /private/var) so the file
|
|
24
|
+
// and drive roots are compared using the same paths. Fall back to the absolute
|
|
25
|
+
// spelling when realpath cannot resolve the file, such as before it exists.
|
|
26
|
+
const absolute = await realpath(resolve(path)).catch(() => resolve(path));
|
|
27
|
+
// Wine stores drive mappings as symlinks, e.g. dosdevices/c: -> ../drive_c.
|
|
28
|
+
// Reading these links avoids starting Wine just to convert a path.
|
|
29
|
+
const devices = resolve(wine.prefix, "dosdevices");
|
|
30
|
+
const drives = await readdir(devices).catch(() => []);
|
|
31
|
+
const mappings = await Promise.all(
|
|
32
|
+
drives
|
|
33
|
+
.filter((drive) => /^[a-z]:$/i.test(drive))
|
|
34
|
+
.map(async (drive) => {
|
|
35
|
+
const root = await realpath(resolve(devices, drive)).catch(() => undefined);
|
|
36
|
+
if (!root) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const suffix = relative(root, absolute);
|
|
41
|
+
// ".." or "../..." means the file is outside this drive's root.
|
|
42
|
+
// Match a full segment so a name such as "..notes" remains valid.
|
|
43
|
+
if (suffix === ".." || suffix.startsWith(`..${sep}`)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
root,
|
|
49
|
+
windows: win32.join(`${drive.toUpperCase()}\\`, ...suffix.split(sep)),
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
// Mappings can overlap: C: -> /wine/drive_c and Z: -> / may both contain
|
|
54
|
+
// the file. Prefer the longest matching root to use the most specific drive.
|
|
55
|
+
const [mapping] = mappings
|
|
56
|
+
.filter((value) => value !== undefined)
|
|
57
|
+
.sort((a, b) => b.root.length - a.root.length);
|
|
58
|
+
if (mapping) {
|
|
59
|
+
return commandArgumentSchema.parse(mapping.windows);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// No readable drive mapping covers the path; ask Wine to convert it instead.
|
|
63
|
+
const result = await execute(
|
|
64
|
+
wine.pathBinary,
|
|
65
|
+
["-w", resolve(path)],
|
|
66
|
+
// Wine inherits OS process settings.
|
|
67
|
+
{
|
|
68
|
+
env: { ...process.env, WINEPREFIX: wine.prefix, WINEDEBUG: wine.debug },
|
|
69
|
+
timeout: codesysTimeouts.hostCommand,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
return commandArgumentSchema.parse(result.stdout.trim());
|
|
73
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export { buildProject } from "./build";
|
|
2
|
+
export {
|
|
3
|
+
type CodesysBuildConfig,
|
|
4
|
+
type CyclicTask,
|
|
5
|
+
configSchema,
|
|
6
|
+
defineConfig,
|
|
7
|
+
loadConfig,
|
|
8
|
+
type ResolvedConfig,
|
|
9
|
+
resolveConfig,
|
|
10
|
+
} from "./config";
|
|
11
|
+
export {
|
|
12
|
+
type ControllerLogin,
|
|
13
|
+
type ControllerResult,
|
|
14
|
+
explicitDownload,
|
|
15
|
+
findControllerLogin,
|
|
16
|
+
inspectController,
|
|
17
|
+
type LoginCommand,
|
|
18
|
+
loginCommandSchema,
|
|
19
|
+
type LoginResult,
|
|
20
|
+
loginResultSchema,
|
|
21
|
+
openControllerLogin,
|
|
22
|
+
updateController,
|
|
23
|
+
} from "./controller-online";
|
|
24
|
+
export { type CodesysCompilerDiagnostic, CodesysCompilerError } from "./diagnostics";
|
|
25
|
+
export { runProject } from "./run";
|
|
26
|
+
export { loadSources, type SourceObject } from "./sources";
|
|
27
|
+
export { StSyntaxError } from "./st/lexer";
|
|
28
|
+
export { type ObjectKind, type ParsedSource, parseSource } from "./st/parse";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
installLibraries,
|
|
32
|
+
validateLibrary,
|
|
33
|
+
libraryIndex,
|
|
34
|
+
type InstalledLibrary,
|
|
35
|
+
} from "./libraries.ts";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
configureDocker,
|
|
39
|
+
configureGateway,
|
|
40
|
+
configureRuntime,
|
|
41
|
+
gatewayImage,
|
|
42
|
+
} from "./docker-build.ts";
|
|
43
|
+
export { readBuild } from "./artifacts.ts";
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SourceObject } from "./sources.ts";
|
|
2
|
+
import { tokenize, type Token } from "./st/lexer.ts";
|
|
3
|
+
import { variableSections } from "./st/declarations.ts";
|
|
4
|
+
|
|
5
|
+
export interface DirectIoBinding {
|
|
6
|
+
address: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
}
|
|
9
|
+
// A span binds at its first element: ARRAY[l..u] OF <element> with consecutive addresses.
|
|
10
|
+
const arrayType = /^ARRAY\s*\[\s*(-?\d+)\s*\.\.\s*(-?\d+)\s*\]\s*OF\s+(\w+)$/;
|
|
11
|
+
const layout = (type: string) => {
|
|
12
|
+
const array = arrayType.exec(type);
|
|
13
|
+
return array
|
|
14
|
+
? { element: array[3]!, length: Number(array[2]) - Number(array[1]) + 1 }
|
|
15
|
+
: { element: type, length: 1 };
|
|
16
|
+
};
|
|
17
|
+
const widths: Record<string, string> = {
|
|
18
|
+
BOOL: "X",
|
|
19
|
+
BYTE: "B",
|
|
20
|
+
SINT: "B",
|
|
21
|
+
USINT: "B",
|
|
22
|
+
WORD: "W",
|
|
23
|
+
INT: "W",
|
|
24
|
+
UINT: "W",
|
|
25
|
+
DWORD: "D",
|
|
26
|
+
DINT: "D",
|
|
27
|
+
UDINT: "D",
|
|
28
|
+
REAL: "D",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Apply the shared mapping to declarations in memory; authored ST stays untouched. */
|
|
32
|
+
export function bindStructuredTextIo(
|
|
33
|
+
objects: SourceObject[],
|
|
34
|
+
bindings: Record<string, DirectIoBinding>,
|
|
35
|
+
) {
|
|
36
|
+
const declarations: {
|
|
37
|
+
object: SourceObject;
|
|
38
|
+
name: string;
|
|
39
|
+
names: Token[];
|
|
40
|
+
colon: Token;
|
|
41
|
+
type: string;
|
|
42
|
+
}[] = [];
|
|
43
|
+
const visit = (object: SourceObject) => {
|
|
44
|
+
const tokens = tokenize(object.declaration, object.source);
|
|
45
|
+
let section = false,
|
|
46
|
+
start = 0;
|
|
47
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
48
|
+
const token = tokens[i]!;
|
|
49
|
+
if (variableSections.has(token.value)) {
|
|
50
|
+
section = true;
|
|
51
|
+
start = i + 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (token.value === "END_VAR") {
|
|
55
|
+
section = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!section || token.value !== ";") continue;
|
|
59
|
+
const part = tokens.slice(start, i).filter((t) => t.kind !== "pragma");
|
|
60
|
+
start = i + 1;
|
|
61
|
+
const colonIndex = part.findIndex((t) => t.value === ":");
|
|
62
|
+
if (colonIndex < 1) continue;
|
|
63
|
+
const names = part
|
|
64
|
+
.slice(0, colonIndex)
|
|
65
|
+
.filter(
|
|
66
|
+
(t) => t.kind === "word" && !["CONSTANT", "RETAIN", "PERSISTENT", "AT"].includes(t.value),
|
|
67
|
+
);
|
|
68
|
+
const at = part.findIndex((t) => t.value === "AT");
|
|
69
|
+
const variables = at < 0 ? names : names.filter((t) => t.start < part[at]!.start);
|
|
70
|
+
const initializer = part.findIndex((t, index) => index > colonIndex && t.value === ":=");
|
|
71
|
+
const typeTokens = part.slice(colonIndex + 1, initializer < 0 ? undefined : initializer);
|
|
72
|
+
const type = typeTokens.length
|
|
73
|
+
? object.declaration
|
|
74
|
+
.slice(typeTokens[0]!.start, typeTokens.at(-1)!.end)
|
|
75
|
+
.replace(/\s+/g, " ")
|
|
76
|
+
.toUpperCase()
|
|
77
|
+
: "";
|
|
78
|
+
for (const name of variables)
|
|
79
|
+
declarations.push({
|
|
80
|
+
object,
|
|
81
|
+
name: name.value,
|
|
82
|
+
names: variables,
|
|
83
|
+
colon: part[colonIndex]!,
|
|
84
|
+
type,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
object.children.forEach(visit);
|
|
88
|
+
};
|
|
89
|
+
objects.forEach(visit);
|
|
90
|
+
const edits = new Map<SourceObject, { start: number; end: number; text: string }[]>();
|
|
91
|
+
for (const [symbol, binding] of Object.entries(bindings)) {
|
|
92
|
+
const parts = symbol.toUpperCase().split(".");
|
|
93
|
+
if (parts.length > 2)
|
|
94
|
+
throw new Error(`${symbol}: direct IO maps a scalar declaration, not a nested field.`);
|
|
95
|
+
const matches = declarations.filter(
|
|
96
|
+
(d) =>
|
|
97
|
+
d.name === parts.at(-1) && (parts.length === 1 || d.object.name.toUpperCase() === parts[0]),
|
|
98
|
+
);
|
|
99
|
+
if (matches.length !== 1)
|
|
100
|
+
throw new Error(
|
|
101
|
+
`${symbol}: expected one ST variable declaration, found ${matches.length}. Use Object.Variable to disambiguate.`,
|
|
102
|
+
);
|
|
103
|
+
const declaration = matches[0]!;
|
|
104
|
+
if (declaration.names.length !== 1)
|
|
105
|
+
throw new Error(`${symbol}: declare mapped IO variables separately.`);
|
|
106
|
+
const declared = layout(declaration.type);
|
|
107
|
+
if (
|
|
108
|
+
!widths[declared.element] ||
|
|
109
|
+
(declared.length > 1 && declared.element === "BOOL") ||
|
|
110
|
+
binding.address[2] !== widths[declared.element]
|
|
111
|
+
)
|
|
112
|
+
throw new Error(`${symbol}: ${declaration.type} is incompatible with ${binding.address}.`);
|
|
113
|
+
const expected = binding.type && layout(binding.type.replace(/\s+/g, " ").toUpperCase());
|
|
114
|
+
if (expected && (expected.element !== declared.element || expected.length !== declared.length))
|
|
115
|
+
throw new Error(`${symbol}: expected ${binding.type}, found ${declaration.type}.`);
|
|
116
|
+
const list = edits.get(declaration.object) ?? [];
|
|
117
|
+
const start = declaration.names[0]!.end;
|
|
118
|
+
if (list.some((e) => e.start === start))
|
|
119
|
+
throw new Error(`${symbol}: the variable has more than one IO binding.`);
|
|
120
|
+
list.push({ start, end: declaration.colon.start, text: ` AT ${binding.address} ` });
|
|
121
|
+
edits.set(declaration.object, list);
|
|
122
|
+
}
|
|
123
|
+
for (const [object, list] of edits)
|
|
124
|
+
for (const edit of list.sort((a, b) => b.start - a.start))
|
|
125
|
+
object.declaration =
|
|
126
|
+
object.declaration.slice(0, edit.start) + edit.text + object.declaration.slice(edit.end);
|
|
127
|
+
}
|