diff --git a/src/backend/editor/compiler/types.ts b/src/backend/editor/compiler/types.ts index 7d2533449..cc5109af6 100644 --- a/src/backend/editor/compiler/types.ts +++ b/src/backend/editor/compiler/types.ts @@ -1,16 +1,5 @@ import { z } from 'zod/v4' -const ArduinoCliConfigSchema = z.object({ - board_manager: z.object({ - additional_urls: z.array(z.string()), - }), - output: z.object({ - no_color: z.boolean(), - }), -}) - -type ArduinoCliConfig = z.infer - const ArduinoCoreControlSchema = z.array(z.record(z.string(), z.string())) type ArduinoCoreControl = z.infer @@ -38,6 +27,6 @@ type ToolchainProperties = { export type { BoardInfo, HalsFile } from '../hardware/types' export { BoardInfoSchema, HalsFileSchema } from '../hardware/types' -export { ArduinoCliConfigSchema, ArduinoCoreControlSchema } +export { ArduinoCoreControlSchema } -export type { ArduinoCliConfig, ArduinoCoreControl, ToolchainProperties } +export type { ArduinoCoreControl, ToolchainProperties } diff --git a/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts new file mode 100644 index 000000000..9d905eee8 --- /dev/null +++ b/src/backend/editor/services/user-service/data/__tests__/arduino-cli-config.test.ts @@ -0,0 +1,159 @@ +import { parse } from 'yaml' + +import { reconcileArduinoCliConfig } from '../arduino-cli-config' +import { ARDUINO_DATA } from '../types' + +/** + * The config every install created before this change: two board-manager URLs + * and the colour suppression the console no longer needs. + */ +const LEGACY_CONFIG = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://espressif.github.io/arduino-esp32/package_esp32_index.json +output: + no_color: true +` + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Board-manager URLs in a YAML document, without asserting its shape. */ +function urlsOf(yaml: string): string[] { + const parsed: unknown = parse(yaml) + if (!isRecord(parsed) || !isRecord(parsed.board_manager)) return [] + const urls = parsed.board_manager.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * The reconciled config, failing the test if nothing changed. + * + * `reconcileArduinoCliConfig` returns `null` for "already up to date", which + * is a real outcome worth asserting on separately -- so a test that expects a + * rewrite says so here rather than casting the null away and failing later + * with a confusing message. + */ +function requireUpdated(result: string | null): string { + if (result === null) throw new Error('expected the config to be rewritten, but it needed no changes') + return result +} + +describe('reconcileArduinoCliConfig', () => { + // --------------------------------------------------------------------- + // The upgrade path that matters: an install that already has no_color. + // --------------------------------------------------------------------- + it('drops output.no_color from a legacy config', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + expect(updated).not.toBeNull() + expect(updated).not.toContain('no_color') + // The whole `output` map existed only to hold it. + expect(updated).not.toContain('output:') + }) + + it('backfills the board manager URLs the legacy config never received', () => { + const updated = reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA) + const result = urlsOf(requireUpdated(updated)) + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('produces a config that still parses as valid YAML', () => { + const updated = requireUpdated(reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA)) + expect(() => parse(updated)).not.toThrow() + expect(parse(updated)).toMatchObject({ board_manager: { additional_urls: expect.any(Array) } }) + }) + + it('is idempotent — a second pass reports nothing left to do', () => { + const once = requireUpdated(reconcileArduinoCliConfig(LEGACY_CONFIG, ARDUINO_DATA)) + expect(reconcileArduinoCliConfig(once, ARDUINO_DATA)).toBeNull() + }) + + // --------------------------------------------------------------------- + // Don't destroy what the user put there. + // --------------------------------------------------------------------- + it('keeps user-added URLs that the editor does not ship', () => { + const withCustom = ` +board_manager: + additional_urls: + - https://arduino.esp8266.com/stable/package_esp8266com_index.json + - https://example.com/package_mine_index.json +output: + no_color: true +` + const updated = requireUpdated(reconcileArduinoCliConfig(withCustom, ARDUINO_DATA)) + const result = urlsOf(updated) + expect(result).toContain('https://example.com/package_mine_index.json') + // ...and the shipped ones still get added alongside it. + for (const url of urlsOf(ARDUINO_DATA)) expect(result).toContain(url) + }) + + it('preserves unrelated settings and comments', () => { + const withExtras = `# my notes\nlogging:\n level: debug\n${LEGACY_CONFIG}` + const updated = requireUpdated(reconcileArduinoCliConfig(withExtras, ARDUINO_DATA)) + expect(updated).toContain('# my notes') + expect(parse(updated)).toMatchObject({ logging: { level: 'debug' } }) + }) + + it('keeps an output map that still holds other keys', () => { + const withOtherOutput = `board_manager:\n additional_urls: []\noutput:\n no_color: true\n format: json\n` + const updated = requireUpdated(reconcileArduinoCliConfig(withOtherOutput, ARDUINO_DATA)) + expect(updated).not.toContain('no_color') + expect(parse(updated)).toMatchObject({ output: { format: 'json' } }) + }) + + // --------------------------------------------------------------------- + // No-op and failure cases. + // --------------------------------------------------------------------- + it('returns null when the file already matches what we ship', () => { + expect(reconcileArduinoCliConfig(ARDUINO_DATA, ARDUINO_DATA)).toBeNull() + }) + + it('installs the shipped URL list when the key is missing entirely', () => { + const updated = requireUpdated(reconcileArduinoCliConfig('output:\n no_color: true\n', ARDUINO_DATA)) + expect(urlsOf(updated)).toEqual(urlsOf(ARDUINO_DATA)) + }) + + // A hand-edited config can be parseable but structurally odd. yaml's nested + // `*In()` helpers throw on a scalar parent ("Expected YAML collection at + // board_manager"), which would abort the whole reconciliation and silently + // leave the user un-migrated. + it('does not throw when board_manager is a scalar', () => { + expect(() => reconcileArduinoCliConfig('board_manager: 5\n', ARDUINO_DATA)).not.toThrow() + }) + + it('does not throw when output is a scalar, and still backfills URLs', () => { + const updated = reconcileArduinoCliConfig('output: "text"\n', ARDUINO_DATA) + expect(updated).not.toBeNull() + for (const url of urlsOf(ARDUINO_DATA)) expect(urlsOf(requireUpdated(updated))).toContain(url) + }) + + it('leaves a scalar board_manager untouched rather than guessing', () => { + // Nothing safe to merge into a scalar: leave it for the user to fix. + const updated = reconcileArduinoCliConfig('board_manager: 5\noutput:\n no_color: true\n', ARDUINO_DATA) + // The no_color retirement still happens; the URL backfill is skipped. + expect(updated).not.toContain('no_color') + expect(updated).toContain('board_manager: 5') + }) + + it('does not throw when additional_urls is a scalar', () => { + expect(() => reconcileArduinoCliConfig('board_manager:\n additional_urls: 7\n', ARDUINO_DATA)).not.toThrow() + }) + + it('leaves an unparseable config alone rather than clobbering it', () => { + expect(reconcileArduinoCliConfig('board_manager: [oops\n : :\n', ARDUINO_DATA)).toBeNull() + }) +}) + +describe('ARDUINO_DATA', () => { + it('no longer ships the obsolete colour suppression', () => { + // The console renders SGR colour now; forcing it off would make the + // renderer dead code on every fresh install. + expect(ARDUINO_DATA).not.toContain('no_color') + }) + + it('is valid YAML with a non-empty board manager list', () => { + expect(urlsOf(ARDUINO_DATA).length).toBeGreaterThan(0) + }) +}) diff --git a/src/backend/editor/services/user-service/data/arduino-cli-config.ts b/src/backend/editor/services/user-service/data/arduino-cli-config.ts new file mode 100644 index 000000000..f3c11b403 --- /dev/null +++ b/src/backend/editor/services/user-service/data/arduino-cli-config.ts @@ -0,0 +1,92 @@ +/** + * Reconcile an existing `arduino-cli.yaml` with the one the editor ships. + * + * The config used to be written once with `{ flag: 'wx' }` and skipped + * forever after, so anything added to `ARDUINO_DATA` later never reached an + * existing install — the only fix was deleting the file by hand. This brings + * a stale file up to date in place. + * + * Two rules, and deliberately only two: + * + * - **Add missing board-manager URLs.** Never remove one: users add their own + * vendor indexes here, and VPP-declared indexes arrive at compile time. + * - **Drop `output.no_color`.** The editor forced it on to stop raw `ESC[92m` + * bytes appearing in the console. The console now renders SGR colour + * itself, so the suppression is obsolete; leaving it behind would silently + * keep colour off on every machine that has ever launched an older build. + * + * Everything else is left exactly as the user left it, comments and ordering + * included — hence the `Document` API for the existing file rather than a + * parse/serialise round-trip through plain objects. + */ + +import { isMap, isSeq, parse, parseDocument } from 'yaml' + +const BOARD_MANAGER_URLS_PATH = ['board_manager', 'additional_urls'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Board-manager URLs declared by the shipped template (which we author). */ +function shippedBoardManagerUrls(shipped: string): string[] { + const parsed: unknown = parse(shipped) + if (!isRecord(parsed)) return [] + + const boardManager = parsed.board_manager + if (!isRecord(boardManager)) return [] + + const urls = boardManager.additional_urls + return Array.isArray(urls) ? urls.filter((url): url is string => typeof url === 'string') : [] +} + +/** + * Return the updated file contents, or `null` when nothing needed changing. + * + * Also returns `null` when `existing` is unparseable — a broken config is the + * user's to fix, and rewriting it would discard whatever they were editing. + */ +export function reconcileArduinoCliConfig(existing: string, shipped: string): string | null { + const doc = parseDocument(existing) + if (doc.errors.length > 0) return null + + let changed = false + + // Nested `*In()` calls walk the tree and throw ("Expected YAML collection + // at board_manager") if a parent turns out to be a scalar. A hand-edited + // config can absolutely be parseable-but-odd, and throwing here would abort + // the whole reconciliation — leaving the user with no migration and only a + // console error to explain it. So fetch each parent and check it first. + const boardManager = doc.get('board_manager') + const output = doc.get('output') + + // 1. Board-manager URLs — union, never subtract. + const shippedUrls = shippedBoardManagerUrls(shipped) + if (shippedUrls.length > 0 && (boardManager === undefined || boardManager === null || isMap(boardManager))) { + const current = isMap(boardManager) ? boardManager.get('additional_urls') : undefined + const present = new Set(isSeq(current) ? current.toJSON().map(String) : []) + const missing = shippedUrls.filter((url) => !present.has(url)) + + if (missing.length > 0) { + if (isSeq(current)) { + for (const url of missing) current.add(url) + } else { + // No `additional_urls` key, or it is not a list — install the shipped + // set wholesale rather than guessing at a merge. + doc.setIn(BOARD_MANAGER_URLS_PATH, shippedUrls) + } + changed = true + } + } + + // 2. Retire the obsolete colour suppression. + if (isMap(output) && output.has('no_color')) { + output.delete('no_color') + changed = true + + // Don't leave an empty `output:` behind once its only key is gone. + if (output.items.length === 0) doc.delete('output') + } + + return changed ? String(doc) : null +} diff --git a/src/backend/editor/services/user-service/data/types.ts b/src/backend/editor/services/user-service/data/types.ts index 57575b7dc..5d2c6dd8e 100644 --- a/src/backend/editor/services/user-service/data/types.ts +++ b/src/backend/editor/services/user-service/data/types.ts @@ -11,8 +11,6 @@ board_manager: - https://raw.githubusercontent.com/VEA-SRL/IRUINO_Library/main/package_vea_index.json - https://github.com/CONTROLLINO-PLC/controllino_rp2/releases/download/global/package_controllino_rp2_index.json - https://downloads.arduino.cc/packages/package_zephyr_index.json -output: - no_color: true ` export const HISTORY_DATA = { diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts index 3ef16c416..ee32c2742 100644 --- a/src/backend/editor/services/user-service/index.ts +++ b/src/backend/editor/services/user-service/index.ts @@ -5,6 +5,7 @@ import { access, constants, mkdir, readFile, rename, rm, writeFile } from 'fs/pr import { basename, join } from 'path' import { promisify } from 'util' +import { reconcileArduinoCliConfig } from './data/arduino-cli-config' import { ARDUINO_DATA, HISTORY_DATA, SETTINGS_DATA } from './data/types' import type { ArduinoListOutput } from './types' @@ -144,21 +145,24 @@ class UserService { } /** - * Ensure the Arduino CLI configuration file exists and carries every - * board-manager URL the editor ships with. + * Create the Arduino CLI configuration file, or bring an existing one up to + * date with what the editor ships. * - * 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. + * This used to write with `{ flag: 'wx' }` and swallow `EEXIST`, which made + * the file effectively write-once: an install that had launched an older + * build kept a stale config forever, and the only fix was deleting it by + * hand. Two things went stale that way — board-manager URLs added to + * `ARDUINO_DATA` never reached existing users, and `output.no_color` stayed + * on, which would keep the console monochrome even though it renders SGR + * colour itself now. * * 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. + * See `reconcileArduinoCliConfig` for the (deliberately narrow) rules. */ async #checkIfArduinoCliConfigExists(): Promise { const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') + try { await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) return @@ -169,30 +173,22 @@ class UserService { } } - // File already exists — reconcile its `additional_urls` with ours. + // File already exists — reconcile it with what we ship. 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')}`) - - await writeFile(pathToArduinoCliConfig, updated, 'utf-8') - console.warn(`Added ${missing.length} missing board manager URL(s) to ${pathToArduinoCliConfig}.`) + const updated = reconcileArduinoCliConfig(existing, UserService.ARDUINO_FILE_CONTENT) + if (!updated) return + + // Write via a sibling temp file and rename over the original. This runs + // on every start against a file the user owns and arduino-cli must be + // able to parse; a crash midway through a direct write would leave it + // truncated and break every build until the user deleted it by hand. + // `rename` within the same directory is atomic, so the config is either + // the old one or the new one, never half of each. + const tempPath = `${pathToArduinoCliConfig}.tmp` + await writeFile(tempPath, updated, 'utf-8') + await rename(tempPath, pathToArduinoCliConfig) + console.warn(`Updated Arduino CLI config at ${pathToArduinoCliConfig}.`) } catch (err) { console.error(`Error updating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) } diff --git a/src/frontend/components/_organisms/console/index.tsx b/src/frontend/components/_organisms/console/index.tsx index a6bcd49e9..76f45ca58 100644 --- a/src/frontend/components/_organisms/console/index.tsx +++ b/src/frontend/components/_organisms/console/index.tsx @@ -136,6 +136,7 @@ const Console = memo(() => { message={log.message} tstamp={formatTimestamp(log.tstamp ?? new Date(), filters.timestampFormat)} searchTerm={filters.searchTerm} + segments={log.segments} compileError={log.compileError} onCompileErrorClick={navigateToCompileError} /> diff --git a/src/frontend/components/_organisms/console/log.tsx b/src/frontend/components/_organisms/console/log.tsx index 66e2872e2..e0912705c 100644 --- a/src/frontend/components/_organisms/console/log.tsx +++ b/src/frontend/components/_organisms/console/log.tsx @@ -1,4 +1,4 @@ -import type { StructuredCompileError } from '@root/middleware/shared/ports/types' +import type { LogSegment, StructuredCompileError } from '@root/middleware/shared/ports/types' import { Copy } from 'lucide-react' import { ComponentPropsWithoutRef, useCallback, useEffect, useRef, useState } from 'react' @@ -18,6 +18,9 @@ type LogComponentProps = ComponentPropsWithoutRef<'p'> & { message: string tstamp: string searchTerm?: string + /** Styled runs when the tool emitted SGR colour; `message` is the same + * text with the escapes stripped. */ + segments?: LogSegment[] /** When set, the bracketed POU prefix on the first line becomes a * click-to-open button that calls {@link onCompileErrorClick}. * Multi-line gcc-style snippet renders as plain pre-wrapped text @@ -63,6 +66,35 @@ const HighlightedText = ({ text, searchTerm }: { text: string; searchTerm?: stri return <>{parts} } +/** + * The message body of a log line. + * + * Applies the tool's SGR colour when the line carried any, and otherwise + * renders exactly as before. Search highlighting runs inside each styled run + * so a match spanning a colour change still highlights correctly. + */ +const MessageBody = ({ + message, + segments, + searchTerm, +}: { + message: string + segments?: LogSegment[] + searchTerm?: string +}) => { + if (!segments?.length) return + + return ( + <> + {segments.map((segment, index) => ( + + + + ))} + + ) +} + /** * Render a compile-error log line: the first line (the bracketed POU * prefix the compiler-module emits, e.g. `[Manual_Override / body @@ -118,6 +150,7 @@ const LogComponent = ({ message, tstamp, searchTerm, + segments, compileError, onCompileErrorClick, ...rest @@ -156,22 +189,13 @@ const LogComponent = ({ {message && (

- {level && tstamp ? ( + {level && tstamp && ( <> [ ]:{' '} - {compileError ? ( - - ) : ( - - )} - ) : compileError ? ( + )} + {compileError ? ( ) : ( - + )}