From a60528fb5b5f1944046e2e1a20e4501a198fd069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 9 Jun 2026 11:40:40 +0200 Subject: [PATCH 1/9] feat(vpp): package prebuilt-object plugins for upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supports runtime-v4 native plugins shipped as precompiled objects (hal.provisioning === "prebuilt", Option C of the packages repo). In prebuilt mode hal.pluginEntry is the plugin DIRECTORY (holding the .o objects + link-only Makefile) rather than an entry source file, so handleVendorPluginPackaging resolves the dir directly instead of via dirname(). The existing collectAndCopy + checksum then copy the .o + Makefile into vpp_plugin/ unchanged (config_template is still excluded), and the runtime links them as it already does for sources. - types.ts: add optional hal.provisioning and hal.minRuntimeVersion (the zod transport schema stays untouched — it is passthrough by design). - compiler-module.ts: branch the plugin-dir resolution on provisioning; mode-aware success log. No change for existing source packages (provisioning absent => source). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/editor/compiler/compiler-module.ts | 13 +++++++++---- src/middleware/shared/ports/types.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 08f376ef1..789ccfb65 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2243,19 +2243,24 @@ class CompilerModule { handleOutputData('VPP board has no HAL configTemplate, skipping plugin config generation', 'info') } - // --- Step 2: Copy plugin source + generate checksum --- + // --- Step 2: Copy plugin payload + generate checksum --- const pluginEntryRelPath = matchingDevice.hal?.pluginEntry if (!pluginEntryRelPath) { handleOutputData('VPP board has no HAL pluginEntry, skipping plugin source upload', 'info') return } - // The plugin source directory is the parent directory of pluginEntry. + // Resolve the plugin directory. In "source" mode (default) pluginEntry is + // the entry source file, so the dir is its parent. In "prebuilt" mode + // (provisioning === 'prebuilt') pluginEntry is the directory itself, + // holding the precompiled .o objects plus the link-only Makefile. // pluginEntryRelPath is supplied by the package manifest; without // containment, an entry like `../../../etc` would resolve outside // matchingPackagePath and the recursive-copy below would slurp // arbitrary host files into the build's vpp_plugin directory. - const pluginSourceDir = join(matchingPackagePath, path.dirname(pluginEntryRelPath)) + const isPrebuilt = matchingDevice.hal?.provisioning === 'prebuilt' + const pluginDirRelPath = isPrebuilt ? pluginEntryRelPath : path.dirname(pluginEntryRelPath) + const pluginSourceDir = join(matchingPackagePath, pluginDirRelPath) try { assertPathContained(matchingPackagePath, pluginSourceDir, 'matchingDevice.hal.pluginEntry') } catch (err) { @@ -2346,7 +2351,7 @@ class CompilerModule { await writeFile(join(destPluginDir, 'checksum.sha256'), combinedHash + '\n', 'utf-8') handleOutputData( - `Copied ${copiedFiles.length} VPP plugin source file(s) to vpp_plugin/ (checksum: ${combinedHash.slice(0, 12)}...)`, + `Copied ${copiedFiles.length} VPP plugin ${isPrebuilt ? 'prebuilt' : 'source'} file(s) to vpp_plugin/ (checksum: ${combinedHash.slice(0, 12)}...)`, 'info', ) } catch (error) { diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index f7113c518..1489c01fb 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -743,8 +743,17 @@ export interface PackageManifest { hal: { type: string pluginType?: string + /** + * Native runtime-v4 plugin provisioning. "source" (default when absent): + * pluginEntry is the entry source file and its directory is compiled on + * the runtime. "prebuilt": pluginEntry is the directory holding the + * precompiled .o objects plus a link-only Makefile; the runtime only links. + */ + provisioning?: string pluginEntry?: string configTemplate?: string + /** Minimum runtime version the prebuilt objects are ABI-compatible with. */ + minRuntimeVersion?: string requirements?: string source?: string compilerFlags?: { From bd3c00766b271ef197027de5292438d3dd367edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 10 Jun 2026 14:05:33 +0200 Subject: [PATCH 2/9] refactor(vpp): drop unused minRuntimeVersion, test prebuilt packaging branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prebuilt-object plugin support didn't require any runtime change, so the minRuntimeVersion ABI floor was never consumed — drop the field from the shared PackageManifest.hal type (provisioning stays). Add a direct test for handleVendorPluginPackaging's prebuilt-vs-source provisioning branch (pluginEntry as directory vs file), exclusion of editor-only files, and the deterministic checksum. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handle-vendor-plugin-packaging.test.ts | 159 ++++++++++++++++++ src/middleware/shared/ports/types.ts | 2 - 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts diff --git a/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts new file mode 100644 index 000000000..209eb06f7 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/handle-vendor-plugin-packaging.test.ts @@ -0,0 +1,159 @@ +/** + * `CompilerModule.handleVendorPluginPackaging` — prebuilt vs source + * provisioning branch. + * + * The packager treats `hal.pluginEntry` differently depending on + * `hal.provisioning`: + * - "prebuilt": pluginEntry IS the directory holding the precompiled + * `.o` objects + link-only Makefile — copied verbatim. + * - source (default / absent): pluginEntry is the entry source FILE, + * so the directory to copy is its parent. + * + * We drive the real method against a temp filesystem, mocking only the + * package manager (which board/manifest it sees) and electron (so the + * module import doesn't try to reach the Electron app at load time). + * The method doesn't touch `this`, so we invoke it via the prototype + * and skip the constructor entirely. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +// The module calls `electronApp.getPath(...)` in its constructor; we never +// construct it here, but the top-level `import electron` still has to resolve. +jest.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + dialog: {}, + MessageChannelMain: class {}, +})) + +const listInstalled = jest.fn() +const getInstalledPackageManifest = jest.fn() +jest.mock('../../package-manager', () => ({ + PackageManagerModule: jest.fn().mockImplementation(() => ({ + listInstalled, + getInstalledPackageManifest, + })), +})) + +// eslint-disable-next-line import/first +import { CompilerModule } from '../compiler-module' + +type LogEntry = { message: string; level: string } + +const BOARD = 'Raspberry Pi (prebuilt test)' + +const handler = CompilerModule.prototype.handleVendorPluginPackaging + +function makeManifest(hal: Record) { + return { + devices: [ + { + name: BOARD, + target: { type: 'runtime-v4' }, + hal, + moduleSystem: undefined, + }, + ], + } +} + +/** Writes a plugin directory with two payload files + an excluded one. */ +function writePluginDir(pkgDir: string): string { + const pluginDir = join(pkgDir, 'hal', 'runtime-v4', 'plugin') + mkdirSync(pluginDir, { recursive: true }) + writeFileSync(join(pluginDir, 'rpi_plugin.o'), 'OBJECT-BYTES') + writeFileSync(join(pluginDir, 'Makefile'), 'all:\n\techo link\n') + // Excluded by the packager — must not be copied into vpp_plugin/. + writeFileSync(join(pluginDir, 'config_template.json'), JSON.stringify({ plugin_name: 'rpi_gpio', pins: [] })) + return pluginDir +} + +describe('handleVendorPluginPackaging — provisioning branch', () => { + let pkgDir: string + let projectDir: string + let targetDir: string + let logs: LogEntry[] + + const runFor = (hal: Record) => { + listInstalled.mockReturnValue([{ packageId: 'com.openplc.rpi', path: pkgDir }]) + getInstalledPackageManifest.mockReturnValue(makeManifest(hal)) + return handler.call( + {} as CompilerModule, + BOARD, + projectDir, + targetDir, + (message: string | Buffer, level?: string) => { + logs.push({ message: String(message), level: level ?? '' }) + }, + ) + } + + beforeEach(() => { + jest.clearAllMocks() + pkgDir = mkdtempSync(join(tmpdir(), 'vpp-pkg-')) + projectDir = mkdtempSync(join(tmpdir(), 'vpp-proj-')) + targetDir = mkdtempSync(join(tmpdir(), 'vpp-target-')) + logs = [] + writePluginDir(pkgDir) + }) + + afterEach(() => { + for (const dir of [pkgDir, projectDir, targetDir]) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('treats pluginEntry as a directory when provisioning is "prebuilt"', async () => { + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const dest = join(targetDir, 'vpp_plugin') + expect(existsSync(join(dest, 'rpi_plugin.o'))).toBe(true) + expect(existsSync(join(dest, 'Makefile'))).toBe(true) + // Excluded file is never copied. + expect(existsSync(join(dest, 'config_template.json'))).toBe(false) + // Deterministic integrity checksum is emitted. + expect(existsSync(join(dest, 'checksum.sha256'))).toBe(true) + // The summary log distinguishes the prebuilt path. + expect(logs.some((l) => /prebuilt file\(s\)/.test(l.message))).toBe(true) + }) + + it('treats pluginEntry as a file and copies its parent dir in source mode (provisioning absent)', async () => { + // Source-mode pluginEntry points at the entry FILE; the directory to copy + // is its parent — the same plugin dir, reached via dirname(). + writeFileSync(join(pkgDir, 'hal', 'runtime-v4', 'plugin', 'rpi_plugin.c'), 'int main(){}') + + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + pluginEntry: 'hal/runtime-v4/plugin/rpi_plugin.c', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const dest = join(targetDir, 'vpp_plugin') + expect(existsSync(join(dest, 'rpi_plugin.c'))).toBe(true) + expect(existsSync(join(dest, 'Makefile'))).toBe(true) + expect(existsSync(join(dest, 'config_template.json'))).toBe(false) + expect(logs.some((l) => /source file\(s\)/.test(l.message))).toBe(true) + }) + + it('copies the payload byte-for-byte (prebuilt object content preserved)', async () => { + await runFor({ + type: 'runtime-v4-plugin', + pluginType: 'native', + provisioning: 'prebuilt', + pluginEntry: 'hal/runtime-v4/plugin', + configTemplate: 'hal/runtime-v4/plugin/config_template.json', + }) + + const copied = readFileSync(join(targetDir, 'vpp_plugin', 'rpi_plugin.o'), 'utf-8') + expect(copied).toBe('OBJECT-BYTES') + }) +}) diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 1489c01fb..bc56d00f9 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -752,8 +752,6 @@ export interface PackageManifest { provisioning?: string pluginEntry?: string configTemplate?: string - /** Minimum runtime version the prebuilt objects are ABI-compatible with. */ - minRuntimeVersion?: string requirements?: string source?: string compilerFlags?: { From 648da220b4e9bb485ecff271b80dab9d9b212409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 17 Jun 2026 11:23:14 +0200 Subject: [PATCH 3/9] feat(compile): support arduino prebuilt mixed VPPs (source + precompiled lib) Wire the editor compile path for prebuilt arduino-hal VPPs, which ship an open hal.source integration layer (compiled locally, owns the OpenPLC contract and defines.h/vpp_config.h coupling) plus a precompiled vendor library linked via --library. - types: PackageManifest gains target.coreVersion and hal.precompiledLibrary - board-info-resolver: BoardBuildInfo carries coreVersion/precompiledLibraryDir; #fromVppDevice maps them from the manifest - resolve-board-selection: forward both fields onto boardEntry - pipeline: pass coreVersion to installArduinoCore and precompiledLibraryDir as prebuiltLibraryPath to the arduino-cli arg builder - build-arduino-cli-args: emit a 2nd --library when prebuiltLibraryPath is set - compiler-platform-port + adapter: thread coreVersion through - compiler-module: handleCoreInstallation installs core@version (pins and verifies the ABI-locked core; fails on mismatch) The source layer still compiles through the existing arduino-cli path, so it sees defines.h/vpp_config.h; no runtime pin-config shim and no generate-defines marker. Co-Authored-By: Claude Opus 4.8 --- src/backend/editor/compiler/compiler-module.ts | 14 ++++++++++++-- .../compiler/editor-compiler-platform-port.ts | 12 ++++++++---- src/backend/shared/compile/pipeline.ts | 17 ++++++++++++++++- .../compile/steps/resolve-board-selection.ts | 5 +++++ .../shared/firmware/build-arduino-cli-args.ts | 14 +++++++++++++- .../shared/hardware/board-info-resolver.ts | 9 +++++++++ .../shared/ports/compiler-platform-port.ts | 5 +++++ src/middleware/shared/ports/types.ts | 13 +++++++++++++ 8 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 789ccfb65..9c21005be 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1036,15 +1036,25 @@ class CompilerModule { async handleCoreInstallation( boardCore: string | null, handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + coreVersion?: string, ) { if (boardCore === null) return const isCoreInstalled = Object.keys(await this.getArduinoInstalledCores()).some((core) => core === boardCore) - if (isCoreInstalled) { + // Without a pinned version, any installed version is fine — skip the install. + // With a pinned version (prebuilt arduino libraries are ABI-locked to it), + // always run `core install @`: arduino-cli installs exactly that + // version (and fails if it does not exist), which both pins and verifies it. + if (!coreVersion && isCoreInstalled) { handleOutputData(`Core ${boardCore} is already installed.`, 'info') return } + const coreRef = coreVersion ? `${boardCore}@${coreVersion}` : boardCore + if (coreVersion) { + handleOutputData(`Installing pinned core ${coreRef} (required by a prebuilt library)...`, 'info') + } + let binaryPath = this.arduinoCliBinaryPath if (CompilerModule.HOST_PLATFORM === 'win32') { @@ -1052,7 +1062,7 @@ class CompilerModule { binaryPath += '.exe' } return new Promise>((resolve, reject) => { - const executeCommand = spawn(binaryPath, ['core', 'install', boardCore, ...this.arduinoCliBaseParameters]) + const executeCommand = spawn(binaryPath, ['core', 'install', coreRef, ...this.arduinoCliBaseParameters]) let stderrData = '' diff --git a/src/backend/editor/compiler/editor-compiler-platform-port.ts b/src/backend/editor/compiler/editor-compiler-platform-port.ts index fe2b39aeb..e5e184d8a 100644 --- a/src/backend/editor/compiler/editor-compiler-platform-port.ts +++ b/src/backend/editor/compiler/editor-compiler-platform-port.ts @@ -239,10 +239,14 @@ export function createEditorCompilerPlatformPort( */ async installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise { try { - await handlers.handleCoreInstallation(args.coreId, (chunk, level) => { - const message = typeof chunk === 'string' ? chunk : chunk.toString() - log(message, level ?? 'info') - }) + await handlers.handleCoreInstallation( + args.coreId, + (chunk, level) => { + const message = typeof chunk === 'string' ? chunk : chunk.toString() + log(message, level ?? 'info') + }, + args.coreVersion, + ) return { ok: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index 10548e971..4e5d3fbec 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -104,6 +104,14 @@ export interface BoardHalsBuildEntry extends BoardHalsCompileEntry { * install fires only when that board is selected. Boards that * don't need a specific library never download it. */ extra_libraries?: string[] + /** Prebuilt arduino-hal (provisioning="prebuilt"): the precompiled Arduino + * library dir, linked via a 2nd `--library`. Present only for arduino + * prebuilt boards (the `source` HAL still compiles as the integration layer). + * Sourced from the VPP manifest `device.hal.precompiledLibrary`. */ + precompiledLibraryDir?: string + /** Exact Arduino core version to install/verify before linking a prebuilt + * arduino library (ABI-locked). From the VPP manifest `target.coreVersion`. */ + coreVersion?: string /** Compiler / runtime identifier (`'arduino-cli' | 'openplc-compiler' * | 'simulator'`). Used by `resolveTargetCapabilities`'s * preset lookup — without this the resolver can't pick the right @@ -643,7 +651,11 @@ async function runCompilePipelineInner( // --------------------------------------------------------------------- emit({ stage: 'core-install', message: 'Installing Arduino core...', level: 'info' }) const coreInstall = await port.installArduinoCore( - { coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '' }, + { + coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '', + // Pin the exact core version for prebuilt arduino libraries (ABI-locked). + ...(boardEntry.coreVersion ? { coreVersion: boardEntry.coreVersion } : {}), + }, makePlatformLog(emit, 'core-install'), ) if (!coreInstall.ok) { @@ -721,6 +733,9 @@ async function runCompilePipelineInner( libraryPath: 'src', avrLibStdCppInclude, parallel: arduinoCliParallel, + // Prebuilt arduino-hal: link the precompiled vendor library alongside the + // source integration layer. arduino-cli accepts a 2nd --library. + ...(boardEntry.precompiledLibraryDir ? { prebuiltLibraryPath: boardEntry.precompiledLibraryDir } : {}), }) // Run arduino-cli compile. Editor: spawns the binary. Web: HTTP diff --git a/src/backend/shared/compile/steps/resolve-board-selection.ts b/src/backend/shared/compile/steps/resolve-board-selection.ts index 0582d7b05..492055486 100644 --- a/src/backend/shared/compile/steps/resolve-board-selection.ts +++ b/src/backend/shared/compile/steps/resolve-board-selection.ts @@ -63,6 +63,11 @@ export function resolveBoardSelection(resolver: BoardInfoResolver, boardTarget: ...(boardInfo.extraArduinoLibraries && boardInfo.extraArduinoLibraries.length > 0 ? { extra_libraries: boardInfo.extraArduinoLibraries } : {}), + // Prebuilt arduino-hal (mixed): the precompiled vendor library to link + // (2nd --library) + the ABI-locked core version to install/verify. Both + // come from the VPP manifest via BoardBuildInfo; absent for source boards. + ...(boardInfo.precompiledLibraryDir ? { precompiledLibraryDir: boardInfo.precompiledLibraryDir } : {}), + ...(boardInfo.coreVersion ? { coreVersion: boardInfo.coreVersion } : {}), // Capability resolution inputs. `resolveTargetCapabilities` // reads `compiler` + `vpp` + `capabilities` on whatever board // shape it's handed — without forwarding all three the diff --git a/src/backend/shared/firmware/build-arduino-cli-args.ts b/src/backend/shared/firmware/build-arduino-cli-args.ts index e07b80b9e..f63098f8b 100644 --- a/src/backend/shared/firmware/build-arduino-cli-args.ts +++ b/src/backend/shared/firmware/build-arduino-cli-args.ts @@ -30,6 +30,14 @@ export interface BuildArduinoCliCompileArgsOptions { sketchPath: string /** Directory passed via `--library` (contains generated.cpp, runtime headers, etc.). */ libraryPath: string + /** + * Optional 2nd `--library`: a prebuilt arduino-hal's precompiled Arduino + * library (provisioning="prebuilt"). arduino-cli accepts multiple --library + * and links the precompiled `.a` (library.properties precompiled=true) found + * under it. The source integration layer (hal.source) is compiled from the + * main libraryPath as usual. + */ + prebuiltLibraryPath?: string /** * Filesystem path to the avr-libstdcpp include directory. Appended * as `-I` onto `compiler.cpp.extra_flags` when the board's @@ -93,7 +101,11 @@ export function buildArduinoCliCompileArgs( args.push('--build-property', `upload.maximum_data_size=${entry.max_data_size}`) } - args.push('--library', options.libraryPath, '--export-binaries', '-b', entry.platform, options.sketchPath) + args.push('--library', options.libraryPath) + if (options.prebuiltLibraryPath) { + args.push('--library', options.prebuiltLibraryPath) + } + args.push('--export-binaries', '-b', entry.platform, options.sketchPath) if (options.trailingArgs && options.trailingArgs.length > 0) { args.push(...options.trailingArgs) diff --git a/src/backend/shared/hardware/board-info-resolver.ts b/src/backend/shared/hardware/board-info-resolver.ts index a2f15505c..c241b2f9d 100644 --- a/src/backend/shared/hardware/board-info-resolver.ts +++ b/src/backend/shared/hardware/board-info-resolver.ts @@ -156,6 +156,13 @@ export interface BoardBuildInfo { extraArduinoLibraries?: string[] /** Opaque key for a package-supplied `libraries/` folder. */ localLibrariesDir?: string + /** Prebuilt arduino-hal (provisioning="prebuilt"): the precompiled Arduino + * library dir, linked via a 2nd `--library`. Its presence marks an arduino + * prebuilt board (the source HAL still compiles as the integration layer). */ + precompiledLibraryDir?: string + /** Exact Arduino core version to install/verify before linking a prebuilt + * arduino library (ABI-locked). From `target.coreVersion`. */ + coreVersion?: string /** Per-board capability overrides. Merged by * `resolveTargetCapabilities` on top of the compiler preset. * Sourced from `hals.json` `capabilities` (static boards) or VPP @@ -278,6 +285,7 @@ export class BoardInfoResolver { if (device.target.platformOptions && device.target.platformOptions.length > 0) { info.platformOptions = device.target.platformOptions } + if (device.target.coreVersion) info.coreVersion = device.target.coreVersion const resolveRel = this.config.resolvePackageRelativePath if (device.hal.source) info.halSourceFile = resolveRel(pkg.path, device.hal.source) @@ -285,6 +293,7 @@ export class BoardInfoResolver { if (device.hal.configTemplate) info.configTemplate = resolveRel(pkg.path, device.hal.configTemplate) if (device.hal.requirements) info.requirements = resolveRel(pkg.path, device.hal.requirements) if (device.hal.libraries) info.localLibrariesDir = resolveRel(pkg.path, device.hal.libraries) + if (device.hal.precompiledLibrary) info.precompiledLibraryDir = resolveRel(pkg.path, device.hal.precompiledLibrary) const flags = this.#collectFlags( device.hal.compilerFlags?.c_flags, diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index cb032097c..b89ac1f17 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -212,6 +212,11 @@ export interface InstallArduinoCoreArgs { /** Core identifier (e.g. `arduino:avr`). Editor invokes * `arduino-cli core install `. */ coreId: string + /** Optional exact core version (e.g. `1.8.8`). When set, the editor + * installs `@` and verifies the installed version matches — + * required for prebuilt arduino-hal boards whose precompiled `.a` is + * ABI-locked to that core version. */ + coreVersion?: string } /** Arduino-CLI library install (editor-only. Same no-op diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index bc56d00f9..bdf246eac 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -738,6 +738,13 @@ export interface PackageManifest { * manifest.schema.json for the canonical field documentation. */ platformOptions?: PlatformOption[] + /** + * Exact Arduino core version a prebuilt arduino-hal library was compiled + * against (arduino-cli targets with hal.provisioning="prebuilt"). The + * editor installs/verifies this version before linking, since the + * precompiled .a is ABI-locked to it. + */ + coreVersion?: string } specs?: Record hal: { @@ -754,6 +761,12 @@ export interface PackageManifest { configTemplate?: string requirements?: string source?: string + /** + * Prebuilt arduino-hal (provisioning="prebuilt") precompiled Arduino + * library directory. Linked via --library alongside the source + * integration layer (hal.source). + */ + precompiledLibrary?: string compilerFlags?: { c_flags?: string[] cxx_flags?: string[] From 3608ae9be1713701dd08eefa7effe7ee21d1e6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 17 Jun 2026 12:14:34 +0200 Subject: [PATCH 4/9] fix(compile): link the vendor precompiled lib in the editor arduino path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleCompileArduinoProgram (the editor's real arduino-cli compile path, distinct from the shared pipeline.ts) composed its args without the vendor precompiled library, so a prebuilt arduino-hal VPP failed with "p1am_vendor.h: No such file or directory" — arduino-cli never saw the lib. The open hal.source layer is renamed to arduino.cpp and compiled here alongside the sketch (NOT in the precompile pass), and it includes the vendor boundary header. Pass info.precompiledLibraryDir as a 2nd --library so arduino-cli puts the lib's src/ on the include path (resolves the header) and auto-links the src//lib*.a archive (the lib ships precompiled=full). Preserves the OpenPLCUserLib ldflags. Co-Authored-By: Claude Opus 4.8 --- src/backend/editor/compiler/compiler-module.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 9c21005be..aa30cf70c 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1800,6 +1800,13 @@ class CompilerModule { ...cxxFlagsArg, '--library', precompiledLibDir, + // Prebuilt arduino-hal (mixed): the vendor's precompiled library. The + // open hal.source layer (renamed to arduino.cpp, compiled here alongside + // the sketch — NOT in the precompile pass) does `#include "p1am_vendor.h"`, + // so arduino-cli needs the lib's src/ on the include path. Passing it as a + // 2nd --library both resolves the boundary header and auto-links the + // src//lib*.a archive (the lib ships precompiled=full). + ...(info.precompiledLibraryDir ? ['--library', info.precompiledLibraryDir] : []), '--build-property', `compiler.libraries.ldflags=-L${precompiledArchDir} -lOpenPLCUserLib`, ...this.arduinoCliBaseParameters, From f9095d290adc36bc029e019fef316140af7ef023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 17 Jun 2026 12:26:27 +0200 Subject: [PATCH 5/9] chore(compile): relax prebuilt core pin to same-core-present for now The prebuilt arduino library is ABI-locked to target.coreVersion, so the strict policy installed and verified exactly that version. Relax it for now: if the same core (by id) is already installed, accept any version instead of forcing the pinned one. The pinned version is still used to choose what to install when the core is absent. Left a TODO to re-introduce exact-version verification once the team settles the ABI-pin policy. Co-Authored-By: Claude Opus 4.8 --- .../editor/compiler/compiler-module.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index aa30cf70c..d3986543f 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1041,12 +1041,22 @@ class CompilerModule { if (boardCore === null) return const isCoreInstalled = Object.keys(await this.getArduinoInstalledCores()).some((core) => core === boardCore) - // Without a pinned version, any installed version is fine — skip the install. - // With a pinned version (prebuilt arduino libraries are ABI-locked to it), - // always run `core install @`: arduino-cli installs exactly that - // version (and fails if it does not exist), which both pins and verifies it. - if (!coreVersion && isCoreInstalled) { - handleOutputData(`Core ${boardCore} is already installed.`, 'info') + // TEMPORARY: the strict ABI version pin is relaxed for now. Prebuilt arduino + // libraries are technically ABI-locked to target.coreVersion, so a divergent + // installed version could break the link; until the team finalizes that + // policy we only require the SAME core to be present, not the exact pinned + // version. Any already-installed version of the core is accepted, and the + // pinned version is only used to choose what to install when the core is + // absent (below). + // TODO: re-introduce exact-version verification (install @ and + // assert the installed version matches) once the ABI-pin policy is decided. + if (isCoreInstalled) { + handleOutputData( + coreVersion + ? `Core ${boardCore} is already installed (pinned ${coreVersion} not enforced for now).` + : `Core ${boardCore} is already installed.`, + 'info', + ) return } From 4be25bcf6999867be61c04ab8a9e61c78115c485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Wed, 17 Jun 2026 13:06:52 +0200 Subject: [PATCH 6/9] test(compile): cover the arduino prebuilt mixed-VPP path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-arduino-cli-args: a 2nd --library is emitted for the vendor precompiled lib (right after the main one, before --export-binaries), and a single --library when prebuiltLibraryPath is absent. - resolve-board-selection: a prebuilt arduino-hal VPP board surfaces coreVersion and the package-resolved precompiledLibraryDir on boardEntry. - handle-core-installation: the relaxed core pin skips install (no spawn) when the same core is already present, even with a divergent pinned version, and logs accordingly. The handleCoreInstallation test lives in __tests__/ rather than the existing compiler-module.spec.ts because jest only collects .test.ts under __tests__/ here — the .spec.ts files are not picked up by the current testMatch config. Co-Authored-By: Claude Opus 4.8 --- .../handle-core-installation.test.ts | 96 +++++++++++++++++++ .../__tests__/resolve-board-selection.test.ts | 60 ++++++++++++ .../__tests__/build-arduino-cli-args.test.ts | 31 ++++++ 3 files changed, 187 insertions(+) create mode 100644 src/backend/editor/compiler/__tests__/handle-core-installation.test.ts diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts new file mode 100644 index 000000000..ab20be677 --- /dev/null +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -0,0 +1,96 @@ +import { spawn } from 'node:child_process' + +import { CompilerModule } from '../compiler-module' + +// Electron is imported transitively by compiler-module; stub the bits the +// instantiation path actually touches so jest doesn't load the real runtime. +jest.mock('electron', () => ({ + app: { + getPath: jest.fn().mockReturnValue('/tmp/mock-user-data'), + getAppPath: jest.fn().mockReturnValue('/tmp/mock-app-root'), + isPackaged: false, + getVersion: jest.fn().mockReturnValue('0.0.0-test'), + }, + dialog: { showSaveDialog: jest.fn().mockResolvedValue({ filePath: '/tmp/mock-save-path' }) }, +})) +jest.mock('electron/main', () => ({}), { virtual: true }) + +// compiler-module pulls in recipe-exec, which calls promisify(execFile) at +// module load, so the mock must expose exec/execFile (with promisify.custom) +// alongside spawn. handleCoreInstallation only ever reaches spawn, and only +// on the install path; the relaxed-pin tests assert it is NOT spawned when the +// same core is already present, so a bare jest.fn() for spawn suffices. +jest.mock('node:child_process', () => { + const { promisify } = jest.requireActual('node:util') as typeof import('node:util') + const noop = async () => ({ stdout: '', stderr: '' }) + const exec = ( + _cmd: string, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + noop().then((v) => cb(null, v)) + return { kill: () => undefined } + } + ;(exec as unknown as { [k: symbol]: unknown })[promisify.custom] = () => noop() + const execFile = ( + _command: string, + _args: ReadonlyArray, + _opts: unknown, + cb: (err: Error | null, val?: { stdout: string; stderr: string }) => void, + ) => { + noop().then((v) => cb(null, v)) + return { kill: () => undefined } + } + ;(execFile as unknown as { [k: symbol]: unknown })[promisify.custom] = () => noop() + return { exec, execFile, spawn: jest.fn() } +}) +;(process as unknown as { resourcesPath: string }).resourcesPath ??= process.cwd() + +type InstalledCores = Awaited> + +describe('handleCoreInstallation (prebuilt core pin relaxed)', () => { + let compilerModule: CompilerModule + + beforeEach(() => { + compilerModule = new CompilerModule() + jest.mocked(spawn).mockClear() + }) + + it('does nothing when boardCore is null', async () => { + const log = jest.fn() + const coresSpy = jest.spyOn(compilerModule, 'getArduinoInstalledCores') + await compilerModule.handleCoreInstallation(null, log) + expect(coresSpy).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + }) + + it('skips install (no spawn) when the same core is present, even with a divergent pinned version', async () => { + const log = jest.fn() + jest + .spyOn(compilerModule, 'getArduinoInstalledCores') + .mockResolvedValue({ 'FACTS:samd': { version: '1.7.99' } } as unknown as InstalledCores) + + // Pinned 1.7.13, but 1.7.99 is installed: the relaxed policy accepts it. + await compilerModule.handleCoreInstallation('FACTS:samd', log, '1.7.13') + + expect(spawn).not.toHaveBeenCalled() + const message = log.mock.calls.map((c) => String(c[0])).join('\n') + expect(message).toMatch(/already installed/) + expect(message).toMatch(/1\.7\.13 not enforced/) + }) + + it('skips install when the core is present and no version is pinned', async () => { + const log = jest.fn() + jest + .spyOn(compilerModule, 'getArduinoInstalledCores') + .mockResolvedValue({ 'arduino:avr': { version: '1.8.6' } } as unknown as InstalledCores) + + await compilerModule.handleCoreInstallation('arduino:avr', log) + + expect(spawn).not.toHaveBeenCalled() + const message = log.mock.calls.map((c) => String(c[0])).join('\n') + expect(message).toMatch(/already installed/) + expect(message).not.toMatch(/not enforced/) + }) +}) diff --git a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts index 3ed7b0a7b..1998eb024 100644 --- a/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts +++ b/src/backend/shared/compile/__tests__/resolve-board-selection.test.ts @@ -179,4 +179,64 @@ describe('resolveBoardSelection', () => { expect(result.boardRuntime).toBe('arduino-cli') } }) + + it('maps a prebuilt arduino-hal VPP board: coreVersion + resolved precompiledLibraryDir', () => { + // Prebuilt mixed VPP: target.coreVersion (ABI-locked core) and + // hal.precompiledLibrary (vendor lib dir, resolved package-relative) + // must surface on boardEntry so the pipeline pins the core and passes + // the 2nd --library. The open hal.source integration layer stays. + const pkg: InstalledPackage = { + packageId: 'com.automationdirect.p1am-prebuilt-test', + version: '0.1.0', + installedAt: '2026-01-01T00:00:00.000Z', + path: '/fake/packages/p1am', + devices: ['p1am-200'], + } + const manifest: PackageManifest = { + formatVersion: '1.0', + package: { + id: 'com.automationdirect.p1am-prebuilt-test', + name: 'P1AM', + version: '0.1.0', + vendor: { name: 'AutomationDirect', logo: 'l.png' }, + description: 'd', + }, + devices: [ + { + id: 'p1am-200', + name: 'AutomationDirect P1AM-200', + preview: 'p.png', + target: { + type: 'arduino-cli', + core: 'FACTS:samd', + platform: 'FACTS:samd:P1AM-200', + coreVersion: '1.7.13', + }, + hal: { + type: 'arduino-hal', + provisioning: 'prebuilt', + source: 'hal/arduino/p1am.cpp', + precompiledLibrary: 'hal/arduino/lib', + extraArduinoLibraries: ['P1AM'], + }, + }, + ], + } + const packageManager: PackageManagerPort = { + listInstalled: () => [pkg], + getInstalledPackageManifest: (id) => (id === pkg.packageId ? manifest : null), + } + const resolver = makeResolver({}, { packageManager }) + + const result = resolveBoardSelection(resolver, 'AutomationDirect P1AM-200') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.boardEntry.platform).toBe('FACTS:samd:P1AM-200') + expect(result.boardEntry.core).toBe('FACTS:samd') + expect(result.boardEntry.coreVersion).toBe('1.7.13') + // resolvePackageRelativePath fake joins as `${pkg.path}/${rel}`. + expect(result.boardEntry.precompiledLibraryDir).toBe('/fake/packages/p1am/hal/arduino/lib') + expect(result.boardEntry.extra_libraries).toEqual(['P1AM']) + } + }) }) diff --git a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts index 52e620c16..5613b5c89 100644 --- a/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts +++ b/src/backend/shared/firmware/__tests__/build-arduino-cli-args.test.ts @@ -133,6 +133,37 @@ describe('buildArduinoCliCompileArgs', () => { expect(args.some((a) => a.startsWith('upload.maximum_data_size='))).toBe(false) }) + it('appends a 2nd --library for the prebuilt vendor lib, right after the main one', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'FACTS:samd:P1AM-200', core: 'FACTS:samd' }, + { + sketchPath: '/work/examples/Baremetal/Baremetal.ino', + libraryPath: '/work/src', + prebuiltLibraryPath: '/packages/p1am/hal/arduino/lib', + parallel: false, + }, + ) + const firstLib = args.indexOf('--library') + // Two --library flags: the main src/ then the prebuilt vendor lib. + expect(args.filter((a) => a === '--library')).toHaveLength(2) + expect(args.slice(firstLib, firstLib + 4)).toEqual([ + '--library', + '/work/src', + '--library', + '/packages/p1am/hal/arduino/lib', + ]) + // The vendor lib still precedes --export-binaries / -b / sketch. + expect(args.indexOf('/packages/p1am/hal/arduino/lib')).toBeLessThan(args.indexOf('--export-binaries')) + }) + + it('emits a single --library when prebuiltLibraryPath is absent', () => { + const args = buildArduinoCliCompileArgs( + { platform: 'arduino:avr:mega' }, + { sketchPath: 'a.ino', libraryPath: 'src', parallel: false }, + ) + expect(args.filter((a) => a === '--library')).toHaveLength(1) + }) + it('appends trailingArgs after the sketch path', () => { const args = buildArduinoCliCompileArgs( { platform: 'arduino:avr:mega' }, From a429b8d8a17f789cee9be02b9e6c94adda4ae705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 18 Jun 2026 11:36:07 +0200 Subject: [PATCH 7/9] fix(compile): pin the prebuilt core to the exact manifest version Revert the relaxed "same core present" policy. A prebuilt arduino library is ABI-locked to target.coreVersion, so the editor must install exactly that version. With a pinned version, always run `core install @` (arduino-cli installs that exact version and fails if it does not exist), which both pins and verifies it; a divergent already-installed version is replaced. Without a pinned version (source-mode boards), any installed version is still accepted. Update the tests to assert the exact-version install (core present with a divergent version, and core absent), the non-zero-exit rejection, and that the skip path only triggers for an unpinned, already-installed core. Co-Authored-By: Claude Opus 4.8 --- .../handle-core-installation.test.ts | 56 ++++++++++++++----- .../editor/compiler/compiler-module.ts | 22 ++------ 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts index ab20be677..2bcdc14d3 100644 --- a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' import { CompilerModule } from '../compiler-module' @@ -17,9 +18,9 @@ jest.mock('electron/main', () => ({}), { virtual: true }) // compiler-module pulls in recipe-exec, which calls promisify(execFile) at // module load, so the mock must expose exec/execFile (with promisify.custom) -// alongside spawn. handleCoreInstallation only ever reaches spawn, and only -// on the install path; the relaxed-pin tests assert it is NOT spawned when the -// same core is already present, so a bare jest.fn() for spawn suffices. +// alongside spawn. handleCoreInstallation reaches spawn only on the install +// path (core absent OR a pinned version is requested); the skip-path tests +// assert spawn is NOT called. jest.mock('node:child_process', () => { const { promisify } = jest.requireActual('node:util') as typeof import('node:util') const noop = async () => ({ stdout: '', stderr: '' }) @@ -48,12 +49,23 @@ jest.mock('node:child_process', () => { type InstalledCores = Awaited> -describe('handleCoreInstallation (prebuilt core pin relaxed)', () => { +// A fake ChildProcess that satisfies handleCoreInstallation's wiring +// (stdout/stderr `.on`, plus a `close` event) and reports the given exit code +// on the next tick so the `.on('close')` handler is registered first. +function fakeChild(exitCode = 0) { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + setImmediate(() => child.emit('close', exitCode)) + return child +} + +describe('handleCoreInstallation (prebuilt core pin = exact manifest version)', () => { let compilerModule: CompilerModule beforeEach(() => { compilerModule = new CompilerModule() - jest.mocked(spawn).mockClear() + jest.mocked(spawn).mockReset() }) it('does nothing when boardCore is null', async () => { @@ -65,22 +77,41 @@ describe('handleCoreInstallation (prebuilt core pin relaxed)', () => { expect(log).not.toHaveBeenCalled() }) - it('skips install (no spawn) when the same core is present, even with a divergent pinned version', async () => { + it('installs the EXACT pinned version even when a different version is already present', async () => { const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(0) as unknown as ReturnType) jest .spyOn(compilerModule, 'getArduinoInstalledCores') .mockResolvedValue({ 'FACTS:samd': { version: '1.7.99' } } as unknown as InstalledCores) - // Pinned 1.7.13, but 1.7.99 is installed: the relaxed policy accepts it. await compilerModule.handleCoreInstallation('FACTS:samd', log, '1.7.13') - expect(spawn).not.toHaveBeenCalled() - const message = log.mock.calls.map((c) => String(c[0])).join('\n') - expect(message).toMatch(/already installed/) - expect(message).toMatch(/1\.7\.13 not enforced/) + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'FACTS:samd@1.7.13'])) + }) + + it('installs the pinned version when the core is absent', async () => { + const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(0) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + + await compilerModule.handleCoreInstallation('FACTS:samd', log, '1.7.13') + + expect(spawn).toHaveBeenCalledTimes(1) + const [, argv] = jest.mocked(spawn).mock.calls[0] + expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'FACTS:samd@1.7.13'])) + }) + + it('rejects when the pinned version install fails (non-zero exit)', async () => { + const log = jest.fn() + jest.mocked(spawn).mockReturnValue(fakeChild(1) as unknown as ReturnType) + jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) + + await expect(compilerModule.handleCoreInstallation('FACTS:samd', log, '9.9.9')).rejects.toThrow(/exited with code 1/) }) - it('skips install when the core is present and no version is pinned', async () => { + it('skips install (no spawn) only when the core is present AND no version is pinned', async () => { const log = jest.fn() jest .spyOn(compilerModule, 'getArduinoInstalledCores') @@ -91,6 +122,5 @@ describe('handleCoreInstallation (prebuilt core pin relaxed)', () => { expect(spawn).not.toHaveBeenCalled() const message = log.mock.calls.map((c) => String(c[0])).join('\n') expect(message).toMatch(/already installed/) - expect(message).not.toMatch(/not enforced/) }) }) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index d3986543f..aa30cf70c 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1041,22 +1041,12 @@ class CompilerModule { if (boardCore === null) return const isCoreInstalled = Object.keys(await this.getArduinoInstalledCores()).some((core) => core === boardCore) - // TEMPORARY: the strict ABI version pin is relaxed for now. Prebuilt arduino - // libraries are technically ABI-locked to target.coreVersion, so a divergent - // installed version could break the link; until the team finalizes that - // policy we only require the SAME core to be present, not the exact pinned - // version. Any already-installed version of the core is accepted, and the - // pinned version is only used to choose what to install when the core is - // absent (below). - // TODO: re-introduce exact-version verification (install @ and - // assert the installed version matches) once the ABI-pin policy is decided. - if (isCoreInstalled) { - handleOutputData( - coreVersion - ? `Core ${boardCore} is already installed (pinned ${coreVersion} not enforced for now).` - : `Core ${boardCore} is already installed.`, - 'info', - ) + // Without a pinned version, any installed version is fine — skip the install. + // With a pinned version (prebuilt arduino libraries are ABI-locked to it), + // always run `core install @`: arduino-cli installs exactly that + // version (and fails if it does not exist), which both pins and verifies it. + if (!coreVersion && isCoreInstalled) { + handleOutputData(`Core ${boardCore} is already installed.`, 'info') return } From ee03886eb38783fbfb9ddf7d719c76f3d3f94193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 18 Jun 2026 13:23:05 +0200 Subject: [PATCH 8/9] docs(compile): describe the core pin as install-exact, not post-install verify The coreVersion comments claimed the editor "verifies the installed version matches", but there is no separate post-install verification step. Reword both the InstallArduinoCoreArgs.coreVersion doc and the handleCoreInstallation comment to describe what the code actually does: run `core install @`, which installs exactly that version and fails if it is unavailable. No behavior change. Co-Authored-By: Claude Opus 4.8 --- src/backend/editor/compiler/compiler-module.ts | 3 ++- src/middleware/shared/ports/compiler-platform-port.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index aa30cf70c..fe5d59831 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1044,7 +1044,8 @@ class CompilerModule { // Without a pinned version, any installed version is fine — skip the install. // With a pinned version (prebuilt arduino libraries are ABI-locked to it), // always run `core install @`: arduino-cli installs exactly that - // version (and fails if it does not exist), which both pins and verifies it. + // version and fails if it does not exist, pinning the core to the version + // the precompiled library was built against. if (!coreVersion && isCoreInstalled) { handleOutputData(`Core ${boardCore} is already installed.`, 'info') return diff --git a/src/middleware/shared/ports/compiler-platform-port.ts b/src/middleware/shared/ports/compiler-platform-port.ts index b89ac1f17..1d038ce7a 100644 --- a/src/middleware/shared/ports/compiler-platform-port.ts +++ b/src/middleware/shared/ports/compiler-platform-port.ts @@ -212,10 +212,10 @@ export interface InstallArduinoCoreArgs { /** Core identifier (e.g. `arduino:avr`). Editor invokes * `arduino-cli core install `. */ coreId: string - /** Optional exact core version (e.g. `1.8.8`). When set, the editor - * installs `@` and verifies the installed version matches — - * required for prebuilt arduino-hal boards whose precompiled `.a` is - * ABI-locked to that core version. */ + /** Optional exact core version (e.g. `1.8.8`). When set, the editor runs + * `core install @`, which installs exactly that version and + * fails if it is unavailable — required for prebuilt arduino-hal boards + * whose precompiled `.a` is ABI-locked to that core version. */ coreVersion?: string } From d037135029201ec7c3e1610228adf5b57a13f2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 18 Jun 2026 13:59:01 +0200 Subject: [PATCH 9/9] style: prettier-format handle-core-installation test Fixes the CI Format Check (npx prettier --check). Formatting only. Co-Authored-By: Claude Opus 4.8 --- .../compiler/__tests__/handle-core-installation.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts index 2bcdc14d3..393bd63d6 100644 --- a/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-core-installation.test.ts @@ -108,7 +108,9 @@ describe('handleCoreInstallation (prebuilt core pin = exact manifest version)', jest.mocked(spawn).mockReturnValue(fakeChild(1) as unknown as ReturnType) jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores) - await expect(compilerModule.handleCoreInstallation('FACTS:samd', log, '9.9.9')).rejects.toThrow(/exited with code 1/) + await expect(compilerModule.handleCoreInstallation('FACTS:samd', log, '9.9.9')).rejects.toThrow( + /exited with code 1/, + ) }) it('skips install (no spawn) only when the core is present AND no version is pinned', async () => {