nodalis-compiler 1.0.21 → 1.0.23

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/CHANGELOG.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## [1.0.21] 2026-03-03
3
+ ## [1.0.23] 2026-03-03
4
4
  - Changed IEC parser to interpret Function Blocks that are actually standard functions to a formal function call.
5
5
  - Fixed nodejs/jint compiles to put executables in a bin folder.
6
6
  - Fixed syntax errors with repeat.
7
+ - Prevent stale files in ST bundle compile.
8
+ - Added a new "action" for "get-toolchains", which will download the necessary toolchains for CPP and Arduino.
7
9
 
8
10
  ## [1.0.17] 2026-02-25
9
11
  - Added support for compilation of multiple ST files as a single project.
package/README.md CHANGED
@@ -56,6 +56,7 @@ Usage:
56
56
  Actions:
57
57
  --action list-compilers
58
58
  --action compile
59
+ --action get-toolchains
59
60
  ```
60
61
 
61
62
  ---
@@ -68,6 +69,18 @@ Actions:
68
69
  nodalis --action list-compilers
69
70
  ```
70
71
 
72
+ ### ✔ Install managed C/C++ toolchains
73
+
74
+ ```bash
75
+ nodalis --action get-toolchains
76
+ ```
77
+
78
+ This installs:
79
+ - Managed Zig-based C/C++ cross-compilers
80
+ - Managed `arduino-cli`
81
+ - Default Arduino board cores for the built-in Nodalis FQBN targets
82
+ - Required Arduino libraries for the built-in Nodalis Arduino runtime, including `ArduinoModbus`
83
+
71
84
  ---
72
85
 
73
86
  ### ✔ Compile a Structured Text program
@@ -113,23 +126,34 @@ await app.compile({
113
126
 
114
127
  #### Dependencies
115
128
 
116
- - Uses a default cross-compiler profile tuned for macOS-style Clang/LLVM toolchains when no overrides are provided.
129
+ - Uses managed Zig-based toolchain wrappers under `~/.nodalis/toolchains` when no overrides are provided.
130
+ - Install those toolchains with `nodalis --action get-toolchains`.
131
+ - `get-toolchains` also installs a managed `arduino-cli` plus the default Nodalis Arduino board cores.
132
+ - `get-toolchains` also installs the default Arduino libraries required by the built-in runtime support.
133
+ - On Windows hosts, the managed toolchain installs wrappers for all Linux and Windows targets.
134
+ - On macOS hosts, the managed toolchain installs wrappers for all Linux, Windows, and macOS targets.
117
135
  - Supply a `toolchain.json` file beside your source to describe a custom toolchain. Example:
118
136
 
119
137
  ```json
120
138
  {
121
- "linux-arm": "arm-linux-gnueabi-g++",
122
- "linux-arm64": "aarch64-linux-gnu-g++",
123
- "linux-x64": "x86_64-linux-gnu-g++",
124
- "macos-arm64": "clang++",
125
- "macos-x64": "clang++",
126
- "windows-x64": "x86_64-w64-mingw32-g++",
127
- "windows-arm64": "/opt/llvm-mingw/bin/aarch64-w64-mingw32-g++"
139
+ "linux-arm": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-linux-arm-c++",
140
+ "linux-arm64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-linux-arm64-c++",
141
+ "linux-x64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-linux-x64-c++",
142
+ "windows-x64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-windows-x64-c++",
143
+ "windows-arm64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-windows-arm64-c++",
144
+ "macos-x64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-macos-x64-c++",
145
+ "macos-arm64": "/Users/you/.nodalis/toolchains/zig/0.15.2/wrappers/zig-macos-arm64-c++"
128
146
  }
129
147
  ```
130
148
 
131
- - Without an explicit file Nodalis falls back to the default compiler for the host OS (`clang++` on macOS, `g++` on Linux, and `cl.exe` or MinGW-w64 `g++` on Windows).
132
- - Common cross-compiler sources: Homebrew packages (`brew install armmbed/formulae/arm-none-eabi-gcc`) and osxcross for macOS targeting, MinGW-w64/MSYS2 or Visual Studio Build Tools for Windows, and distro packages such as `gcc-arm-linux-gnueabihf` or `x86_64-w64-mingw32-g++` on Linux.
149
+ - The managed installer skips macOS targets on non-macOS hosts.
150
+ - `toolchain.json` remains the escape hatch for unsupported or custom compiler setups.
151
+
152
+ ### Arduino
153
+
154
+ Nodalis uses a managed `arduino-cli` by default when present under `~/.nodalis/toolchains/arduino-cli`.
155
+ Running `nodalis --action get-toolchains` also installs the default board cores required by the built-in Arduino FQBN targets, currently including `arduino:mbed_opta:opta`.
156
+ It also installs the default Arduino libraries required by the shipped support code, currently including `ArduinoModbus`.
133
157
 
134
158
  #### Variations
135
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodalis-compiler",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Compiles IEC-61131-3/10 languages into code that can be used as a PLC on multiple platforms.",
5
5
  "icon": "nodalis.png",
6
6
  "main": "src/nodalis.js",
@@ -23,14 +23,12 @@ import { Compiler, IECLanguage, OutputType, CommunicationProtocol } from './Comp
23
23
  import * as iec from './iec-parser/parser.js';
24
24
  import { parseStructuredText } from './st-parser/parser.js';
25
25
  import { transpile } from './st-parser/gcctranspiler.js';
26
+ import { DEFAULT_ARDUINO_FQBN } from './arduinoDefaults.js';
27
+ import { getManagedArduinoCliPath, getManagedArduinoCliExecOptions } from '../toolchains.js';
26
28
 
27
29
  const __filename = fileURLToPath(import.meta.url);
28
30
  const __dirname = dirname(__filename);
29
31
 
30
- const DEFAULT_ARDUINO_FQBN = {
31
- 'arduino-opta': 'arduino:mbed_opta:opta'
32
- };
33
-
34
32
  const MODBUS_ARDUINO_CORE_IDS = new Set([
35
33
  'arduino:megaavr',
36
34
  'arduino:samd',
@@ -115,6 +113,7 @@ export class ArduinoCompiler extends Compiler {
115
113
  let sourceCode = '';
116
114
  let bundleEntryProgram = '';
117
115
  if (directoryBundleMode) {
116
+ this.cleanupStructuredTextBundleArtifacts(sourcePath);
118
117
  const { combinedSource, entryProgramName } = this.loadStructuredTextBundle(sourcePath, resourceName);
119
118
  sourceCode = combinedSource;
120
119
  bundleEntryProgram = entryProgramName;
@@ -256,7 +255,7 @@ void loop() {
256
255
  fs.cpSync(path.join(supportDir, 'json.hpp'), path.join(outputPath, 'json.hpp'), { force: true });
257
256
 
258
257
  if (this.isExecutableOutput()) {
259
- const arduinoCli = compilerConfig.arduino_cli || compilerConfig.arduinoCli || 'arduino-cli';
258
+ const arduinoCli = compilerConfig.arduino_cli || compilerConfig.arduinoCli || getManagedArduinoCliPath();
260
259
  const arduinoFqbn = this.resolveArduinoFqbn(target, compilerConfig);
261
260
  if (!arduinoFqbn) {
262
261
  throw new Error(`No Arduino FQBN configured for target "${target}". Use an explicit FQBN target (e.g. arduino:mbed_opta:opta) or add "arduino_fqbn" to toolchain.json.`);
@@ -271,23 +270,25 @@ void loop() {
271
270
  fs.mkdirSync(binDir, { recursive: true });
272
271
  const arduinoCompileCmd = `${arduinoCli} compile --fqbn "${arduinoFqbn}" "${outputPath}" --build-path "${buildDir}" --output-dir "${binDir}" --export-binaries`;
273
272
  try {
274
- execSync(arduinoCompileCmd, { stdio: 'pipe' });
273
+ execSync(arduinoCompileCmd, getManagedArduinoCliExecOptions({ stdio: 'pipe' }));
275
274
  } catch (err) {
276
275
  const stderrText = getExecOutputText(err?.stderr);
277
276
  const stdoutText = getExecOutputText(err?.stdout);
278
277
  const compilerOutput = [stderrText, stdoutText].filter(Boolean).join('\n');
278
+ const missingArduinoLibrary = compilerOutput.includes('ArduinoModbus.h') || compilerOutput.includes('No such file or directory');
279
279
  const details = compilerOutput || err.message;
280
280
  throw new Error(
281
- `Arduino CLI build failed for "${arduinoFqbn}". Verify arduino-cli and board core availability.\n${details}`
281
+ `Arduino CLI build failed for "${arduinoFqbn}". ` +
282
+ `${missingArduinoLibrary
283
+ ? 'Run "nodalis --action get-toolchains" to install the managed Arduino cores and libraries.'
284
+ : 'Verify arduino-cli, board core, and library availability.'}\n${details}`
282
285
  );
283
286
  }
284
287
  }
285
288
  }
286
289
 
287
290
  loadStructuredTextBundle(sourcePath, resourceName) {
288
- const stFiles = fs.readdirSync(sourcePath, { withFileTypes: true })
289
- .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.st'))
290
- .map((entry) => entry.name);
291
+ const stFiles = this.listStructuredTextBundleFiles(sourcePath);
291
292
 
292
293
  if (stFiles.length === 0) {
293
294
  throw new Error(`No .st files found in source directory "${sourcePath}".`);
@@ -354,7 +355,7 @@ void loop() {
354
355
 
355
356
  let coreList = '';
356
357
  try {
357
- coreList = execSync(`${arduinoCli} core list`, { encoding: 'utf8' });
358
+ coreList = execSync(`${arduinoCli} core list`, getManagedArduinoCliExecOptions({ encoding: 'utf8' }));
358
359
  } catch (err) {
359
360
  throw new Error(`Failed to query installed Arduino cores using "${arduinoCli}". ${err.message}`);
360
361
  }
@@ -363,8 +364,8 @@ void loop() {
363
364
  }
364
365
 
365
366
  try {
366
- execSync(`${arduinoCli} core update-index`, { stdio: 'inherit' });
367
- execSync(`${arduinoCli} core install ${coreId}`, { stdio: 'inherit' });
367
+ execSync(`${arduinoCli} core update-index`, getManagedArduinoCliExecOptions({ stdio: 'inherit' }));
368
+ execSync(`${arduinoCli} core install ${coreId}`, getManagedArduinoCliExecOptions({ stdio: 'inherit' }));
368
369
  } catch (err) {
369
370
  throw new Error(`Failed to install Arduino core "${coreId}" required for ${arduinoFqbn}. ${err.message}`);
370
371
  }
@@ -388,7 +389,10 @@ void loop() {
388
389
  const targets = Object.keys(DEFAULT_ARDUINO_FQBN);
389
390
 
390
391
  try {
391
- const boardListRaw = execSync('arduino-cli board listall --format json', { encoding: 'utf8' });
392
+ const boardListRaw = execSync(
393
+ `${getManagedArduinoCliPath()} board listall --format json`,
394
+ getManagedArduinoCliExecOptions({ encoding: 'utf8' })
395
+ );
392
396
  const boardList = JSON.parse(boardListRaw);
393
397
  const boards = Array.isArray(boardList.boards) ? boardList.boards : [];
394
398
 
@@ -24,19 +24,12 @@ import { parseStructuredText } from './st-parser/parser.js';
24
24
  import { transpile } from './st-parser/gcctranspiler.js';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { dirname } from 'node:path';
27
+ import { getDefaultToolchain, getToolchainRoot, isManagedZigCompiler } from '../toolchains.js';
27
28
 
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const __dirname = dirname(__filename);
30
31
 
31
- const DEFAULT_TOOLCHAIN = {
32
- "linux-arm": "arm-linux-gnueabi-g++",
33
- "linux-arm64": "aarch64-linux-gnu-g++",
34
- "linux-x64": "x86_64-linux-gnu-g++",
35
- "macos-arm64": "clang++",
36
- "macos-x64": "clang++",
37
- "windows-x64": "x86_64-w64-mingw32-g++",
38
- "windows-arm64": "/opt/llvm-mingw/bin/aarch64-w64-mingw32-g++"
39
- };
32
+ const DEFAULT_TOOLCHAIN = getDefaultToolchain();
40
33
 
41
34
  let ToolChain = { ...DEFAULT_TOOLCHAIN };
42
35
 
@@ -79,7 +72,7 @@ export class CPPCompiler extends Compiler {
79
72
  const isStructuredTextLanguage = String(language || '').toUpperCase() === IECLanguage.STRUCTURED_TEXT;
80
73
  const directoryBundleMode = sourceIsDirectory && isStructuredTextLanguage && typeof resourceName === 'string' && resourceName.trim().length > 0;
81
74
 
82
- ToolChain = { ...DEFAULT_TOOLCHAIN };
75
+ ToolChain = { ...getDefaultToolchain() };
83
76
  const sourceDir = sourceIsDirectory ? sourcePath : path.dirname(sourcePath);
84
77
  const toolchainConfigPath = path.join(sourceDir, "toolchain.json");
85
78
  if (fs.existsSync(toolchainConfigPath)) {
@@ -103,6 +96,7 @@ export class CPPCompiler extends Compiler {
103
96
  let bundleEntryProgram = '';
104
97
 
105
98
  if (directoryBundleMode) {
99
+ this.cleanupStructuredTextBundleArtifacts(sourcePath);
106
100
  const { combinedSource, entryProgramName } = this.loadStructuredTextBundle(sourcePath, resourceName);
107
101
  sourceCode = combinedSource;
108
102
  bundleEntryProgram = entryProgramName;
@@ -368,7 +362,12 @@ int main() {
368
362
  .forEach((dir) => searchDirs.add(dir));
369
363
  }
370
364
 
371
- const compilerPath = execSync(`which "${compiler}"`, { encoding: 'utf8' }).trim();
365
+ const compilerPath = path.isAbsolute(compiler)
366
+ ? compiler
367
+ : execSync(process.platform === 'win32' ? `where "${compiler}"` : `which "${compiler}"`, { encoding: 'utf8' })
368
+ .split(/\r?\n/)
369
+ .map((line) => line.trim())
370
+ .find(Boolean);
372
371
  if (compilerPath) {
373
372
  const compilerDir = path.dirname(compilerPath);
374
373
  searchDirs.add(compilerDir);
@@ -400,9 +399,7 @@ int main() {
400
399
  }
401
400
 
402
401
  loadStructuredTextBundle(sourcePath, resourceName) {
403
- const stFiles = fs.readdirSync(sourcePath, { withFileTypes: true })
404
- .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.st'))
405
- .map((entry) => entry.name);
402
+ const stFiles = this.listStructuredTextBundleFiles(sourcePath);
406
403
 
407
404
  if (stFiles.length === 0) {
408
405
  throw new Error(`No .st files found in source directory "${sourcePath}".`);
@@ -501,44 +498,28 @@ int main() {
501
498
  }
502
499
 
503
500
  detectCompiler(hostOs, hostArch, targetOs, targetArch) {
504
- const hostDefaults = {
505
- linux: "g++",
506
- macos: "clang++",
507
- windows: "cl.exe"
508
- };
509
- const hostKey = `${hostOs}-${hostArch}`;
510
501
  const targetKey = `${targetOs}-${targetArch}`;
511
502
 
512
503
  const ensureCompilerAvailable = (compilerName, message) => {
513
- const versionCommand = compilerName === "cl.exe" ? compilerName : `${compilerName} --version`;
514
504
  try {
515
- execSync(versionCommand, { stdio: "ignore" });
505
+ execSync(`"${compilerName}" --version`, { stdio: 'ignore' });
516
506
  } catch {
517
507
  throw new Error(message);
518
508
  }
519
509
  };
520
510
 
521
- if (targetKey === hostKey) {
522
- const defaultCompiler = hostDefaults[hostOs];
523
- if (!defaultCompiler) {
524
- throw new Error(`No default compiler configured for host platform ${hostOs}.`);
525
- }
526
- ensureCompilerAvailable(
527
- defaultCompiler,
528
- `The default compiler "${defaultCompiler}" is not available. Install it using your package manager (e.g., brew install ${defaultCompiler} or apt install ${defaultCompiler}).
529
- You can also create a file called "toolchain.json" in your source directory which will supply the path to the gnu c compiler for each platform. See the README file for more details.`
530
- );
531
- return defaultCompiler;
532
- }
533
-
534
511
  const configuredCompiler = ToolChain[targetKey];
535
512
  if (!configuredCompiler) {
536
- throw new Error(`No cross-compiler is configured for target ${targetKey}. Add it to toolchain.json or update the ToolChain defaults.`);
513
+ throw new Error(
514
+ `No toolchain is configured for target ${targetKey} on host ${hostOs}-${hostArch}. ` +
515
+ `Run "nodalis --action get-toolchains" or add an override in toolchain.json.`
516
+ );
537
517
  }
538
518
  ensureCompilerAvailable(
539
519
  configuredCompiler,
540
- `Cross-compiler "${configuredCompiler}" for target ${targetKey} is not available. Install it via your package manager (e.g., brew install ${configuredCompiler} or apt install ${configuredCompiler}).
541
- You can also create a file called "toolchain.json" in your source directory which will supply the path to the gnu c compiler for each platform. See the README file for more details.`
520
+ `Toolchain "${configuredCompiler}" for target ${targetKey} is not available. ` +
521
+ `Run "nodalis --action get-toolchains" to install managed toolchains under ${getToolchainRoot()}, ` +
522
+ `or add a custom compiler path in toolchain.json.`
542
523
  );
543
524
  return configuredCompiler;
544
525
  }
@@ -562,7 +543,7 @@ int main() {
562
543
  if (compiler === 'cl.exe') {
563
544
  return { c: [], cpp: [] };
564
545
  }
565
- let flags = { //default flags are for macos clang
546
+ let flags = { // default flags are for managed/native clang wrappers
566
547
  'linux-x64': [],
567
548
  'linux-arm64': [],
568
549
  'linux-arm': [],
@@ -582,7 +563,18 @@ int main() {
582
563
  "macos-arm64": "",
583
564
  }
584
565
 
585
- if (!compiler.includes("clang")) {
566
+ if (isManagedZigCompiler(compiler)) {
567
+ flags = {
568
+ 'linux-x64': [],
569
+ 'linux-arm64': [],
570
+ 'linux-arm': [],
571
+ 'macos-x64': [],
572
+ 'macos-arm64': [],
573
+ 'windows-x64': [],
574
+ 'windows-arm64': []
575
+ };
576
+ }
577
+ else if (!compiler.includes("clang")) {
586
578
  flags = {
587
579
  'linux-x64': ["-D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE -pthread"],
588
580
  'linux-arm64': ["-D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE -pthread"],
@@ -14,6 +14,7 @@
14
14
  // See the License for the specific language governing permissions and
15
15
  // limitations under the License.
16
16
 
17
+ import fs from 'fs';
17
18
  import path from 'path';
18
19
 
19
20
  export const IECLanguage = Object.freeze({
@@ -70,6 +71,26 @@ export class Compiler {
70
71
  return path.join(this.options.outputPath, 'bin');
71
72
  }
72
73
 
74
+ getStructuredTextBundleArtifactName() {
75
+ return 'nodalisplc.st';
76
+ }
77
+
78
+ cleanupStructuredTextBundleArtifacts(sourcePath) {
79
+ const artifactPath = path.join(sourcePath, this.getStructuredTextBundleArtifactName());
80
+ if (fs.existsSync(artifactPath) && fs.lstatSync(artifactPath).isFile()) {
81
+ fs.rmSync(artifactPath, { force: true });
82
+ }
83
+ }
84
+
85
+ listStructuredTextBundleFiles(sourcePath) {
86
+ const bundleArtifactName = this.getStructuredTextBundleArtifactName().toLowerCase();
87
+ return fs.readdirSync(sourcePath, { withFileTypes: true })
88
+ .filter((entry) => entry.isFile()
89
+ && entry.name.toLowerCase().endsWith('.st')
90
+ && entry.name.toLowerCase() !== bundleArtifactName)
91
+ .map((entry) => entry.name);
92
+ }
93
+
73
94
  /** @returns {string[]} */
74
95
  get supportedLanguages() {
75
96
  throw new Error('supportedLanguages must be implemented by subclass.');
@@ -65,6 +65,7 @@ export class JSCompiler extends Compiler {
65
65
  let sourceCode = '';
66
66
  let bundleEntryProgram = '';
67
67
  if (directoryBundleMode) {
68
+ this.cleanupStructuredTextBundleArtifacts(sourcePath);
68
69
  const { combinedSource, entryProgramName } = this.loadStructuredTextBundle(sourcePath, resourceName);
69
70
  sourceCode = combinedSource;
70
71
  bundleEntryProgram = entryProgramName;
@@ -314,9 +315,7 @@ export function run(){
314
315
  }
315
316
 
316
317
  JSCompiler.prototype.loadStructuredTextBundle = function (sourcePath, resourceName) {
317
- const stFiles = fs.readdirSync(sourcePath, { withFileTypes: true })
318
- .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.st'))
319
- .map((entry) => entry.name);
318
+ const stFiles = this.listStructuredTextBundleFiles(sourcePath);
320
319
 
321
320
  if (stFiles.length === 0) {
322
321
  throw new Error(`No .st files found in source directory "${sourcePath}".`);
@@ -0,0 +1,5 @@
1
+ export const DEFAULT_ARDUINO_FQBN = {
2
+ 'arduino-opta': 'arduino:mbed_opta:opta'
3
+ };
4
+
5
+ export const DEFAULT_ARDUINO_FQBNS = Object.values(DEFAULT_ARDUINO_FQBN);
@@ -671,19 +671,12 @@ template <typename T, typename... Ts>
671
671
  inline T MUX(std::size_t K, T in0, Ts... rest)
672
672
  {
673
673
  constexpr std::size_t N = 1 + sizeof...(Ts);
674
+ T values[N] = {in0, static_cast<T>(rest)...};
674
675
  if (K >= N)
675
676
  {
676
- throw std::out_of_range("MUX selector out of range");
677
+ return values[0];
677
678
  }
678
-
679
- auto values = std::tuple<T, Ts...>(in0, rest...);
680
- return std::apply(
681
- [K](auto... elems) -> T
682
- {
683
- T arr[] = {elems...};
684
- return arr[K];
685
- },
686
- values);
679
+ return values[K];
687
680
  };
688
681
 
689
682
  // ============================================================
package/src/nodalis.js CHANGED
@@ -30,6 +30,7 @@ import { FileProgrammer } from './programmers/FileProgrammer.js';
30
30
  import { SSHProgrammer } from './programmers/SSHProgrammer.js';
31
31
  import { ArduinoProgrammer } from './programmers/ArduinoProgrammer.js';
32
32
  import { CompileList } from "mticp-npm"
33
+ import { getToolchainRoot, installDefaultToolchains } from './toolchains.js';
33
34
 
34
35
  const __filename = fileURLToPath(import.meta.url);
35
36
  const __dirname = path.dirname(__filename);
@@ -172,6 +173,10 @@ export class Nodalis {
172
173
  }
173
174
  }
174
175
 
176
+ async getToolchains() {
177
+ return installDefaultToolchains();
178
+ }
179
+
175
180
  }
176
181
 
177
182
  function isCliEntryPoint() {
@@ -221,6 +226,11 @@ Actions:
221
226
  --sshPort Optional SSH port for SSH deployment.
222
227
  --arduinoFqbn Required for Arduino target if --target Arduino.
223
228
 
229
+ --action get-toolchains
230
+ Detects the host OS/arch and installs managed C/C++ toolchains,
231
+ arduino-cli, and default Arduino board cores under:
232
+ ${getToolchainRoot()}
233
+
224
234
  Examples:
225
235
  node nodalis.js --action list-compilers
226
236
 
@@ -231,6 +241,8 @@ Examples:
231
241
  --resourceName MyPLC \\
232
242
  --sourcePath ./examples/pump.iec \\
233
243
  --language st
244
+
245
+ node nodalis.js --action get-toolchains
234
246
  `);
235
247
  process.exit(0);
236
248
  }
@@ -296,9 +308,18 @@ Examples:
296
308
  break;
297
309
  }
298
310
 
311
+ case 'get-toolchains': {
312
+ app.getToolchains().then((result) => {
313
+ console.log(JSON.stringify(result, null, 2));
314
+ }).catch(err => {
315
+ console.error(`Toolchain installation failed: ${err.message}`);
316
+ });
317
+ break;
318
+ }
319
+
299
320
  default: {
300
321
  console.error(`Unknown or missing action: ${argMap.action}`);
301
- console.error(`Valid actions: list-compilers, list-programmers, compile, deploy`);
322
+ console.error(`Valid actions: list-compilers, list-programmers, compile, deploy, get-toolchains`);
302
323
  break;
303
324
  }
304
325
  }
@@ -16,6 +16,7 @@ import fs from 'fs/promises';
16
16
  import path from 'path';
17
17
  import { Programmer } from './Programmer.js';
18
18
  import { runCommand } from './utils.js';
19
+ import { getManagedArduinoCliExecOptions, getManagedArduinoCliPath } from '../toolchains.js';
19
20
 
20
21
  export class ArduinoProgrammer extends Programmer {
21
22
  constructor(options) {
@@ -58,7 +59,11 @@ export class ArduinoProgrammer extends Programmer {
58
59
  }
59
60
  }
60
61
 
61
- const { stdout, stderr } = await runCommand('arduino-cli', args);
62
+ const { stdout, stderr } = await runCommand(
63
+ getManagedArduinoCliPath(),
64
+ args,
65
+ getManagedArduinoCliExecOptions()
66
+ );
62
67
  if (stdout) {
63
68
  console.log(stdout.trim());
64
69
  }
@@ -0,0 +1,417 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import https from 'https';
5
+ import { execFileSync } from 'child_process';
6
+ import { pipeline } from 'stream/promises';
7
+ import { DEFAULT_ARDUINO_FQBNS } from './compilers/arduinoDefaults.js';
8
+
9
+ const ZIG_VERSION = '0.15.2';
10
+ const ARDUINO_CLI_VERSION = '1.3.1';
11
+ const TOOLCHAIN_ROOT = path.join(os.homedir(), '.nodalis', 'toolchains');
12
+ const WRAPPER_EXTENSION = process.platform === 'win32' ? '.cmd' : '';
13
+ const DEFAULT_ARDUINO_LIBRARIES = ['ArduinoModbus'];
14
+
15
+ const TARGET_TRIPLES = {
16
+ 'linux-arm': 'arm-linux-gnueabihf',
17
+ 'linux-arm64': 'aarch64-linux-gnu',
18
+ 'linux-x64': 'x86_64-linux-gnu',
19
+ 'macos-arm64': 'aarch64-macos',
20
+ 'macos-x64': 'x86_64-macos',
21
+ 'windows-arm64': 'aarch64-windows-gnu',
22
+ 'windows-x64': 'x86_64-windows-gnu'
23
+ };
24
+
25
+ const normalizeHostOS = (value) => {
26
+ if (value === 'win32') return 'windows';
27
+ if (value === 'darwin') return 'macos';
28
+ if (value === 'linux') return 'linux';
29
+ throw new Error(`Unsupported host operating system: ${value}`);
30
+ };
31
+
32
+ const normalizeHostArch = (value) => {
33
+ if (value === 'x64') return 'x64';
34
+ if (value === 'arm64') return 'arm64';
35
+ if (value.startsWith('arm')) return 'arm';
36
+ throw new Error(`Unsupported host architecture: ${value}`);
37
+ };
38
+
39
+ const stripArchiveExtension = (archiveName) => {
40
+ if (archiveName.endsWith('.tar.gz')) return archiveName.slice(0, -7);
41
+ if (archiveName.endsWith('.tar.xz')) return archiveName.slice(0, -7);
42
+ if (archiveName.endsWith('.zip')) return archiveName.slice(0, -4);
43
+ return archiveName;
44
+ };
45
+
46
+ const getWrapperDir = () => path.join(TOOLCHAIN_ROOT, 'zig', ZIG_VERSION, 'wrappers');
47
+
48
+ const getWrapperPath = (target) => path.join(getWrapperDir(), `zig-${target}-c++${WRAPPER_EXTENSION}`);
49
+
50
+ const getArduinoCliRootDir = () => path.join(TOOLCHAIN_ROOT, 'arduino-cli', ARDUINO_CLI_VERSION);
51
+
52
+ const getArduinoCliDataDir = () => path.join(getArduinoCliRootDir(), 'data');
53
+
54
+ const getArduinoCliDownloadsDir = () => path.join(getArduinoCliRootDir(), 'downloads');
55
+
56
+ const getArduinoCliUserDir = () => path.join(getArduinoCliRootDir(), 'user');
57
+
58
+ const getZigInstallDir = (host = detectHostPlatform()) => {
59
+ const archiveName = getZigArchiveName(host);
60
+ return path.join(TOOLCHAIN_ROOT, 'zig', ZIG_VERSION, stripArchiveExtension(archiveName));
61
+ };
62
+
63
+ const getZigBinaryPath = (host = detectHostPlatform()) => {
64
+ const installDir = getZigInstallDir(host);
65
+ return path.join(installDir, process.platform === 'win32' ? 'zig.exe' : 'zig');
66
+ };
67
+
68
+ const getArduinoCliArchiveName = (host = detectHostPlatform()) => {
69
+ if (host.os === 'windows') {
70
+ const archSegment = host.arch === 'arm64' ? 'Windows_ARM64' : host.arch === 'x64' ? 'Windows_64bit' : null;
71
+ if (!archSegment) {
72
+ throw new Error(`Unsupported Windows host architecture for arduino-cli: ${host.arch}`);
73
+ }
74
+ return `arduino-cli_${ARDUINO_CLI_VERSION}_${archSegment}.zip`;
75
+ }
76
+
77
+ if (host.os === 'macos') {
78
+ const archSegment = host.arch === 'arm64' ? 'macOS_ARM64' : host.arch === 'x64' ? 'macOS_64bit' : null;
79
+ if (!archSegment) {
80
+ throw new Error(`Unsupported macOS host architecture for arduino-cli: ${host.arch}`);
81
+ }
82
+ return `arduino-cli_${ARDUINO_CLI_VERSION}_${archSegment}.tar.gz`;
83
+ }
84
+
85
+ if (host.os === 'linux') {
86
+ if (host.arch === 'x64') return `arduino-cli_${ARDUINO_CLI_VERSION}_Linux_64bit.tar.gz`;
87
+ if (host.arch === 'arm64') return `arduino-cli_${ARDUINO_CLI_VERSION}_Linux_ARM64.tar.gz`;
88
+ if (host.arch === 'arm') return `arduino-cli_${ARDUINO_CLI_VERSION}_Linux_ARMv7.tar.gz`;
89
+ }
90
+
91
+ throw new Error(`Unsupported arduino-cli host platform ${host.os}-${host.arch}`);
92
+ };
93
+
94
+ const getArduinoCliInstallDir = (host = detectHostPlatform()) => {
95
+ return path.join(getArduinoCliRootDir(), `${host.os}-${host.arch}`);
96
+ };
97
+
98
+ export const getManagedArduinoCliPath = (host = detectHostPlatform()) => {
99
+ const installDir = getArduinoCliInstallDir(host);
100
+ return path.join(installDir, host.os === 'windows' ? 'arduino-cli.exe' : 'arduino-cli');
101
+ };
102
+
103
+ const getSupportedTargetsForHost = (host = detectHostPlatform()) => {
104
+ const targets = ['linux-arm', 'linux-arm64', 'linux-x64', 'windows-arm64', 'windows-x64'];
105
+ if (host.os === 'macos') {
106
+ targets.push('macos-arm64', 'macos-x64');
107
+ }
108
+ return targets;
109
+ };
110
+
111
+ const getZigArchiveName = (host = detectHostPlatform()) => {
112
+ if (host.os === 'windows') {
113
+ const archSegment = host.arch === 'arm64' ? 'aarch64' : host.arch === 'x64' ? 'x86_64' : null;
114
+ if (!archSegment) {
115
+ throw new Error(`Unsupported Windows host architecture for Zig: ${host.arch}`);
116
+ }
117
+ return `zig-${archSegment}-windows-${ZIG_VERSION}.zip`;
118
+ }
119
+
120
+ if (host.os === 'macos') {
121
+ const archSegment = host.arch === 'arm64' ? 'aarch64' : host.arch === 'x64' ? 'x86_64' : null;
122
+ if (!archSegment) {
123
+ throw new Error(`Unsupported macOS host architecture for Zig: ${host.arch}`);
124
+ }
125
+ return `zig-${archSegment}-macos-${ZIG_VERSION}.tar.xz`;
126
+ }
127
+
128
+ if (host.os === 'linux') {
129
+ if (host.arch === 'x64') return `zig-x86_64-linux-${ZIG_VERSION}.tar.xz`;
130
+ if (host.arch === 'arm64') return `zig-aarch64-linux-${ZIG_VERSION}.tar.xz`;
131
+ if (host.arch === 'arm') return `zig-arm-linux-${ZIG_VERSION}.tar.xz`;
132
+ }
133
+
134
+ throw new Error(`Unsupported Zig host platform ${host.os}-${host.arch}`);
135
+ };
136
+
137
+ const writeWrapper = (wrapperPath, zigBinaryPath, targetTriple) => {
138
+ fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
139
+
140
+ if (process.platform === 'win32') {
141
+ const windowsScript = [
142
+ '@echo off',
143
+ `"%~dp0..\\${path.basename(path.dirname(zigBinaryPath))}\\zig.exe" c++ -target ${targetTriple} %*`
144
+ ].join('\r\n');
145
+ fs.writeFileSync(wrapperPath, windowsScript);
146
+ return;
147
+ }
148
+
149
+ const posixScript = [
150
+ '#!/bin/sh',
151
+ `exec "${zigBinaryPath}" c++ -target ${targetTriple} "$@"`
152
+ ].join('\n');
153
+ fs.writeFileSync(wrapperPath, posixScript, { mode: 0o755 });
154
+ fs.chmodSync(wrapperPath, 0o755);
155
+ };
156
+
157
+ const ensureWrappers = (host = detectHostPlatform()) => {
158
+ const zigBinaryPath = getZigBinaryPath(host);
159
+ for (const target of getSupportedTargetsForHost(host)) {
160
+ const targetTriple = TARGET_TRIPLES[target];
161
+ if (!targetTriple) continue;
162
+ writeWrapper(getWrapperPath(target), zigBinaryPath, targetTriple);
163
+ }
164
+ };
165
+
166
+ export const detectHostPlatform = () => ({
167
+ os: normalizeHostOS(os.platform()),
168
+ arch: normalizeHostArch(os.arch())
169
+ });
170
+
171
+ export const getToolchainRoot = () => TOOLCHAIN_ROOT;
172
+
173
+ export const getManagedArduinoCliExecOptions = (overrides = {}) => ({
174
+ ...overrides,
175
+ env: {
176
+ ...process.env,
177
+ ARDUINO_DIRECTORIES_DATA: getArduinoCliDataDir(),
178
+ ARDUINO_DIRECTORIES_DOWNLOADS: getArduinoCliDownloadsDir(),
179
+ ARDUINO_DIRECTORIES_USER: getArduinoCliUserDir(),
180
+ ...(overrides.env || {})
181
+ }
182
+ });
183
+
184
+ export const isManagedZigCompiler = (compilerPath) => String(compilerPath || '').includes(`${path.sep}.nodalis${path.sep}toolchains${path.sep}zig${path.sep}`);
185
+
186
+ export const getDefaultToolchain = (host = detectHostPlatform()) => {
187
+ return getSupportedTargetsForHost(host).reduce((acc, target) => {
188
+ acc[target] = getWrapperPath(target);
189
+ return acc;
190
+ }, {});
191
+ };
192
+
193
+ const downloadFile = async (url, destinationPath) => {
194
+ await new Promise((resolve, reject) => {
195
+ const request = https.get(url, (response) => {
196
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
197
+ response.resume();
198
+ downloadFile(response.headers.location, destinationPath).then(resolve).catch(reject);
199
+ return;
200
+ }
201
+
202
+ if (response.statusCode !== 200) {
203
+ response.resume();
204
+ reject(new Error(`Download failed with HTTP ${response.statusCode} for ${url}`));
205
+ return;
206
+ }
207
+
208
+ const fileStream = fs.createWriteStream(destinationPath);
209
+ pipeline(response, fileStream).then(resolve).catch(reject);
210
+ });
211
+
212
+ request.on('error', reject);
213
+ });
214
+ };
215
+
216
+ const extractArchive = (archivePath, destinationDir) => {
217
+ fs.mkdirSync(destinationDir, { recursive: true });
218
+
219
+ if (archivePath.endsWith('.zip')) {
220
+ if (process.platform !== 'win32') {
221
+ throw new Error(`ZIP extraction is only configured for Windows hosts: ${archivePath}`);
222
+ }
223
+
224
+ execFileSync(
225
+ 'powershell.exe',
226
+ [
227
+ '-NoProfile',
228
+ '-NonInteractive',
229
+ '-Command',
230
+ `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`
231
+ ],
232
+ { stdio: 'inherit' }
233
+ );
234
+ return;
235
+ }
236
+
237
+ execFileSync('tar', ['-xf', archivePath, '-C', destinationDir], { stdio: 'inherit' });
238
+ };
239
+
240
+ const ensureArduinoCliDirs = () => {
241
+ fs.mkdirSync(getArduinoCliRootDir(), { recursive: true });
242
+ fs.mkdirSync(getArduinoCliDataDir(), { recursive: true });
243
+ fs.mkdirSync(getArduinoCliDownloadsDir(), { recursive: true });
244
+ fs.mkdirSync(getArduinoCliUserDir(), { recursive: true });
245
+ };
246
+
247
+ const installDefaultArduinoCores = (arduinoCliPath) => {
248
+ execFileSync(arduinoCliPath, ['core', 'update-index'], getManagedArduinoCliExecOptions({ stdio: 'inherit' }));
249
+
250
+ const coreIds = [...new Set(
251
+ DEFAULT_ARDUINO_FQBNS.map((fqbn) => {
252
+ const parts = String(fqbn).split(':');
253
+ return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : null;
254
+ }).filter(Boolean)
255
+ )];
256
+
257
+ for (const coreId of coreIds) {
258
+ execFileSync(arduinoCliPath, ['core', 'install', coreId], getManagedArduinoCliExecOptions({ stdio: 'inherit' }));
259
+ }
260
+ };
261
+
262
+ const installDefaultArduinoLibraries = (arduinoCliPath) => {
263
+ for (const libraryName of DEFAULT_ARDUINO_LIBRARIES) {
264
+ execFileSync(
265
+ arduinoCliPath,
266
+ ['lib', 'install', libraryName],
267
+ getManagedArduinoCliExecOptions({ stdio: 'inherit' })
268
+ );
269
+ }
270
+ };
271
+
272
+ export const installDefaultToolchains = async () => {
273
+ const host = detectHostPlatform();
274
+ const archiveName = getZigArchiveName(host);
275
+ const rootDir = path.join(TOOLCHAIN_ROOT, 'zig', ZIG_VERSION);
276
+ const archivePath = path.join(rootDir, archiveName);
277
+ const installDir = getZigInstallDir(host);
278
+ const zigBinaryPath = getZigBinaryPath(host);
279
+
280
+ fs.mkdirSync(rootDir, { recursive: true });
281
+
282
+ const results = [];
283
+ if (!fs.existsSync(zigBinaryPath)) {
284
+ const downloadUrl = `https://ziglang.org/download/${ZIG_VERSION}/${archiveName}`;
285
+
286
+ try {
287
+ await downloadFile(downloadUrl, archivePath);
288
+ extractArchive(archivePath, rootDir);
289
+ fs.rmSync(archivePath, { force: true });
290
+ results.push({
291
+ id: 'zig',
292
+ status: 'installed',
293
+ installDir,
294
+ downloadUrl
295
+ });
296
+ } catch (err) {
297
+ fs.rmSync(archivePath, { force: true });
298
+ results.push({
299
+ id: 'zig',
300
+ status: 'failed',
301
+ downloadUrl,
302
+ error: err.message
303
+ });
304
+ return {
305
+ host,
306
+ rootDir: TOOLCHAIN_ROOT,
307
+ toolchains: results,
308
+ defaultToolchain: getDefaultToolchain(host)
309
+ };
310
+ }
311
+ } else {
312
+ results.push({
313
+ id: 'zig',
314
+ status: 'already-installed',
315
+ installDir
316
+ });
317
+ }
318
+
319
+ ensureWrappers(host);
320
+
321
+ const arduinoArchiveName = getArduinoCliArchiveName(host);
322
+ const arduinoRootDir = getArduinoCliRootDir();
323
+ const arduinoArchivePath = path.join(arduinoRootDir, arduinoArchiveName);
324
+ const arduinoInstallDir = getArduinoCliInstallDir(host);
325
+ const arduinoCliPath = getManagedArduinoCliPath(host);
326
+
327
+ ensureArduinoCliDirs();
328
+
329
+ if (!fs.existsSync(arduinoCliPath)) {
330
+ const downloadUrl = `https://downloads.arduino.cc/arduino-cli/${arduinoArchiveName}`;
331
+
332
+ try {
333
+ await downloadFile(downloadUrl, arduinoArchivePath);
334
+ fs.mkdirSync(arduinoInstallDir, { recursive: true });
335
+ extractArchive(arduinoArchivePath, arduinoInstallDir);
336
+ fs.rmSync(arduinoArchivePath, { force: true });
337
+ results.push({
338
+ id: 'arduino-cli',
339
+ status: 'installed',
340
+ installDir: arduinoInstallDir,
341
+ downloadUrl
342
+ });
343
+ } catch (err) {
344
+ fs.rmSync(arduinoArchivePath, { force: true });
345
+ results.push({
346
+ id: 'arduino-cli',
347
+ status: 'failed',
348
+ downloadUrl,
349
+ error: err.message
350
+ });
351
+ return {
352
+ host,
353
+ rootDir: TOOLCHAIN_ROOT,
354
+ toolchains: results,
355
+ defaultToolchain: getDefaultToolchain(host),
356
+ arduinoCli: arduinoCliPath,
357
+ defaultArduinoFqbns: DEFAULT_ARDUINO_FQBNS
358
+ };
359
+ }
360
+ } else {
361
+ results.push({
362
+ id: 'arduino-cli',
363
+ status: 'already-installed',
364
+ installDir: arduinoInstallDir
365
+ });
366
+ }
367
+
368
+ try {
369
+ installDefaultArduinoCores(arduinoCliPath);
370
+ results.push({
371
+ id: 'arduino-default-cores',
372
+ status: 'installed',
373
+ fqbns: DEFAULT_ARDUINO_FQBNS
374
+ });
375
+ } catch (err) {
376
+ results.push({
377
+ id: 'arduino-default-cores',
378
+ status: 'failed',
379
+ fqbns: DEFAULT_ARDUINO_FQBNS,
380
+ error: err.message
381
+ });
382
+ }
383
+
384
+ try {
385
+ installDefaultArduinoLibraries(arduinoCliPath);
386
+ results.push({
387
+ id: 'arduino-default-libraries',
388
+ status: 'installed',
389
+ libraries: DEFAULT_ARDUINO_LIBRARIES
390
+ });
391
+ } catch (err) {
392
+ results.push({
393
+ id: 'arduino-default-libraries',
394
+ status: 'failed',
395
+ libraries: DEFAULT_ARDUINO_LIBRARIES,
396
+ error: err.message
397
+ });
398
+ }
399
+
400
+ return {
401
+ host,
402
+ rootDir: TOOLCHAIN_ROOT,
403
+ toolchains: [
404
+ ...results,
405
+ ...getSupportedTargetsForHost(host).map((target) => ({
406
+ id: target,
407
+ status: 'ready',
408
+ compiler: getWrapperPath(target),
409
+ targetTriple: TARGET_TRIPLES[target]
410
+ }))
411
+ ],
412
+ defaultToolchain: getDefaultToolchain(host),
413
+ arduinoCli: arduinoCliPath,
414
+ defaultArduinoFqbns: DEFAULT_ARDUINO_FQBNS,
415
+ defaultArduinoLibraries: DEFAULT_ARDUINO_LIBRARIES
416
+ };
417
+ };