@tsplc/build 0.0.0-stage → 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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1652 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1552 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +17 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/entrypoint.sh +10 -0
  17. package/docker/install/NOTICE.md +21 -0
  18. package/docker/install/core.sh +36 -0
  19. package/docker/install/decode.c +12 -0
  20. package/docker/install/device/Runtime.ico +0 -0
  21. package/docker/install/device/Runtime.png +0 -0
  22. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  23. package/docker/install/extract.py +43 -0
  24. package/docker/install/helper-launcher.c +46 -0
  25. package/docker/install/install.sh +94 -0
  26. package/docker/install/prerequisites.sh +15 -0
  27. package/docker/install/verify.py +133 -0
  28. package/docker/install/verify.sh +34 -0
  29. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  30. package/docker/install/wine-ncrypt/README.md +28 -0
  31. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  32. package/docker/install/wine-ncrypt/main.c +619 -0
  33. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  34. package/docker/install/wine-signal/README.md +22 -0
  35. package/docker/install/wine-signal/build.sh +10 -0
  36. package/docker/install/wine-signal/check.sh +34 -0
  37. package/docker/install/wine-signal/noop-i386.c +2 -0
  38. package/docker/install/wine-signal/signal-state.c +105 -0
  39. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  40. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  41. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  42. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  43. package/docker/install/wine-signal/wine +4 -0
  44. package/docker/runner.ts +131 -0
  45. package/package.json +53 -4
  46. package/python/build.py +655 -0
  47. package/python/codesys_online.py +300 -0
  48. package/python/test_build.py +444 -0
  49. package/python/test_codesys_online.py +170 -0
  50. package/python/test_installer_packages.py +216 -0
  51. package/python/test_installer_verification.py +307 -0
  52. package/python/test_worker.py +142 -0
  53. package/python/worker.py +89 -0
  54. package/runtime/install.sh +74 -0
  55. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  56. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  57. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  58. package/src/artifacts.ts +77 -0
  59. package/src/build.ts +87 -0
  60. package/src/cli.ts +116 -0
  61. package/src/config.ts +12 -0
  62. package/src/controller-online.ts +143 -0
  63. package/src/controller-session.ts +229 -0
  64. package/src/diagnostics.ts +66 -0
  65. package/src/docker-build.ts +148 -0
  66. package/src/docker-context.ts +35 -0
  67. package/src/docker-run.ts +104 -0
  68. package/src/host/codesys-command.ts +45 -0
  69. package/src/host/codesys-host.ts +145 -0
  70. package/src/host/codesys-worker.ts +345 -0
  71. package/src/host/windows-path.ts +73 -0
  72. package/src/index.ts +26 -0
  73. package/src/io.ts +127 -0
  74. package/src/libraries.ts +166 -0
  75. package/src/package-path.ts +5 -0
  76. package/src/run.ts +133 -0
  77. package/src/runtime-status.ts +21 -0
  78. package/src/sources.ts +248 -0
  79. package/src/st/declarations.ts +229 -0
  80. package/src/st/lexer.ts +125 -0
  81. package/src/st/parse.ts +202 -0
  82. package/src/st/reader.ts +50 -0
@@ -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,26 @@
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 { type ControllerResult, inspectController, updateController } from "./controller-online";
12
+ export { type CodesysCompilerDiagnostic, CodesysCompilerError } from "./diagnostics";
13
+ export { runProject } from "./run";
14
+ export { loadSources, type SourceObject } from "./sources";
15
+ export { StSyntaxError } from "./st/lexer";
16
+ export { type ObjectKind, type ParsedSource, parseSource } from "./st/parse";
17
+
18
+ export {
19
+ installLibraries,
20
+ validateLibrary,
21
+ libraryIndex,
22
+ type InstalledLibrary,
23
+ } from "./libraries.ts";
24
+
25
+ export { configureDocker, configureRuntime } from "./docker-build.ts";
26
+ 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
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import type { LibraryDependency } from "@tsplc/config";
5
+
6
+ export const libraryRegistry = "https://store-archive.codesys.com/CODESYSLibs";
7
+ const libraryFile = /\.(?:compiled-library(?:-v\d+)?|library)$/i;
8
+ type Fetcher = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+ export interface InstalledLibrary {
10
+ title: string;
11
+ company?: string;
12
+ version?: string;
13
+ namespace?: string;
14
+ path: string;
15
+ sha256: string;
16
+ url?: string;
17
+ }
18
+ interface LibraryLock {
19
+ version: 1;
20
+ dependencies: Record<string, InstalledLibrary>;
21
+ }
22
+ const hash = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
23
+ const component = (name: string) => {
24
+ if (!name.trim() || /[/\\\0\r\n]/.test(name) || name === "." || name === "..")
25
+ throw new Error(`Invalid library registry component: ${name}.`);
26
+ return encodeURIComponent(name);
27
+ };
28
+ async function response(url: string, fetcher: Fetcher) {
29
+ const result = await fetcher(url, { signal: AbortSignal.timeout(30_000) });
30
+ if (!result.ok) throw new Error(`Library registry ${result.status}: ${url}`);
31
+ return result;
32
+ }
33
+ export async function libraryIndex(
34
+ path: string[] = ["System"],
35
+ fetcher: Fetcher = fetch,
36
+ ): Promise<string[]> {
37
+ const url = `${libraryRegistry}/${path.map(component).join("/")}/index`;
38
+ const result = await response(url, fetcher);
39
+ const text = await result.text();
40
+ if (text.length > 4_000_000 || /<html/i.test(text))
41
+ throw new Error(`Invalid library registry index: ${url}`);
42
+ return text
43
+ .split(/\r?\n/)
44
+ .map((s) => s.trim())
45
+ .filter(Boolean);
46
+ }
47
+ export async function validateLibrary(
48
+ title: string,
49
+ version: string,
50
+ company = "System",
51
+ fetcher: Fetcher = fetch,
52
+ ) {
53
+ if (!(await libraryIndex([company], fetcher)).includes(title))
54
+ throw new Error(`Unknown CODESYS library ${company}/${title}. Names are case sensitive.`);
55
+ const versions = await libraryIndex([company, title], fetcher);
56
+ if (!versions.includes(version))
57
+ throw new Error(`Unknown version ${version} for ${title}. Available: ${versions.join(", ")}`);
58
+ const filenames = (await libraryIndex([company, title, version], fetcher)).filter((name) =>
59
+ libraryFile.test(name),
60
+ );
61
+ if (filenames.length !== 1)
62
+ throw new Error(
63
+ `Expected one library payload for ${title} ${version}, received ${filenames.length}.`,
64
+ );
65
+ return {
66
+ filename: filenames[0]!,
67
+ url: `${libraryRegistry}/${[company, title, version, filenames[0]!].map(component).join("/")}`,
68
+ };
69
+ }
70
+ export async function installLibraries(
71
+ dependencies: Record<string, LibraryDependency>,
72
+ root: string,
73
+ options: { frozen?: boolean; fetcher?: Fetcher } = {},
74
+ ) {
75
+ const file = resolve(root, "tsplc.lock.json"),
76
+ store = resolve(root, ".tsplc/libraries");
77
+ let previous: LibraryLock = { version: 1, dependencies: {} };
78
+ try {
79
+ previous = JSON.parse(await readFile(file, "utf8"));
80
+ } catch (cause) {
81
+ if (!(cause && typeof cause === "object" && "code" in cause && cause.code === "ENOENT"))
82
+ throw cause;
83
+ }
84
+ if (previous.version !== 1 || !previous.dependencies || typeof previous.dependencies !== "object")
85
+ throw new Error("Invalid tsplc.lock.json.");
86
+ const installed: Record<string, InstalledLibrary> = {};
87
+ const fetcher = options.fetcher ?? fetch;
88
+ await mkdir(store, { recursive: true });
89
+ for (const [title, dependency] of Object.entries(dependencies)) {
90
+ component(title);
91
+ const settings = typeof dependency === "string" ? { version: dependency } : dependency;
92
+ const locked = previous.dependencies[title];
93
+ const prior = locked ? { ...locked, path: resolve(root, locked.path) } : undefined;
94
+ const same =
95
+ prior !== undefined &&
96
+ prior.namespace === settings.namespace &&
97
+ ("path" in settings
98
+ ? prior.url === undefined && prior.path === resolve(root, settings.path)
99
+ : prior.version === settings.version && prior.company === (settings.company ?? "System"));
100
+ if (same) {
101
+ try {
102
+ if (hash(await readFile(prior.path)) === prior.sha256) {
103
+ installed[title] = prior;
104
+ continue;
105
+ }
106
+ } catch {
107
+ /* Missing cache can be restored from the exact URL. */
108
+ }
109
+ if (options.frozen) throw new Error(`Locked library ${title} is missing or changed.`);
110
+ } else if (options.frozen) throw new Error(`Dependency ${title} differs from tsplc.lock.json.`);
111
+ if ("path" in settings) {
112
+ const path = resolve(root, settings.path);
113
+ if (!libraryFile.test(path))
114
+ throw new Error(`${title}: expected a .library or .compiled-library file.`);
115
+ installed[title] = {
116
+ title,
117
+ path,
118
+ sha256: hash(await readFile(path)),
119
+ ...(settings.namespace ? { namespace: settings.namespace } : {}),
120
+ };
121
+ } else {
122
+ const company = settings.company ?? "System";
123
+ const payload = await validateLibrary(title, settings.version, company, fetcher);
124
+ const result = await response(payload.url, fetcher);
125
+ const data = new Uint8Array(await result.arrayBuffer());
126
+ if (!data.length || data.length > 256 * 1024 * 1024)
127
+ throw new Error(`${title}: invalid library payload size.`);
128
+ const sha256 = hash(data);
129
+ // An existing lock pins the payload, even if the registry later changes it.
130
+ if (same && prior.sha256 !== sha256)
131
+ throw new Error(
132
+ `Registry payload changed for locked library ${title} ${settings.version}.`,
133
+ );
134
+ const path = resolve(store, `${sha256}-${basename(payload.filename)}`),
135
+ temp = `${path}.${randomUUID()}.tmp`;
136
+ await writeFile(temp, data, { flag: "wx" });
137
+ await rename(temp, path);
138
+ installed[title] = {
139
+ title,
140
+ company,
141
+ version: settings.version,
142
+ path,
143
+ sha256,
144
+ url: payload.url,
145
+ ...(settings.namespace ? { namespace: settings.namespace } : {}),
146
+ };
147
+ }
148
+ }
149
+ if (
150
+ options.frozen &&
151
+ Object.keys(previous.dependencies).some((name) => !Object.hasOwn(dependencies, name))
152
+ )
153
+ throw new Error("Removed dependencies differ from tsplc.lock.json.");
154
+ if (!options.frozen) {
155
+ const temp = `${file}.${randomUUID()}.tmp`;
156
+ const portable = Object.fromEntries(
157
+ Object.entries(installed).map(([title, library]) => [
158
+ title,
159
+ { ...library, path: relative(root, library.path).replaceAll("\\", "/") },
160
+ ]),
161
+ );
162
+ await writeFile(temp, JSON.stringify({ version: 1, dependencies: portable }, null, 2) + "\n");
163
+ await rename(temp, file);
164
+ }
165
+ return Object.values(installed);
166
+ }
@@ -0,0 +1,5 @@
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Both source modules and published bundles live one directory below the package root.
5
+ export const packageDirectory = resolve(fileURLToPath(new URL("..", import.meta.url)));
package/src/run.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ import { readBuild } from "./artifacts";
9
+ import type { ResolvedConfig } from "./config";
10
+ import { runtimeImage } from "./docker-build";
11
+ import { withWorkerLock } from "./host/codesys-worker";
12
+ import { getApplicationStatus } from "./runtime-status";
13
+
14
+ const execute = promisify(execFile);
15
+ const stateSchema = z.object({
16
+ Running: z.boolean(),
17
+ StartedAt: z.iso.datetime({ offset: true }),
18
+ });
19
+
20
+ const service = "plc";
21
+
22
+ /** The Docker Compose project of a tsPLC project: `FrameAssembly` runs as `frame-assembly`. */
23
+ export const runtimeProject = (name: string) =>
24
+ name
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9_-]+/g, "-");
28
+
29
+ /** The local PLC: named volumes keep its provisioning and the installed application across restarts. */
30
+ export const runtimeDefinition = (name: string) => ({
31
+ services: {
32
+ [service]: {
33
+ image: runtimeImage,
34
+ platform: "linux/amd64",
35
+ hostname: `${runtimeProject(name)}-${service}`,
36
+ init: true,
37
+ ports: ["127.0.0.1:4840:4840", "127.0.0.1:11740:11740"],
38
+ cap_add: ["SYS_NICE", "IPC_LOCK"],
39
+ volumes: ["control-conf:/conf/codesyscontrol", "control-data:/data/codesyscontrol"],
40
+ restart: "no",
41
+ },
42
+ },
43
+ volumes: { "control-conf": {}, "control-data": {} },
44
+ });
45
+
46
+ /** Deploy the completed boot application to the project's local PLC container. */
47
+ export const runProject = async (
48
+ config: Pick<ResolvedConfig, "outDir" | "timeouts">,
49
+ name: string,
50
+ ) =>
51
+ withWorkerLock(config, async () => {
52
+ // Validate before stopping anything. A missing receipt includes a failed rebuild.
53
+ const build = await readBuild(config.outDir).catch((cause: unknown) => {
54
+ throw new Error(`No complete build in ${config.outDir}. Run tsplc-build build first.`, {
55
+ cause,
56
+ });
57
+ });
58
+ const definitionDirectory = await mkdtemp(join(tmpdir(), "tsplc-runtime-"));
59
+ try {
60
+ const composeFile = join(definitionDirectory, "compose.yaml");
61
+ // JSON is valid YAML.
62
+ await writeFile(composeFile, JSON.stringify(runtimeDefinition(name)));
63
+ const docker = (...args: string[]) =>
64
+ execute("docker", args, {
65
+ timeout: config.timeouts.runtime,
66
+ maxBuffer: 8 * 1024 * 1024,
67
+ });
68
+ const compose = (...args: string[]) =>
69
+ docker("compose", "--project-name", runtimeProject(name), "--file", composeFile, ...args);
70
+ const installer = await readFile(new URL("../runtime/install.sh", import.meta.url), "utf8");
71
+ console.log(`Installing ${build.application} in Docker service ${service}`);
72
+ await compose("stop", service);
73
+ // The helper uses the service's volumes without starting its PLC or publishing
74
+ // ports. Its read-only artifact mount is removed with the helper container.
75
+ await compose(
76
+ "run",
77
+ "--rm",
78
+ "--no-deps",
79
+ "-T",
80
+ "--volume",
81
+ `${dirname(build.output)}:/codesys-build:ro`,
82
+ "--entrypoint",
83
+ "/bin/sh",
84
+ service,
85
+ "-c",
86
+ installer,
87
+ "codesys-build-install",
88
+ build.application,
89
+ basename(build.output, ".app"),
90
+ );
91
+ await compose("up", "--detach", "--no-deps", service);
92
+ const containers = (await compose("ps", "--all", "--quiet", service)).stdout
93
+ .trim()
94
+ .split(/\s+/)
95
+ .filter(Boolean);
96
+ if (containers.length !== 1 || !containers[0]) {
97
+ throw new Error(`Expected one container for Compose service ${service}`);
98
+ }
99
+ const container = containers[0];
100
+ const deadline = Date.now() + config.timeouts.runtime;
101
+ while (true) {
102
+ const state = stateSchema.parse(
103
+ JSON.parse((await docker("inspect", "--format", "{{json .State}}", container)).stdout),
104
+ );
105
+ if (!state.Running) {
106
+ throw new Error(
107
+ `Docker service ${service} exited before the application started. Check docker logs ${container}.`,
108
+ );
109
+ }
110
+ // Use this container start time, not the host clock or an old "started" event.
111
+ const { stdout: log } = await docker(
112
+ "exec",
113
+ container,
114
+ "/bin/sh",
115
+ "-c",
116
+ "if [ -f /data/codesyscontrol/codesyscontrol.log ]; then cat /data/codesyscontrol/codesyscontrol.log; fi",
117
+ );
118
+ const status = getApplicationStatus(log, build.application, Date.parse(state.StartedAt));
119
+ if (status === "running") {
120
+ console.log(`${build.application} is running in ${service}`);
121
+ return { ...build, container };
122
+ }
123
+ if (status === "failed" || Date.now() >= deadline) {
124
+ throw new Error(
125
+ `CODESYS did not start ${build.application}. Runtime log:\n${log.split("\n").slice(-25).join("\n")}`,
126
+ );
127
+ }
128
+ await delay(250);
129
+ }
130
+ } finally {
131
+ await rm(definitionDirectory, { force: true, recursive: true });
132
+ }
133
+ });
@@ -0,0 +1,21 @@
1
+ /** Runtime stdout is buffered in Docker. Its log file records start/stop events immediately. */
2
+ export const getApplicationStatus = (log: string, application: string, startedAt: number) => {
3
+ const event = log
4
+ .split(/\r?\n/)
5
+ .reverse()
6
+ .find((line) => {
7
+ const timestamp = Date.parse(line.split(",")[0] ?? "");
8
+ if (!Number.isFinite(timestamp) || timestamp < startedAt) {
9
+ return false;
10
+ }
11
+ return (
12
+ line.includes(`<app>${application}</app>`) &&
13
+ /\b(started|stopped|exception|corrupt|failed|denied)\b/i.test(line) &&
14
+ !line.includes("load started")
15
+ );
16
+ });
17
+ if (!event) {
18
+ return "pending";
19
+ }
20
+ return event.includes(`Application [<app>${application}</app>] started`) ? "running" : "failed";
21
+ };