Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,72 @@ describe('handleCoreInstallation (prebuilt core pin = exact manifest version)',
expect(message).toMatch(/already installed/)
})
})

/**
* Vendor board-manager index support.
*
* Regression cover for "Platform 'industrialshields:esp32' not found": a VPP
* declaring `target.boardManagerUrl` must reach arduino-cli as
* `--additional-urls`, on BOTH `core update-index` and `core install`. The
* index refresh is what makes the install resolvable — `core install` alone
* matches against the cached index and still fails.
*/
describe('handleCoreInstallation (vendor board manager URL)', () => {
const VENDOR_URL = 'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json'
let compilerModule: CompilerModule

beforeEach(() => {
compilerModule = new CompilerModule()
jest.mocked(spawn).mockReset()
jest.mocked(spawn).mockImplementation(() => fakeChild(0) as unknown as ReturnType<typeof spawn>)
jest.spyOn(compilerModule, 'getArduinoInstalledCores').mockResolvedValue({} as InstalledCores)
})

it('refreshes the index against the vendor URL BEFORE installing', async () => {
await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL)

expect(spawn).toHaveBeenCalledTimes(2)
const [, updateArgv] = jest.mocked(spawn).mock.calls[0]
const [, installArgv] = jest.mocked(spawn).mock.calls[1]
expect(updateArgv).toEqual(expect.arrayContaining(['core', 'update-index', '--additional-urls', VENDOR_URL]))
expect(installArgv).toEqual(
expect.arrayContaining(['core', 'install', 'industrialshields:esp32', '--additional-urls', VENDOR_URL]),
)
})

it('passes --additional-urls alongside a pinned core version', async () => {
await compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), '2.7.1', VENDOR_URL)

const [, installArgv] = jest.mocked(spawn).mock.calls[1]
expect(installArgv).toEqual(
expect.arrayContaining(['core', 'install', 'industrialshields:esp32@2.7.1', '--additional-urls', VENDOR_URL]),
)
})

it('omits --additional-urls entirely when the board declares no vendor index', async () => {
await compilerModule.handleCoreInstallation('arduino:avr', jest.fn(), '1.8.6')

expect(spawn).toHaveBeenCalledTimes(1)
const [, argv] = jest.mocked(spawn).mock.calls[0]
expect(argv).not.toContain('--additional-urls')
expect(argv).toEqual(expect.arrayContaining(['core', 'install', 'arduino:avr@1.8.6']))
})

it('still attempts the install when the index refresh fails', async () => {
// First spawn (update-index) fails, second (install) succeeds. A flaky
// network on the refresh must not mask the install's own error.
let call = 0
jest.mocked(spawn).mockImplementation(() => {
call += 1
return fakeChild(call === 1 ? 1 : 0) as unknown as ReturnType<typeof spawn>
})

await expect(
compilerModule.handleCoreInstallation('industrialshields:esp32', jest.fn(), undefined, VENDOR_URL),
).resolves.not.toThrow()

expect(spawn).toHaveBeenCalledTimes(2)
const [, installArgv] = jest.mocked(spawn).mock.calls[1]
expect(installArgv).toEqual(expect.arrayContaining(['core', 'install', 'industrialshields:esp32']))
})
})
64 changes: 59 additions & 5 deletions src/backend/editor/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,8 @@

checkStrucppAvailability(): MethodsResult<string> {
try {
const { getVersion } = loadStrucpp()

Check warning on line 494 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe array destructuring of a tuple element with an error typed value
return { success: true, data: getVersion() }

Check warning on line 495 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe call of a(n) `error` type typed value

Check warning on line 495 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
} catch {
throw new Error('STruC++ not available. Run "npm run setup:binaries" to install it.')
}
Expand Down Expand Up @@ -938,9 +938,18 @@
// and the Arduino sketch walks them dynamically for I/O binding.
// The debugger will be redesigned in Phase 4.

// TODO: This method is used to update the index of the Arduino core.
// We should validate if this is necessary and if it works correctly.
async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback) {
/**
* `arduino-cli core update-index` — refetch the platform indexes.
*
* Required before installing a core that lives in a third-party index:
* passing `--additional-urls` to `core install` alone is not enough,
* because the CLI resolves the platform against its *cached* index and
* reports "Platform not found" until that cache has seen the vendor URL.
*
* `additionalUrls` is forwarded so the refresh covers the vendor index
* as well as the ones configured in `arduino-cli.yaml`.
*/
async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback, additionalUrls?: string) {
return new Promise<MethodsResult<string | Buffer>>((resolve, reject) => {
let binaryPath = this.arduinoCliBinaryPath
const [flag, configFilePath] = this.arduinoCliBaseParameters
Expand All @@ -949,7 +958,13 @@
// INFO: On Windows, we need to add the .exe extension to the binary path.
binaryPath += '.exe'
}
const executeCommand = spawn(binaryPath, ['core', 'update-index', flag, configFilePath])
const executeCommand = spawn(binaryPath, [
'core',
'update-index',
...(additionalUrls ? ['--additional-urls', additionalUrls] : []),
flag,
configFilePath,
])

let stderrData = ''

Expand All @@ -971,10 +986,24 @@
})
}

/**
* Install the Arduino core a board needs, pulling it from a vendor
* board-manager index when the board declares one.
*
* `boardManagerUrl` comes from the VPP manifest (`target.boardManagerUrl`)
* or hals.json (`board_manager_url`). Cores outside arduino-cli's
* built-in index — `industrialshields:esp32`, for example — are
* unresolvable without it, and the install dies with
* "Platform '<id>' not found" (exit 7). When one is supplied we refresh
* the index against that URL first, then install with the same
* `--additional-urls`; both steps are needed, since `core install`
* resolves against the cached index.
*/
async handleCoreInstallation(
boardCore: string | null,
handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void,
coreVersion?: string,
boardManagerUrl?: string,
) {
if (boardCore === null) return

Expand All @@ -994,14 +1023,39 @@
handleOutputData(`Installing pinned core ${coreRef} (required by a prebuilt library)...`, 'info')
}

// Refresh the platform index against the vendor URL before installing.
// Non-fatal: a transient network failure here should not mask the far
// more useful error that `core install` produces a moment later.
if (boardManagerUrl) {
handleOutputData(`Using vendor board index: ${boardManagerUrl}`, 'info')
try {
// `handleCoreUpdateIndex` logs at the wider 'info' | 'warning' |
// 'error' level set; this callback only accepts 'info' | 'error',
// so fold 'warning' down to 'info'.
await this.handleCoreUpdateIndex(
(chunk, level) => handleOutputData(chunk, level === 'error' ? 'error' : 'info'),
boardManagerUrl,
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
handleOutputData(`Warning: could not refresh the board index (${message}). Continuing.`, 'info')
}
}

let binaryPath = this.arduinoCliBinaryPath

if (CompilerModule.HOST_PLATFORM === 'win32') {
// INFO: On Windows, we need to add the .exe extension to the binary path.
binaryPath += '.exe'
}
return new Promise<MethodsResult<string | Buffer>>((resolve, reject) => {
const executeCommand = spawn(binaryPath, ['core', 'install', coreRef, ...this.arduinoCliBaseParameters])
const executeCommand = spawn(binaryPath, [
'core',
'install',
coreRef,
...(boardManagerUrl ? ['--additional-urls', boardManagerUrl] : []),
...this.arduinoCliBaseParameters,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
])

let stderrData = ''

Expand Down Expand Up @@ -2983,7 +3037,7 @@
_mainProcessPort.postMessage({
logLevel,
message: data,
...(compileError ? { compileError } : {}),

Check warning on line 3040 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
})
},
{ hasCBlocks, pous: knownPous, libraries, missingLibraries },
Expand Down
7 changes: 7 additions & 0 deletions src/backend/editor/compiler/editor-compiler-platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ export function createEditorCompilerPlatformPort(
* `handleCoreInstallation` already takes a core id and a log
* callback — direct passthrough modulo the log-shape
* translation.
*
* `args.boardManagerUrl` (the VPP's `target.boardManagerUrl`) is
* forwarded so vendor cores outside arduino-cli's built-in index
* install automatically rather than failing with "Platform not
* found". `handleCoreInstallation` refreshes the index against
* that URL before installing.
*/
async installArduinoCore(args: InstallArduinoCoreArgs, log: PlatformLog): Promise<UploadResult> {
try {
Expand All @@ -219,6 +225,7 @@ export function createEditorCompilerPlatformPort(
log(message, level ?? 'info')
},
args.coreVersion,
args.boardManagerUrl,
)
return { ok: true }
} catch (error) {
Expand Down
52 changes: 44 additions & 8 deletions src/backend/editor/services/user-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getErrorMessage } from '@root/frontend/utils/get-error-message'
import { exec } from 'child_process'
import { app } from 'electron'
import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises'
import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { promisify } from 'util'

Expand Down Expand Up @@ -144,22 +144,58 @@ class UserService {
}

/**
* Checks if the Arduino CLI configuration file exists and creates it if it doesn't.
* Ensure the Arduino CLI configuration file exists and carries every
* board-manager URL the editor ships with.
*
* This used to write with `{ flag: 'wx' }` and swallow `EEXIST`, which
* made the file effectively write-once: any URL added to `ARDUINO_DATA`
* after a user's first launch never reached them, and the only fix was
* deleting the file by hand. Now missing URLs are merged into the
* existing config on every start.
*
* Merge, never overwrite: users add their own indexes and change other
* settings in this file, and clobbering it would silently discard them.
* Anything already present is left untouched, including ordering.
*/
async #checkIfArduinoCliConfigExists(): Promise<void> {
const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml')
try {
await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' })
return
} catch (err) {
// If the error is due to the file already existing, log a warning and continue.
if (err instanceof Error && err.message.includes('EEXIST')) {
console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`)
} else if (err instanceof Error) {
console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`)
} else {
if (!(err instanceof Error && err.message.includes('EEXIST'))) {
console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`)
return
}
}

// File already exists — reconcile its `additional_urls` with ours.
try {
const existing = await readFile(pathToArduinoCliConfig, 'utf-8')
const shipped = UserService.ARDUINO_FILE_CONTENT.match(/^\s*-\s*(https?:\/\/\S+)\s*$/gm) ?? []
const missing = shipped.map((line) => line.trim().replace(/^-\s*/, '')).filter((url) => !existing.includes(url))

if (missing.length === 0) return

// Splice the missing entries in under the existing `additional_urls:`
// key, matching its indentation so the YAML stays valid.
const anchor = existing.match(/^(\s*)additional_urls:\s*$/m)
if (!anchor) {
console.warn(
`Arduino CLI config at ${pathToArduinoCliConfig} has no 'additional_urls' key. ` +
`Leaving it alone; missing board indexes: ${missing.join(', ')}`,
)
return
}
const firstEntry = existing.match(/^(\s*)-\s*https?:\/\//m)
const indent = firstEntry ? firstEntry[1] : `${anchor[1]} `
const updated = existing.replace(anchor[0], `${anchor[0]}\n${missing.map((u) => `${indent}- ${u}`).join('\n')}`)

Comment thread
thiagoralves marked this conversation as resolved.
await writeFile(pathToArduinoCliConfig, updated, 'utf-8')
console.warn(`Added ${missing.length} missing board manager URL(s) to ${pathToArduinoCliConfig}.`)
} catch (err) {
console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`)
}
}

async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> {
Expand Down
36 changes: 36 additions & 0 deletions src/backend/shared/compile/__tests__/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,42 @@ describe('runCompilePipeline — simulator path', () => {
expect(callArgs.argv).toEqual(['compile', '-b', 'arduino:avr:mega'])
})

// Regression: the board's `boardManagerUrl` (VPP `target.boardManagerUrl`)
// was resolved onto boardEntry but never forwarded to installArduinoCore,
// so vendor cores outside arduino-cli's built-in index could not be
// installed — "Platform 'industrialshields:esp32' not found".
it('forwards boardEntry.boardManagerUrl to installArduinoCore', async () => {
const port = makePort()
const { emit } = captureEvents()
const boardManagerUrl =
'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json'
await runCompilePipeline(
makeArgs({
isSimulator: false,
boardRuntime: 'arduino-cli',
boardEntry: {
platform: 'industrialshields:esp32:esp32plc',
core: 'industrialshields:esp32',
boardManagerUrl,
},
}),
port,
emit,
)
expect(port.installArduinoCore).toHaveBeenCalledWith(
expect.objectContaining({ coreId: 'industrialshields:esp32', boardManagerUrl }),
expect.any(Function),
)
})

it('omits boardManagerUrl for boards that do not declare one', async () => {
const port = makePort()
const { emit } = captureEvents()
await runCompilePipeline(makeArgs({ isSimulator: false, boardRuntime: 'arduino-cli' }), port, emit)
const [coreArgs] = port.installArduinoCore.mock.calls[0]
expect(coreArgs).not.toHaveProperty('boardManagerUrl')
})

it('calls installArduinoCore + installArduinoLib before compileArduino (no-op semantics for web)', async () => {
const port = makePort()
const { emit } = captureEvents()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,59 @@ describe('resolveBoardSelection', () => {
expect(result.boardEntry.extra_libraries).toEqual(['P1AM'])
}
})

it('carries target.boardManagerUrl through to boardEntry', () => {
// A VPP whose core is not in arduino-cli's built-in index must surface
// its vendor index here, or the pipeline has nothing to hand to
// `installArduinoCore` and the install fails with "Platform not found".
const boardManagerUrl =
'https://apps.industrialshields.com/main/arduino/boards/package_industrialshields_index.json'
const pkg: InstalledPackage = {
packageId: 'com.openplc.industrialshields',
version: '1.0.1',
installedAt: '2026-01-01T00:00:00.000Z',
path: '/fake/packages/industrialshields',
devices: ['esp32-plc-14-0-10v'],
}
const manifest: PackageManifest = {
formatVersion: '1.0',
package: {
id: 'com.openplc.industrialshields',
name: 'IndustrialShields PLCs',
version: '1.0.1',
vendor: { name: 'Industrial Shields', logo: 'l.png' },
description: 'd',
},
devices: [
{
id: 'esp32-plc-14-0-10v',
name: 'ESP32 PLC 14 0-10V',
preview: 'p.png',
target: {
type: 'arduino-cli',
core: 'industrialshields:esp32',
platform: 'industrialshields:esp32:plc14ios:cpu=plc14ios',
boardManagerUrl,
},
hal: {
type: 'arduino-hal',
source: 'hal/arduino/esp32plc.cpp',
define: 'ISPLC_ESP32_PLC_14_0_10V',
},
},
],
}
const packageManager: PackageManagerPort = {
listInstalled: () => [pkg],
getInstalledPackageManifest: (id) => (id === pkg.packageId ? manifest : null),
}
const resolver = makeResolver({}, { packageManager })

const result = resolveBoardSelection(resolver, 'ESP32 PLC 14 0-10V')
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.boardEntry.core).toBe('industrialshields:esp32')
expect(result.boardEntry.boardManagerUrl).toBe(boardManagerUrl)
}
})
})
11 changes: 11 additions & 0 deletions src/backend/shared/compile/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Shared OpenPLC compile pipeline.
*
Expand Down Expand Up @@ -118,6 +118,11 @@
/** Exact Arduino core version to install/verify before linking a prebuilt
* arduino library (ABI-locked). From the VPP manifest `target.coreVersion`. */
coreVersion?: string
/** Vendor board-manager index (`package_<vendor>_index.json`). From the
* VPP manifest `target.boardManagerUrl` or hals.json `board_manager_url`.
* Forwarded to `installArduinoCore`, which passes it to arduino-cli as
* `--additional-urls` so cores outside the built-in index resolve. */
boardManagerUrl?: string
/** Compiler / runtime identifier (`'arduino-cli' | 'openplc-compiler'
* | 'simulator'`). Used by `resolveTargetCapabilities`'s
* preset lookup — without this the resolver can't pick the right
Expand Down Expand Up @@ -719,6 +724,12 @@
coreId: typeof boardEntry.platform === 'string' ? deriveArduinoCoreFromPlatform(boardEntry.platform) : '',
// Pin the exact core version for prebuilt arduino libraries (ABI-locked).
...(boardEntry.coreVersion ? { coreVersion: boardEntry.coreVersion } : {}),
// Vendor board-manager index for cores outside arduino-cli's built-in
// list. Resolved from the VPP manifest's `target.boardManagerUrl`; the
// editor turns it into `--additional-urls` (and refreshes the index)
// so the core can be auto-installed instead of erroring out with
// "Platform not found".
...(boardEntry.boardManagerUrl ? { boardManagerUrl: boardEntry.boardManagerUrl } : {}),
},
makePlatformLog(emit, 'core-install'),
)
Expand Down
Loading
Loading